Repository navigation
Fix .eval reads failing with AttributeError '_needs_input' on Pythons carrying the gh-156002 zipfile change - #5209
Merged
epatey merged 8 commits intoSep 9, 2026
Conversation
CPython gh-156002 (CVE-2026-15310, "Memory exhaustion via crafted zip file") makes ZipExtFile._read1 call decompress(data, max_length) on non-deflate decompressors and consult needs_input before reading more compressed bytes. The multi-frame zstd decompressobj registered for ZIP_ZSTANDARD exposed neither, so every zstd member read raised AttributeError: '_MultiFrameZstdDecompressObj' object has no attribute '_needs_input' on interpreters that ship the change: Docker Hardened Images' python:3.13-dev since 2026-09-03 (it applies the open 3.13 backport python/cpython#156738 verbatim), and every maintained CPython once #156737-#156741 merge. Accept max_length without enforcing it (the zstandard backport has no output bound; withholding bytes would add copies without bounding memory) and report needs_input=True (decompress always drains its input and emits all output). For this wrapper the new _read1 thereby reduces exactly to the old one. Tests cover the new call shape on any interpreter (a double of the post- gh-156002 _read1 loop), the unchanged one-arg shape, partial input across frame boundaries, and read/read1/readline round trips of a multi-frame entry.
…sor-needs-input # Conflicts: # CHANGELOG.md
Contributor
Suppression ledger changed: 788 → 787 (-1)
Reason-less (baselined) suppressions: 777 → 776 |
…fined The test file added seven reason-less type: ignore[attr-defined] comments, which the suppressions ledger rejects. Return the wrapper class from the helper (asserting on the private zipfile._get_decompressor result) so no suppression is needed, and record the ledger shrink from the now-used import.
rasmusfaber
force-pushed
the
fix/zipfile-decompressor-needs-input
branch
from
September 3, 2026 11:38
4d11c86 to
c8ae2de
Compare
…uble The three direct-call tests were redundant with the _read1 double and the existing round trips, and the double's payload compressed to 519 bytes so every parametrized read size took a single call. Use an incompressible payload so 4096 slices the stream across the frame boundary and 65_536 does not, and drop the n=1 row (identical to 4096 after MIN_READ_SIZE).
rasmusfaber
marked this pull request as ready for review
September 3, 2026 11:48
rasmusfaber
added a commit
to METR/inspect_ai
that referenced
this pull request
Sep 3, 2026
…UKGovernmentBEIS#5209) Squashed cherry-pick of UKGovernmentBEIS#5209 (e5331c1, c8ae2de, 956dd30), restricted to src/ and tests/: the PR also drops a suppressions.json entry, and that file does not exist at 0.3.261. CPython gh-156002 (CVE-2026-15310) rewrote ZipExtFile._read1 to probe `needs_input` and call `decompress(data, max_length)` on non-deflate decompressors. `_MultiFrameZstdDecompressObj` wraps the zstandard backport's one-argument decompressobj and exposes neither, so every read of a zstd-compressed .eval member raises AttributeError '_needs_input'. Hawk hit this via dhi.io/python:3.13-dev, which republished on 2026-09-03 with the still-open 3.13 backport applied under an unchanged 3.13.15 version string. This is the reviewed upstream version of the fix, not the earlier revision hawk pinned as a hotfix in METR/hawk#1603: comments trimmed, the tests folded into a single _read1 double, and the test helper typed rather than suppressed.
epatey
approved these changes
Sep 9, 2026
epatey
left a comment
Collaborator
There was a problem hiding this comment.
Approved at 956dd30.
The fix does what it says. With needs_input always True and eof always False, the post-gh-156002 _read1 loop reads on every iteration and stops when compress_left reaches zero, the same as before the CPython change. I checked that against the merged 3.14 backport (python/cpython#156737), and the _read1 double in the test matches that code. Accepting max_length without enforcing it keeps the existing behavior for zstd members, which is fine here.
One note, take or leave: the 3.14 backport merged a few hours after this PR opened, so 3.14.8 will carry the change for everyone, not only hardened-image users.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR contains:
What is the current behavior? (You can also link to an open issue here)
On interpreters whose
zipfilecarries CPython gh-156002 (CVE-2026-15310, "Memory exhaustion via crafted zip file"), every read of a zstd-compressed.evalmember fails:The patched
ZipExtFile._read1now drives non-deflate decompressors like the stdlibbz2/lzma/compression.zstdones: it probesneeds_inputbefore reading more compressed bytes and callsdecompress(data, max_length)with two positional arguments._MultiFrameZstdDecompressObj(src/inspect_ai/_util/zipfile.py), which is registered forZIP_ZSTANDARDon every Python version and wraps thezstandardbackport's one-argumentdecompressobj, exposes neither.Where this bites today: Docker Hardened Images'
python:3.13-devrepublished on 2026-09-03 with the open 3.13 backport python/cpython#156738 applied verbatim (same 3.13.15 version string). The fix is on CPythonmainand 3.15; backports #156737–#156741 are open for 3.10–3.14, so every maintained CPython will ship this (3.13.16 / 3.14.8 are scheduled for 2026-10-06). User-facing symptom:eval_setwrites asuccesslog, cannot read it back, treats the task as incomplete and re-runs it1 + retry_attemptstimes.What is the new behavior?
_MultiFrameZstdDecompressObj.decompressacceptsmax_length(not enforced, see below) and the object reportsneeds_input = True. For this wrapper the patched_read1thereby reduces exactly to the pre-patch one: it always reads, always gets everything, and_eofis still driven bycompress_left <= 0..evalreads work on stock and patched interpreters alike.Why
max_lengthis not enforced: thezstandarddecompressobj has no output bound of its own. Withholding bytes in Python would only add copies on top of an expansion that is already allocated (peak RSS measured identical), and a naive re-slice is quadratic under smallread(n)calls (256 MiB of zeros underread(4096): >400 s vs 0.4 s). Rationing compressed input instead is ~50× slower on the whole-memberzf.read()path inspect uses and bounds nothing on a real bomb whose compressed stream is smaller than the ration. So the gh-156002 memory bound is not delivered for zstd members — the same exposure as before this change; a truly bounded wrapper would needcompression.zstd/backports.zstdas the inner decompressor, which is a larger change.Tests: a small double of the post-gh-156002
_read1loop exercises the new call shape on any interpreter (including the stdlib'sgetattr(..., "needs_input", None)fallback and the new termination formula), plus the unchanged one-argument shape, partial input across a frame boundary, andread/read1/readlineround trips of a genuinely multi-frame entry. With the fix reverted, 6 of the 7 new tests fail on a stock interpreter (AttributeError/TypeError).Verified:
tests/util/test_zipfile_multiframe.py+tests/util/test_async_zip.pypass on 3.13.12 stock, 3.13.12 with the DHI/PR-156738 zipfile shimmed ontoPYTHONPATH, 3.14.3 stock (nativecompression.zstdpresent), 3.14.3 with the PR-156737 zipfile shimmed in, and inside the realdhi.io/python:3.13-devimage.ruff check,ruff format --checkandmypyare clean on the changed files.Does this PR introduce a breaking change? (What changes might users need to make in their application due to this PR?)
No.
Other information:
Note for maintainers: CI here tests 3.10 and 3.11 only, so this will not surface upstream until those backports ship (their PRs are open); a 3.14 leg would catch it first. Every other third-party
zipfile._get_decompressorpatcher (zipfile-zstditself,zipfile-deflate64,zipfile-inflate64) breaks identically and none has adapted yet.Tooling: authored with Claude Code (Claude Fable 5.1).
Agent review
_read1state machine; stdlib decompressor contract; design alternatives with measurements; test quality incl. mutation testing; downstream blast radius). Passes 1–3 ran executed differential/fuzz harnesses (e.g. 18 entry shapes × 17 access patterns incl. random seeks, data-descriptor and encrypted entries, 20,000-frame streams; 1,500 random multi-frame slicings per runtime) on stock and patched zipfiles across 3.13/3.14 and the real DHI image — all byte-identical to stock._read1double; over-absolute "cannot bound its output" comment → reworded; docstring cited a vendor image rather than CPython issue/PR numbers → cites gh-156002 and the backport PRs), 3 dismissed (a fresh re-import ofzipfile_zstdafter our patch would shadow the wrapper — only reachable by deleting it fromsys.modules;max_length=0returns everything where the stdlib returnsb""— zipfile never passes 0, observed minimum 4096; pre-existingbytes +=copies putread()peak at ~2.4× output — predates this change, out of scope). No pass refuted the fix.