Skip to content

feat(fuzz): add egfx_zgfx_decompress oracle and harden the ZGFX decoder against attacker-controlled input#1333

Open
Greg Lamberson (glamberson) wants to merge 1 commit into
Devolutions:masterfrom
lamco-admin:feat/egfx-zgfx-decompress-fuzz
Open

feat(fuzz): add egfx_zgfx_decompress oracle and harden the ZGFX decoder against attacker-controlled input#1333
Greg Lamberson (glamberson) wants to merge 1 commit into
Devolutions:masterfrom
lamco-admin:feat/egfx-zgfx-decompress-fuzz

Conversation

@glamberson
Copy link
Copy Markdown
Contributor

Summary

Hardening

Eight input-validation gaps closed in ironrdp-graphics (all class-(c) per #1314: reachable via attacker-controlled wire inputs). Each one was surfaced by a rigorous smoke-fuzz iteration:

  1. Bits::try_split_to (new in utils.rs) provides the checked counterpart to split_to that every fix below builds on.
  2. SegmentedDataPdu::from_buffer multipart segment-size: split_at_checked plus a defensive Vec::with_capacity cap mirroring PR feat(fuzz): add egfx PDU decoders to pdu_decode oracle + fix two surfaced bugs #1271.
  3. decompress_segment trailing-unused-bits subtraction: checked_mul + checked_sub.
  4. decompress_segment token loop: checked prefix indexing + checked split_to(8) for the NullLiteral value.
  5. handle_match: checked distance-bits split, checked_add for distance_base + value, plus distance > HISTORY_SIZE bound check.
  6. read_unencoded_bytes: checked splits for the 15-bit length, pad-to-boundary, and length * 8 payload.
  7. read_encoded_bytes: checked splits + usize::BITS bound on load_be::<usize>() + checked_shl replacing pow + checked_add for base + value.
  8. FixedCircularBuffer::read_with_offset: defense-in-depth offset > buffer.len() bound check below the caller-side guard.

Plus the spec-derived memory-budget enforcement from MS-RDPEGFX 2.2.1 (design rationale documented at issuecomment-4558356535 on #1120):

  1. New MAX_DECOMPRESSED_PER_SEGMENT = 65_535 enforced per-token in decompress_segment and threaded through the match-copy paths so the cap is checked before any allocation.
  2. Multipart running-total check against the declared uncompressedSize, surfacing early detection of segments that collectively exceed the wire-declared bound.

Seven new ZgfxError variants total with matching Display and Error::source impl entries.

Validation

  • cargo xtask ci clean locally on 1.89.0: fmt, lints, tests, typos, locks all green.
  • check_egfx_zgfx_decompress regression-replay passes against both shipped crash artifacts.
  • 46 existing ZGFX unit tests continue to pass against the hardened code.
  • Final 15-minute rigorous fuzz: 2,942,961 iterations in 901 seconds (~3,266 exec/s sustained), peak RSS 84 MB, zero panics, zero sanitizer reports, zero OOMs.

Iterative audit progression on a 15-minute libFuzzer + ASan budget per round:

  • Round 1 (no fixes): crash at iter ~30 (trailing-bit underflow)
  • Round 2 (after fixes 1-2): crash at iter ~28 (bit-budget)
  • Round 3 (after F1-F8 audit): OOM at iter ~9877 (1.5 GB peak)
  • Round 4 (after F1-F10 complete): clean

Notes

  • Crash artifacts for the two header-parse bugs added to crates/ironrdp-testsuite-core/test_data/fuzz_regression/egfx_zgfx_decompress/. The later-round crashes are not added as separate regression entries because the libFuzzer corpus accumulated 161 representative inputs and the unit-tested error paths cover the cases directly.
  • The MS-RDPEGFX 2.2.1 per-segment bound is a semantic change: inputs that previously decoded to more than 65,535 bytes per segment now return Err. Such inputs were never spec-conformant. Codebase precedent (PR feat(zgfx): add LZ77 compression support #1097 et seq.) is to add ZgfxError variants without a ! marker on the commit; following that convention.
  • Memory-budget design discussion lives on Advanced fuzzing with arbitrary #1120 (issuecomment-4558356535) rather than in this PR body so future readers find the rationale on the canonical fuzz-umbrella thread.

Refs #1316.

…er against attacker-controlled input

Adds the egfx_zgfx_decompress fuzz target under Devolutions#1316 (target 3 of the
egfx fuzz-coverage umbrella), scoped to the ZGFX decompression path in
ironrdp-graphics::zgfx. ZGFX is the egfx-specific compression scheme
defined in MS-RDPEGFX 2.2.4.1, distinct from ironrdp-bulk's MPPC /
NCRUSH / XCRUSH (which carry connection-level RDP traffic). The target
shape mirrors the bulk_* targets from PR Devolutions#1285: panic plus sanitizer
oracle on Decompressor::decompress, fresh decompressor per iteration so
history state does not leak between inputs.

A complete input-validation audit of the ZGFX decoder accompanies the
target. Iterative rigorous smoke fuzz on a 15-minute budget surfaced
each bug class in turn; the fixes below close every one that the audit
covers. Final validation run after the last fix completed clean: 2.94M
iterations in 901 seconds, peak RSS 84 MB, no panics, no OOMs, no
sanitizer reports.

Decoder input-validation fixes (all class-(c) per Devolutions#1314's taxonomy:
reachable via attacker-controlled wire inputs):

- ironrdp-graphics::utils::Bits::try_split_to (new): checked counterpart
  to split_to that returns None instead of panicking when the requested
  width exceeds the bit budget. Foundation for the per-call-site checks
  below.

- zgfx/control_messages.rs:34 (SegmentedDataPdu::from_buffer): the
  multipart-segment loop called slice::split_at on an
  attacker-controlled u32 size field without bound-checking against
  buffer.len(). Fixed via split_at_checked surfacing
  ZgfxError::SegmentSizeExceedsBuffer { size, remaining }. Also caps
  Vec::with_capacity(segment_count) against the remaining buffer size
  (each segment is at least 5 bytes), mirroring the pattern from
  PR Devolutions#1271.

- zgfx/mod.rs:90 (decompress_segment): the trailing-unused-bits
  subtraction 8 * (encoded_data.len() - 1) - last_byte underflowed on
  any input where last_byte exceeded the available bit budget. Fixed
  via checked_mul + checked_sub surfacing
  ZgfxError::InvalidTrailingBitCount(usize).

- zgfx/mod.rs decompress_segment token loop: replaced the unchecked
  bits[..token.prefix.len()] slice indexing with bits.get(...) so a
  truncated segment whose remaining bits are shorter than every prefix
  surfaces as TokenBitsNotFound. Replaced bits.split_to(8) (NullLiteral
  value) with try_split_to(8) returning
  ZgfxError::IncompleteBitStream { needed, remaining }.

- zgfx/mod.rs handle_match: replaced bits.split_to(distance_value_size)
  with the checked variant. Replaced the unchecked distance_base + value
  u32 addition with checked_add. Added a defense-in-depth bound check
  rejecting match distances larger than HISTORY_SIZE (2.5 MB) so the
  circular buffer's position arithmetic cannot underflow on
  attacker-controlled distance.

- zgfx/mod.rs read_unencoded_bytes: all three bits.split_to calls
  (15-bit length field, pad-to-boundary, length * 8 payload) replaced
  with the checked variant. length is bounded by the 15-bit field but
  length * 8 is now computed via checked_mul.

- zgfx/mod.rs read_encoded_bytes: leading-ones + length-token-size
  arithmetic guarded against overflow. The 2^N base calculation
  replaced with 1usize.checked_shl(exponent) and bounded against
  usize::BITS so an attacker-controlled length_token_size cannot drive
  the exponent into a pow overflow or load_be::<usize>() panic. The
  base + value sum uses checked_add. New ZgfxError variant
  LengthTokenSizeTooLarge(usize) surfaces the rejected value.

- zgfx/circular_buffer.rs read_with_offset: added a defense-in-depth
  bound check rejecting offsets larger than the buffer size, so a
  stray attacker-controlled offset surfaces as a typed I/O error
  rather than an unsigned-underflow panic in the position calculation.
  Complements the caller-side bound check in handle_match.

Memory-budget oracle (per MS-RDPEGFX section 2.2.1, applied per the
design discussion on Devolutions#1120 issuecomment-4558356535):

- New const MAX_DECOMPRESSED_PER_SEGMENT = 65_535 in zgfx/mod.rs.
  Per the spec, a single ZGFX segment must not produce more than
  65,535 decompressed bytes. The decompress_segment loop enforces
  this per-token and at the segment-cumulative level, surfacing
  ZgfxError::SegmentDecompressedSizeExceedsLimit { decompressed, limit }
  before allocation rather than after. The check threads through
  handle_match, read_unencoded_bytes, and read_encoded_bytes via a
  max_remaining parameter so a match-copy of length N is rejected
  before the circular buffer read consumes that much memory.

- Multipart running-total check after each segment, surfacing
  ZgfxError::MultipartTotalExceedsDeclared { written, declared } if
  segments collectively produce more bytes than the
  uncompressedSize u32 declared in the multipart header. The declared
  u32 remains the wire-format upper bound; realistic-traffic limits
  below that are a caller-layer concern. Closes the multipart-bomb
  variant of the same OOM class.

Two of the bugs (segment-size split_at, trailing-bit underflow) shipped
with concrete crash reproducer inputs from the original smoke fuzz.
Both inputs are added to
crates/ironrdp-testsuite-core/test_data/fuzz_regression/egfx_zgfx_decompress/
as crash-multipart-segment-size-oversize.bin (11 bytes) and
crash-trailing-bit-underflow.bin (3 bytes). The regression-replay test
check_egfx_zgfx_decompress passes against the fixed code; both inputs
now return typed errors rather than panicking. The OOM and bit-budget
crash inputs from later rounds are not added as regression entries
because the libFuzzer corpus already contains structurally similar
inputs and the unit tests cover the typed-error paths directly.

The 46 existing ZGFX unit tests continue to pass against the hardened
code, confirming no semantic regression for well-formed input.

Validation run after all fixes:
- 15-minute libFuzzer + ASan run with -timeout=10
- 2,942,961 iterations in 901 seconds (~3,266 exec/s sustained)
- Peak RSS: 84 MB (vs 1,565 MB on the pre-F9 OOM)
- Zero panics, zero sanitizer reports, zero OOMs
- 161 corpus entries accumulated from iterative bug discovery

Refs Devolutions#1316. Cross-reference for the memory-budget design rationale:
issuecomment-4558356535 on Devolutions#1120.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant