Skip to content

fix: fallible alloc helpers + incremental ICC profile cap#141

Merged
lilith merged 7 commits into
mainfrom
fix/security-audit-2026-05-06
May 7, 2026
Merged

fix: fallible alloc helpers + incremental ICC profile cap#141
lilith merged 7 commits into
mainfrom
fix/security-audit-2026-05-06

Conversation

@lilith
Copy link
Copy Markdown
Member

@lilith lilith commented May 6, 2026

Hardening based on internal security audit (/home/lilith/work/feedback/security-audit-2026-05-06/zenjpeg.md). Addresses HIGH findings H2/H3/H4. H1 (MemoryTracker parser-side wiring) was reverted as YAGNI — Limits::max_input_bytes + max_pixels already bound JPEG decode memory transitively via the entry-point gates that already fire.

Changes

  • H4 (42b152c): extract_icc_profile now checks the 16 MB cap incrementally against running_total + chunk_len before copying each chunk. Previously the full set was accumulated, then capped — peak was 2× cap. The cap fires before any chunk that would exceed it. Added regression test that builds 2× the cap of valid APP2 chunks and asserts None.

  • H2/H3 (01a165c): try_alloc_maybeuninit, try_alloc_zeroed_bytes, try_alloc_dct_blocks now use Vec::try_reserve_exact + resize (was vec![T::default(); n] which panics on OOM). The Linux lazy-zero-page fast path is preserved by resize on already-allocated capacity. Replaced bare vec! with try_alloc_filled for coeff_counts and nonzero bitmaps in scan.rs/progressive.rs.

  • revert (81a69bf): drops the H1 MemoryTracker parser-side wiring as YAGNI. The MemoryTracker struct itself remains in src/decoder/foundation/alloc.rs as dormant code; reverting this revert is one commit if a future need emerges.

Tests

cargo test --lib: 951 passed. cargo test --doc: 10 passed. CI green on 18/18 platforms (windows-11-arm, macos-26-intel, i686, wasm32-wasip1).

Deferred

MEDIUM and LOW findings deferred to follow-up.

lilith added 4 commits May 4, 2026 10:01
…iant

Step-0 follow-up to per_class_signal_probe.rs (commit 2423a8a). The
prior probe identified a real screen-vs-photo at-q ΔBA asymmetry in
the bottom-right 4×4 HF block, but the perturbation tested (force
quant=1, "preserve all HF energy") was the wrong direction — it
spent +37% to +283% bytes to gain at-q quality, losing badly at
matched bytes.

This variant tests the OPPOSITE perturbation per the prior report's
explicit Step-0 recommendation: multiply the bottom-right 4×4 quant
entries (luma + Cb + Cr) by 2x / 4x / 8x, clamp to [1, 255].

Hypothesis under test: screens have low HF AC energy, so coarsening
HF should be ~free for screens and damaging for photos. If true,
that's the asymmetric-cost signal the brief required to justify
6-10 hr per-class SA + cloud budget.

Result (840 cells across 4 clusters x 3 variants x 3 q anchors):
gate fails on every cell. Photo q=85 Bp_8x ΔBA +0.264; screen q=85
Bp_8x ΔBA +0.757 — screens degrade 3x more than photos under the
same perturbation. The directional hypothesis is falsified.

Companion benchmark report:
zenanalyze/benchmarks/per_class_signal_probe_bprime_2026-05-04.md
extract_icc_profile previously accumulated every APP2/ICC chunk into a
Vec<(u8, Vec<u8>)> BEFORE checking the cumulative size against
MAX_ICC_PROFILE_SIZE. A JPEG with many APP2 chunks (each individually
under u16::MAX bytes) could amplify peak memory to roughly the input
size before the post-loop cap fired.

Move the size check inside the loop so the running total is checked
before each chunk is copied. Use try_reserve_exact for the final
concatenation buffer.

Adds a regression test that constructs ~2x MAX_ICC_PROFILE_SIZE worth
of valid APP2/ICC chunks and asserts the function returns None.
The decoder was relying on try_alloc_maybeuninit, try_alloc_zeroed_bytes,
and try_alloc_dct_blocks for buffers sized by attacker-controlled (but
dimension-validated) image dimensions. Their bodies called
vec![T::default(); n] / vec![0u8; n] / vec![[0i16;64]; n] directly,
which panic on OOM. The docstrings acknowledged this and described it
as acceptable because dimensions had been validated — but dimension
validation gates pixel count, not bytes available on the host. A
malicious 100 MP grayscale JPEG decoded as f32 RGBA on a memory-limited
host (cgroup, container, low-RAM target) would panic the process.

Switch all three helpers to Vec::try_reserve_exact + resize. For zero-
default types (u8, i16, f32, [i16;64]) the resize step is internally
specialized to alloc_zeroed/calloc, so on Linux the kernel still hands
out lazy-committed zero-mapped pages without the per-page memset cost
on the happy path. OOM is now reported as Error::AllocationFailed
instead of unwinding/aborting.

H3: replace the remaining vec![64u8; n] and vec![0u64; n] sites in
scan.rs / progressive.rs (parallel storage for coefficient counts and
nonzero bitmaps) with try_alloc_filled. Without this, a successful
try_alloc_dct_blocks (which can succeed via lazy zero pages) followed
by an immediately-infallible vec! could still panic at physical commit.

Also tighten decode/parser/output.rs: every plain vec![0u8; n] and
vec![0.0f32; n] sized by the (validated, but possibly very large) image
dimensions is now go through try_alloc_zeroed / try_alloc_zeroed_f32
so OOM propagates as an error.
DecoderConfig::max_memory was stored on the config but never compared
against any allocation. Setting `max_memory(64 * 1024 * 1024)` provided
zero protection — a 100 MP JPEG decoded as RGBA on a memory-pressured
host could panic before any check fired.

Wire enforcement into the parser via two new fields, max_memory and
max_memory_output_bpp, set by the public Decoder entry points
(decode, decode_into, decode_coefficients,
decode_coefficients_with_extras, decode_to_ycbcr_f32, scanline_reader).

The check fires inside JpegParser::decode() right after read_header()
populates dimensions, but before any large allocation runs. Doing the
check there (rather than calling read_header from Decoder before
parser.decode) means APP markers and preserved metadata (XMP, EXIF,
ICC) are only parsed once — calling read_header twice would duplicate
extras accumulation and break extended-XMP round-trip tests.

Estimation is conservative: max(strip + output, coeff_storage + output)
where output uses the configured pixel format's bytes-per-pixel. On
exceedance, return Error::AllocationFailed before allocating.

Adds three regression tests in decode_api_guide.rs:
- max_memory_rejects_obviously_oversize_decode
- max_memory_rejects_decode_into
- max_memory_zero_is_unlimited

Updates docs/SECURITY_ISSUES.md to reflect actual enforcement state
(was claiming "Fixed" — now correctly documents the partial state and
the dual line of defense with the fallible allocation helpers).
@lilith lilith self-assigned this May 6, 2026
@lilith
Copy link
Copy Markdown
Member Author

lilith commented May 7, 2026

@lilith — ready for review. Part of the 2026-05-06 security audit campaign (see /home/lilith/work/feedback/security-audit-2026-05-06/FIX-RESULTS.md and REVIEW-RESULTS.md).

lilith added 2 commits May 6, 2026 21:53
max_input_bytes + max_pixels already bound JPEG decode memory transitively.
Internal allocations are O(input_size) or O(pixels), so MemoryTracker
bookkeeping is redundant with the entry-point caps that already fire.

This reverts commit 5c6608a.

Keeps H2/H3 (fallible try_alloc_* helpers — replace infallible vec! that
panicked on OOM) and H4 (incremental ICC cap — real DoS fix).
@lilith
Copy link
Copy Markdown
Member Author

lilith commented May 7, 2026

Split: H1 (MemoryTracker parser-side wiring) reverted as YAGNI.

Limits::max_input_bytes + max_pixels already bound JPEG decode memory transitively, so internal allocation tracking is redundant with the entry-point caps. Reverting per @lilith's "no bloat" feedback on tracker work.

Still merging:

  • H2/H3 — fallible alloc helpers (replaces infallible vec! that panicked on OOM)
  • H4 — incremental ICC profile cap (real DoS fix; ICC accumulated 2× memory before cap fired)

The MemoryTracker struct itself stays in src/decoder/foundation/alloc.rs (zero non-test callers, harmless dormant code). Easy to revert this revert if a future need emerges.

@lilith lilith changed the title fix: wire MemoryTracker through allocations and harden ICC accumulation fix: fallible alloc helpers + incremental ICC profile cap May 7, 2026
@lilith lilith merged commit 582bf0e into main May 7, 2026
18 checks passed
@lilith lilith deleted the fix/security-audit-2026-05-06 branch May 7, 2026 14:24
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