From c8a6beef5dc26f671687354ae0c4d46b1399e18b Mon Sep 17 00:00:00 2001 From: Wenyuan Huang <157926136+whuang369@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:08:42 -0500 Subject: [PATCH] Fix #202, #203, #204: close scoring exploits in problems 112, 107 and 28 Each issue lets a submission that does no real work score 100. #202, problem 112 checker: testlib reads an overflowing literal such as 1e400 as inf. The relative tolerance test |actual - claimed| <= 1e-6 * max(1, |actual|, |claimed|) then compares inf with inf and passes for any placement, even with every point at the origin, and the score caps at 100. Reject a claimed distance that is not finite. An infinite coordinate needs no extra check, because the unit sphere test already rejects it. #203, problem 107 interactor: the guess d was compared in 32-bit int, so 2 * d overflows for a guess such as -1500000000 (in practice it wraps to 1294967296) and the factor-of-two test passes without any query. Compare in 64 bits. Also print the out-of-range query value with %lld instead of %d. #204, problem 28 interactor: the answer check only required each word to be at most the last dictionary word with the same first letter and never checked that the word is in the dictionary, so "answer a0 a1 ... a999" with no query scored 100. Check each answered word against the dictionary. Repeats are already rejected, so n accepted words are the whole dictionary; an ensuref checks that the word lists hold exactly n words. The first character of a query prefix or answer word also indexed s[] and num[] without a bounds check, so "query 0a 1" or an answer word starting with 'A' crashed the interactor. A prefix that does not start with a lowercase letter now matches nothing, as '{' to '~' already did. Verdicts and scores for valid play are unchanged. Verified in the frontier-cs-judge image (g++ 11.4, -O2 -std=gnu++17): all 42 new tests in algorithmic/tests/test_problem{28,107,112}.py pass, and on main the 12 that reproduce the exploits and crashes fail. The other 30 pass on both, including problem 107's error bounds and the full dictionary of each problem 28 test (ratio 1). Fixes #202, fixes #203, fixes #204. Co-Authored-By: Claude Opus 5.5 --- algorithmic/problems/107/interactor.cpp | 5 +- algorithmic/problems/112/chk.cc | 6 ++ algorithmic/problems/28/interactor.cc | 12 +++- algorithmic/tests/test_problem107.py | 64 +++++++++++++++++++ algorithmic/tests/test_problem112.py | 66 +++++++++++++++++++ algorithmic/tests/test_problem28.py | 85 +++++++++++++++++++++++++ 6 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 algorithmic/tests/test_problem107.py create mode 100644 algorithmic/tests/test_problem112.py create mode 100644 algorithmic/tests/test_problem28.py diff --git a/algorithmic/problems/107/interactor.cpp b/algorithmic/problems/107/interactor.cpp index b180e6c18..7989d910c 100644 --- a/algorithmic/problems/107/interactor.cpp +++ b/algorithmic/problems/107/interactor.cpp @@ -43,12 +43,13 @@ int main(int argc, char* argv[]) { if (op == 0) { i64 y = ouf.readLong(); if (y < 1 || y > 1000000000000000000) { - quitf(_wa, "Invalid query: element %d is out of range [1, 1000000000000000000]", y); + quitf(_wa, "Invalid query: element %lld is out of range [1, 1000000000000000000]", y); } println(std::gcd(x, y)); } else if (op == 1) { - int a = ans.readInt(), d = ouf.readInt(); + // The guess may be any int, so 2 * d and a - d need 64 bits. + i64 a = ans.readInt(), d = ouf.readInt(); if (std::abs(a - d) <= 7 || (2 * a >= d && 2 * d >= a)) { max = std::max(max, q); break; diff --git a/algorithmic/problems/112/chk.cc b/algorithmic/problems/112/chk.cc index 71bf75d89..b16b8d079 100644 --- a/algorithmic/problems/112/chk.cc +++ b/algorithmic/problems/112/chk.cc @@ -59,6 +59,12 @@ int main(int argc, char* argv[]) { double claimed_min_dist = ouf.readDouble(); + // testlib reads an overflowing literal such as 1e400 as inf, and the + // relative comparison below would treat inf as equal to any distance. + if (!std::isfinite(claimed_min_dist)) { + quitf(_wa, "Minimum distance is not a finite number"); + } + if (claimed_min_dist < 0) { quitf(_wa, "Minimum distance cannot be negative: %.10f", claimed_min_dist); } diff --git a/algorithmic/problems/28/interactor.cc b/algorithmic/problems/28/interactor.cc index 07924e81d..055d1144b 100644 --- a/algorithmic/problems/28/interactor.cc +++ b/algorithmic/problems/28/interactor.cc @@ -24,6 +24,7 @@ static double referenceScore(double r) { #include #include #include +#include using namespace std; @@ -32,6 +33,7 @@ char current[15]; int num[30]; int cnt = 0; mapvis; +setdict; void dfs(int now, int start){ if(cnt >= 1000)return; @@ -69,7 +71,9 @@ int main(int argc, char *argv[]) { int t = inf.readInt(), n = inf.readInt(); for(int i = 0; i < 26; i++){ num[i] = inf.readInt(); + for(int j = 1; j <= num[i]; j++)dict.insert(s[i][j]); } + ensuref((int)dict.size() == n, "The word lists hold %d words, but N is %d", (int)dict.size(), n); cout<= 'a' && qs[0] <= 'z') ? num[start] : 0; int match_cnt = 0; string ret[1010]; - for(int j = 1; j <= num[start]; j++){ + for(int j = 1; j <= limit; j++){ int match = 1; for(int t = 0; t < qs.length(); t++){ if(s[start][j][t] != qs[t])match = 0; @@ -105,10 +111,10 @@ int main(int argc, char *argv[]) { cout.flush(); } else if(operation == "answer"){ + // n distinct dictionary words are the whole dictionary. for(int j = 1; j <= n; j++){ string answers = ouf.readWord(); - int start = answers[0]-'a'; - if(answers > s[start][num[start]])quitf(_wa, "Invalid answer"); + if(!dict.count(answers))quitf(_wa, "Invalid answer"); if(vis[answers])quitf(_wa, "Repeated answer"); vis[answers] = 1; } diff --git a/algorithmic/tests/test_problem107.py b/algorithmic/tests/test_problem107.py new file mode 100644 index 000000000..3b6d6631c --- /dev/null +++ b/algorithmic/tests/test_problem107.py @@ -0,0 +1,64 @@ +"""A guess must not pass the error check by overflowing 2 * d (#203).""" +import math +from pathlib import Path +import re +import subprocess +import pytest + +ROOT = Path(__file__).resolve().parents[2] +DATA = ROOT / 'algorithmic/problems/107/testdata' + + +@pytest.fixture(scope='module') +def interactor(tmp_path_factory): + executable = tmp_path_factory.mktemp('interactor107') / 'interactor' + subprocess.run(['g++', '-std=gnu++17', '-O2', '-I' + str(ROOT / 'algorithmic/judge/include'), + str(ROOT / 'algorithmic/problems/107/interactor.cpp'), '-o', str(executable)], check=True) + return executable + + +def play(interactor, tmp_path, data, answer, contestant): + inf, transcript, ans = [tmp_path / name for name in ('in', 'transcript', 'ans')] + inf.write_text(data);ans.write_text(answer) + return subprocess.run([str(interactor), str(inf), str(transcript), str(ans)], + input=contestant, text=True, capture_output=True, timeout=5) + + +def ratio(result): + return float(re.search(r'Ratio: ([0-9.]+)', result.stderr).group(1)) + + +# 720720 has 240 divisors. In 32-bit arithmetic the first three guesses pass, +# because 2 * d wraps to a positive value. +@pytest.mark.parametrize('guess', [-1500000000, -1073741825, -2147483528, -2147483648, 2147483647]) +def test_overflowing_guess_is_wrong(interactor, tmp_path, guess): + result = play(interactor, tmp_path, '1\n720720\n', '240\n', '1 %d\n' % guess) + assert result.returncode == 7, result.stderr + assert 'Wrong guess' in result.stderr and ratio(result) == 0 + + +def test_issue_repro_on_testdata_scores_zero(interactor, tmp_path): + games = int((DATA / '1.in').read_text().split()[0]) + result = play(interactor, tmp_path, (DATA / '1.in').read_text(), (DATA / '1.ans').read_text(), + '1 -1500000000\n' * games) + assert result.returncode == 7, result.stderr + assert 'Wrong guess' in result.stderr and ratio(result) == 0 + + +# Accepted iff |guess - divisors| <= 7 or 1/2 <= guess/divisors <= 2. +@pytest.mark.parametrize('x,divisors,guess,correct', [ + (720720, 240, 120, True), (720720, 240, 119, False), (720720, 240, 480, True), (720720, 240, 481, False), + (1, 1, -6, True), (1, 1, -7, False), (1, 1, 8, True), (1, 1, 9, False), +]) +def test_error_bounds_are_unchanged(interactor, tmp_path, x, divisors, guess, correct): + result = play(interactor, tmp_path, '1\n%d\n' % x, '%d\n' % divisors, '1 %d\n' % guess) + assert result.returncode == 7, result.stderr + assert ('Wrong guess' not in result.stderr) == correct + assert ratio(result) == (1 if correct else 0) + + +def test_query_count_still_scores(interactor, tmp_path): + result = play(interactor, tmp_path, '1\n720720\n', '240\n', '0 16\n1 240\n') + assert result.returncode == 7, result.stderr + assert result.stdout.split() == ['1', '16'] + assert ratio(result) == pytest.approx((60 + math.log(40)) / 100, abs=1e-9) diff --git a/algorithmic/tests/test_problem112.py b/algorithmic/tests/test_problem112.py new file mode 100644 index 000000000..a7b997bb4 --- /dev/null +++ b/algorithmic/tests/test_problem112.py @@ -0,0 +1,66 @@ +"""A claimed distance must be finite: testlib reads 1e400 as inf (#202).""" +import math +from pathlib import Path +import re +import subprocess +import pytest + +ROOT = Path(__file__).resolve().parents[2] +DATA = ROOT / 'algorithmic/problems/112/testdata' +N = int((DATA / '12.in').read_text()) + + +@pytest.fixture(scope='module') +def checker(tmp_path_factory): + executable = tmp_path_factory.mktemp('checker112') / 'checker' + subprocess.run(['g++', '-std=gnu++17', '-O2', '-I' + str(ROOT / 'algorithmic/judge/include'), + str(ROOT / 'algorithmic/problems/112/chk.cc'), '-o', str(executable)], check=True) + return executable + + +def spread(n): + # Fibonacci lattice on the unit sphere. + points = [] + for i in range(n): + z = 1 - (2 * i + 1) / n + theta = i * math.pi * (3 - math.sqrt(5)) + points.append((math.sqrt(1 - z * z) * math.cos(theta), math.sqrt(1 - z * z) * math.sin(theta), z)) + return points + + +def output(claim, points): + return claim + '\n' + ''.join('%.17g %.17g %.17g\n' % p for p in points) + + +def check(checker, tmp_path, text): + out = tmp_path / 'output' + out.write_text(text) + return subprocess.run([str(checker), str(DATA / '12.in'), str(out), str(DATA / '12.ans')], + text=True, capture_output=True, timeout=10) + + +@pytest.mark.parametrize('claim', ['1e400', '1e309']) +@pytest.mark.parametrize('placement', ['origin', 'spread']) +def test_infinite_claim_is_wrong(checker, tmp_path, claim, placement): + points = [(0.0, 0.0, 0.0)] * N if placement == 'origin' else spread(N) + result = check(checker, tmp_path, output(claim, points)) + assert result.returncode == 1, result.stderr + assert 'not a finite number' in result.stderr + + +def test_infinite_coordinate_is_wrong(checker, tmp_path): + # The sphere check rejects it, since inf * inf is inf; testlib never reads a NaN. + result = check(checker, tmp_path, '0\n1e400 0 0\n' + '0 0 0\n' * (N - 1)) + assert result.returncode == 1, result.stderr + assert 'outside the unit sphere' in result.stderr + + +def test_matching_claim_still_scores(checker, tmp_path): + points = spread(N) + claim = min(math.dist(p, q) for i, p in enumerate(points) for q in points[:i]) + result = check(checker, tmp_path, output(repr(claim), points)) + assert result.returncode == 7, result.stderr + ref = float((DATA / '12.ans').read_text().split()[0]) + lower = 60 * claim / ref + expected = lower if lower <= 60 else min(100, 60 + math.log(claim / ref)) + assert float(re.search(r'Ratio: ([0-9.]+)', result.stderr).group(1)) == pytest.approx(expected / 100, abs=1e-9) diff --git a/algorithmic/tests/test_problem28.py b/algorithmic/tests/test_problem28.py new file mode 100644 index 000000000..e8325517c --- /dev/null +++ b/algorithmic/tests/test_problem28.py @@ -0,0 +1,85 @@ +"""The answer must be the dictionary itself, not any n distinct words (#204).""" +import math +from pathlib import Path +import re +import subprocess +import pytest + +ROOT = Path(__file__).resolve().parents[2] +DATA = ROOT / 'algorithmic/problems/28/testdata' + + +@pytest.fixture(scope='module') +def interactor(tmp_path_factory): + executable = tmp_path_factory.mktemp('interactor28') / 'interactor' + subprocess.run(['g++', '-std=gnu++17', '-O2', '-I' + str(ROOT / 'algorithmic/judge/include'), + str(ROOT / 'algorithmic/problems/28/interactor.cc'), '-o', str(executable)], check=True) + return executable + + +def words(letter, count): + # The interactor's list for a letter: the first `count` ten-letter words that start with it. + return [letter + 'aaaaaa' + ''.join(chr(97 + j // 26**k % 26) for k in (2, 1, 0)) for j in range(count)] + + +def play(interactor, tmp_path, data, contestant): + inf, transcript, ans = [tmp_path / name for name in ('in', 'transcript', 'ans')] + inf.write_text(data);ans.write_text('0\n') + return subprocess.run([str(interactor), str(inf), str(transcript), str(ans)], + input=contestant, text=True, capture_output=True, timeout=5) + + +def ratio(result): + return float(re.search(r'Ratio: ([0-9.]+)', result.stderr).group(1)) + + +# Two words start with 'a' and two with 'c'. +SMALL = '1\n4\n2 0 2' + ' 0' * 23 + '\n' +DICTIONARY = words('a', 2) + words('c', 2) + + +@pytest.mark.parametrize('answer', [ + ['a0', 'a1', 'a2', 'a3'], + DICTIONARY[:3] + ['c'], + DICTIONARY[:3] + ['Aaaaaaaaaa'], + DICTIONARY[:3] + ['baaaaaaaaa'], + DICTIONARY[:3] + ['aaaaaaaaac'], +]) +def test_word_outside_the_dictionary_is_wrong(interactor, tmp_path, answer): + result = play(interactor, tmp_path, SMALL, 'answer ' + ' '.join(answer) + '\n') + assert result.returncode == 1, result.stderr + assert 'Invalid answer' in result.stderr + + +def test_repeated_word_is_wrong(interactor, tmp_path): + answer = [DICTIONARY[0]] + DICTIONARY[:3] + result = play(interactor, tmp_path, SMALL, 'answer ' + ' '.join(answer) + '\n') + assert result.returncode == 1, result.stderr + assert 'Repeated answer' in result.stderr + + +# The first character used to index s[] and num[] unchecked; only '{' to '~' +# landed in the four spare rows. +@pytest.mark.parametrize('prefix', ['A', '0a', '\x7f', '{']) +def test_prefix_without_a_leading_letter_matches_nothing(interactor, tmp_path, prefix): + result = play(interactor, tmp_path, SMALL, 'query %s 1\nanswer %s\n' % (prefix, ' '.join(DICTIONARY))) + assert result.returncode == 7, result.stderr + assert result.stdout.split()[2:] == ['0'] + assert ratio(result) == pytest.approx((60 + math.log(2400)) / 100, abs=1e-9) + + +def test_dictionary_in_any_order_scores(interactor, tmp_path): + result = play(interactor, tmp_path, SMALL, 'query a 2\nquery c 2\nanswer ' + ' '.join(DICTIONARY[::-1]) + '\n') + assert result.returncode == 7, result.stderr + assert result.stdout.split()[2:] == ['2'] + DICTIONARY[:2] + ['2'] + DICTIONARY[2:] + assert ratio(result) == pytest.approx((60 + math.log(2400 / 4)) / 100, abs=1e-9) + + +@pytest.mark.parametrize('test', range(1, 11)) +def test_full_dictionary_of_each_test_is_accepted(interactor, tmp_path, test): + data = (DATA / ('%d.in' % test)).read_text() + counts = list(map(int, data.split()[2:28])) + answer = [w for i, count in enumerate(counts) for w in words(chr(97 + i), count)] + result = play(interactor, tmp_path, data, 'answer ' + ' '.join(answer) + '\n') + assert result.returncode == 7, result.stderr + assert ratio(result) == 1