Skip to content

Fix critical/high-severity correctness and security bugs found in an audit - #432

Draft
franv314 wants to merge 7 commits into
olimpiadi-informatica:masterfrom
franv314:fix/audit-critical-high
Draft

Fix critical/high-severity correctness and security bugs found in an audit#432
franv314 wants to merge 7 commits into
olimpiadi-informatica:masterfrom
franv314:fix/audit-critical-high

Conversation

@franv314

@franv314 franv314 commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

A focused audit of the codebase surfaced 23 issues; this PR fixes the 8 rated critical/high severity (the remaining medium/low findings, including one described below, are left for a follow-up):

  • C#/JavaScript runtime argument order (task-maker-lang): runtime_args() appended the executable/script path after the user's own arguments instead of before, so mono/node try to load the wrong thing as soon as any argv is passed - which task-maker's own checkers/generators/validators/Terry solutions always do. Verified against real mono/node binaries: old order fails with "file not found" / MODULE_NOT_FOUND, new order works.
  • u64 underflow in cache compatibility check (task-maker-cache): self.extra_memory - extra_memory could underflow when --extra-memory differs between the cached and current run, corrupting the following limit comparison and letting a stale cache entry be served.
  • Duplicate testcase counting in gen.toml subtasks (task-maker-format): a testcase reachable through two included groups was counted twice, silently doubling its weight in Sum-aggregated subtask scores.
  • Inverted LRU eviction (task-maker-store): flush() used a BinaryHeap (max-heap) and evicted the most recently used files first instead of the least recently used, defeating the file-store cache.
  • Shell injection in the Java compile command (task-maker-lang): the javac/jar invocation spliced the binary name and main class unescaped into a sh -c string; reachable via the network-facing eval_server tool.
  • Out-of-bounds panic on checker-reported index (task-maker-format, Terry): a checker reporting a subtask testcase index past the end of the real list crashed the whole evaluation.
  • Panic in AllOutputsEqual on empty outputs (task-maker-format, IOI): a subtask whose testcases all have byte-identical empty outputs crashed the exact sanity check meant to catch that situation.
  • Missing subset check for extra_readable_dirs (task-maker-cache): the cache compatibility check only rejected a strict superset relationship, missing the case of two differing, non-nested sets of granted directories.

Each fix has a dedicated commit with a full explanation; see individual commit messages for details, root cause, and trigger scenario.

Dropped from this PR after further investigation

An earlier revision of this PR also included a fix for a theoretical race condition in ControllerKeeper::run() (task-maker-exec), where reserving a result slot and writing the corresponding config to the controller's stdin were two independently-locked steps. On paper, concurrent solution starts could have their results/pids cross-wired. However, tracing (and empirically confirming with real timing instrumentation) the only caller, sandbox_manager, showed it always blocks on wait_for_pipes() after each START_SOLUTION request - which can't return until the corresponding run() call has already completed its critical section - so the two-thread overlap this would require never actually occurs, regardless of how the controller program itself behaves. The underlying code pattern is still fragile (relies on an implementation detail of its sole caller rather than an explicit invariant), but it's not an active bug, so it's been reclassified as low severity and removed from this PR.

Test plan

  • cargo build --workspace and cargo test --workspace --lib (all green)
  • cargo clippy --workspace --all-targets -- -D warnings (clean)
  • cargo fmt --check (clean)
  • New regression tests added for every fix
  • C#/JS argument order additionally smoke-tested against real mono/node binaries
  • Java shell-quoting fix additionally smoke-tested against a real injection payload

mono/node treat the first non-option token as the program to load, but
runtime_args() appended the executable/script path after the user's
own arguments instead of before them. As soon as any argv is passed -
which task-maker's own checkers, generators, validators and Terry
solutions always do - mono/node try to load the first argument as the
program and fail immediately, making every C#/JS checker, generator,
validator or Terry solution non-functional.
@franv314
franv314 marked this pull request as draft August 26, 2026 18:58
CacheEntry::is_compatible() computed self.extra_memory - extra_memory
(both u64) assuming the cached value is never smaller than the query's,
but --extra-memory legitimately varies between runs. When it doesn't
hold, the subtraction underflows and (in release builds, with no
overflow-checks) corrupts the following limit comparison, letting a
stale, incompatible cache entry be served for a grading run. Fixed by
comparing left + left_extra > right + right_extra directly instead of
pre-computing a delta, which works regardless of which side is larger.

Also fixes the extra_readable_dirs check, which only rejected a strict
superset relationship; two differing, non-nested sets of granted
directories (neither a subset of the other) were incorrectly treated
as compatible even though the cached run could have depended on access
the new run doesn't grant.
While resolving a subtask's included groups, testcase ids were
appended for every group visited and only sorted afterwards, never
deduplicated. A testcase reachable through more than one included
group (its own group and a group_name override both ending up in the
same subtask) ended up listed twice in subtask.testcases. Since Sum
aggregation divides by the length of that list, the duplicate silently
doubled that testcase's weight in the subtask's average score.
FileStoreIndex::flush() popped candidates from a BinaryHeap ordered by
last_access, but BinaryHeap is a max-heap, so pop() returned the *most*
recently used entry first - the opposite of the documented "least
recently used" eviction contract. Whenever the store needed to reclaim
space with more than one unlocked candidate, it deleted the most
recently produced or reused files while ancient, untouched ones
survived, defeating the cache and causing avoidable recomputation.

Fixed by wrapping heap entries in Reverse so pop() returns the oldest
entry first. Added a regression test with explicit, well-separated
timestamps set directly on the index entries; the existing tests
weren't sensitive enough to catch this (they still pass against the
old, unfixed logic too - likely because their back-to-back
SystemTime::now() calls don't reliably produce distinct timestamps,
falling back to an incidental tie-break).
The javac/jar invocation was built as a single shell string with the
binary name and main class spliced in unescaped, then run through
sh -c. A source filename containing shell metacharacters (backticks,
\$(...), ;) could execute arbitrary commands during compilation; this
is reachable over the network through the eval_server tool, whose
filename validation doesn't reject shell metacharacters or spaces.
Even without malice, a legitimate filename containing a space broke
compilation. Every other compiled language builds an argv array
instead of a shell string and was unaffected.

Fixed by single-quoting both interpolated values before splicing them
into the shell command (the glob-based javac/jar invocation itself
still needs a shell, so it can't be switched to a plain argv command).
print_subtasks() indexed evaluation_results[testcase] with a usize
taken directly from the checker's own JSON stdout, with no bounds
check anywhere in the pipeline - unlike the safe .zip() used a few
lines above for the analogous feedback loop. Any Terry checker that
reports a subtask testcase index past the end of the real testcase
list crashed the whole evaluation after every execution had already
finished, instead of surfacing a diagnostic.
first_chunk is only populated for non-empty chunks, so it stays None
for a zero-byte output file, but the check unconditionally unwrapped
it once it decided the outputs were identical. A subtask whose
testcases all have a byte-identical empty official output - a
legitimate "print nothing" task, or exactly the broken-generator
situation this check exists to catch - made the sanity-check pass
panic instead of warning about it.
@franv314
franv314 force-pushed the fix/audit-critical-high branch from b3a0e70 to df4604d Compare August 26, 2026 21:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant