Skip to content

fix(rocDecode): Bound the VP9 parser reads and fix the quantizer and loop filter arithmetic - #12624

Merged
jeffqjiangNew merged 7 commits into
developfrom
users/jeffqjiangNew/rocDecode_vp9_parser_error_handle
Oct 2, 2026
Merged

jeffqjiangNew merged 7 commits into
developfrom
users/jeffqjiangNew/rocDecode_vp9_parser_error_handle

Conversation

@jeffqjiangNew

@jeffqjiangNew jeffqjiangNew commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

This PR addresses 6 review findings in vp9_parser: three out-of-bounds accesses driven by values the
bitstream controls, one signed narrowing of a chunk index, one piece of undefined behaviour
sitting next to a spec deviation in the same expressions, and one case of a rejected frame
leaving parser state half updated for the next one.

Technical Details

Uncompressed header reads — ParseUncompressedHeader() and its helpers

The header syntax is driven by the bitstream and Parser::GetBit/ReadBits take no length,
so a frame whose advertised size is shorter than the header it codes read past the end of
the picture data; the existing uncomp_header_size_ > size check only runs once the whole
header has been read. Not superframe-specific — the non-superframe path sets
frame_sizes_[0] to the chunk size with no minimum, so a 1-byte packet overruns on the
frame_sync_code alone.

New ReadBitsChecked()/GetBitChecked() refuse a read past the end of the current frame,
and the 59 read sites plus ReadSigned() route through them. A refused read leaves
bit_offset alone, records an overrun and yields 0, so no call site needed an error path
and the five void helpers kept their signatures. The frame is rejected on that flag once
the syntax is complete. The limit is a member set once per frame, so the shared Parser::
helpers — and the AVC, HEVC and AV1 parsers — are untouched.

Carried state on a failed parse — ParseUncompressedHeader()

The header carries state forward between frames, and the body mutates it before its later
failure exits: SetupPastIndependence() resets the segmentation features and loop filter
deltas mid-parse, and the size change block assigns pic_width_/pic_height_ before the
check that rejects an unsupported size up on a non-key frame. Since ParsePictureData()
skips a failed frame and continues with the chunk, a truncated key frame wiped the carried
state for the next one, and a rejected size change left a following frame of that size
comparing equal — skipping the size change check and being submitted against the older,
smaller surfaces.

ParseUncompressedHeader() is now a wrapper that snapshots what the body can touch and
restores it on failure, with the body in ParseUncompressedHeaderBody(). A wrapper rather
than a restore at each exit, because it also covers the pre-existing syntax-error exits that
had the same problem. frame_data_size_in_bits_/bitstream_overrun_ are excluded (the body
sets both before its first read), as are y_dequant_/uv_dequant_/lvl_lookup_ (derived,
recomputed by every successful frame before use); both exclusions are documented on the
wrapper so the rule is clear when the body gains state.

Superframe index frame sizes — CheckSuperframe()

The index supplies num_frames sizes of up to 4 bytes each, which ParsePictureData() used
both as the length passed to the header parser and as the pointer step, unchecked against
the chunk. Now summed and required to fit the chunk ahead of the index — one aggregate check
suffices, since the prefix sums are bounded by the total. The comparison is <= rather than
an exact match so a chunk with trailing padding still decodes.

Two smaller fixes in the same function: the size accumulation shifted an int-promoted byte
by up to 24 bits, now cast to uint32_t first; and the chunk index was an int narrowed
from a uint32_t expression, now size_t. That second one is reachable from an oversized
payload_size from the application rather than from the bitstream. CheckSuperframe()
returned void and so had nowhere to report any of this; it now returns a ParserResult.

Quantizer table row index — DcQ(), AcQ()

Both index [3][256] tables with the row (bit_depth - 8) >> 1; the column was clamped, the
row was not. The color config is coded only in key and intra-only frames, and
uncompressed_header_ is zeroed once in the constructor, so bit_depth is still 0 if an
inter frame arrives first — a row index of −4, reading 2048 bytes ahead of each table. A seek
to a non-key frame is enough to reach it.

bit_depth is now checked for 8, 10 or 12 before use and the frame rejected otherwise;
clamping alone would have decoded with the 8-bit tables for a frame that cannot be decoded at
all. The row is clamped too, since DcQ()/AcQ() return int and cannot report.

Loop filter levels — LoopFilterFrameInit()

The ref and mode deltas are int8_t and the defaults include −1, so the left shift by
n_shift was undefined. Spec 4.4 defines << as a zero-filling two's complement shift,
which at these magnitudes is multiplication by 2^n_shift; the code scales by that instead.
No output change.

Spec 8.8.1 clips signed intermediates, but lvlSeg, intraLvl and interLvl were
accumulated into uint8_t and only cast to int inside the clamp, so a negative result
wrapped first — lvl_seg 0 with a delta of −1 clamped to 63, maximum filter strength where
the spec asks for 0. feature_data is int16_t, so the same wrap applied one level up. All
three are now int.

n_shift is unchanged: 8.8.1 sets nShift from loop_filter_level before the per-segment
steps begin, not from the per-segment lvlSeg.

Issue Tracking

JIRA ID: ROCM-31875, AICV-310

Test Plan

Run /validate.

Test Result

╔══════════════════════════════╗
║ rocDecode Validation Summary ║
╠══════════════════════════════╣
║ CTest (16 tests) ✓ PASSED ║
║ Conformance AVC ✓ PASSED ║
║ Conformance AV1 ✓ PASSED ║
║ Conformance HEVC ✓ PASSED ║
║ Conformance VP9 ✓ PASSED ║
╠══════════════════════════════╣
║ Overall ✓ PASSED ║
╚══════════════════════════════╝

Conformance breakdown

┌───────┬─────────┬────────┬────────┬────────────┐
│ Codec │ Streams │ Passed │ Failed │ Unfinished │
├───────┼─────────┼────────┼────────┼────────────┤
│ AVC │ 127 │ 127 │ 0 │ 0 │
├───────┼─────────┼────────┼────────┼────────────┤
│ AV1 │ 162 │ 162 │ 0 │ 0 │
├───────┼─────────┼────────┼────────┼────────────┤
│ HEVC │ 135 │ 135 │ 0 │ 0 │
├───────┼─────────┼────────┼────────┼────────────┤
│ VP9 │ 108 │ 108 │ 0 │ 0 │
├───────┼─────────┼────────┼────────┼────────────┤
│ Total │ 532 │ 532 │ 0 │ 0 │
└───────┴─────────┴────────┴────────┴────────────┘

Submission Checklist

jeffqjiangNew and others added 3 commits September 29, 2026 17:49
 - CheckSuperframe() reads num_frames sizes of up to 4 bytes each from the
   superframe index and stored them in frame_sizes_ without comparing them to
   the chunk they describe. ParsePictureData() then passes each one to
   ParseUncompressedHeader() as the available size and advances pic_data_ptr by
   it, so both the length handed to the header parser and the pointer step came
   straight from the bitstream. A single frame size can reach about 4 GB, and
   the effect compounds across the frames of the superframe.
 - Sum the sizes and require them to fit the chunk ahead of the index. One
   aggregate check covers the whole walk: the prefix sums are bounded by the
   total, so each frame is guaranteed to fit in the bytes that remain. The
   comparison is <= rather than an exact match so that a chunk carrying
   trailing padding still decodes.
 - The size accumulation shifted p_stream[offset], which promotes to int, by up
   to 24 bits. With size_bytes equal to 4 the last byte overflows the signed
   32 bit type, which is undefined. Cast to uint32_t before the shift.
 - CheckSuperframe() returned void and had nowhere to report any of this. It
   now returns a ParserResult and ParsePictureData() propagates the failure.
 - The index parsing loop itself is already in bounds: offset starts at
   chunk_data_size - size_index + 1 and reads exactly size_index - 2 bytes,
   ending at chunk_data_size - 2. It is left as is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
 - DcQ() and AcQ() index dc_qlookup[3][256] and ac_qlookup[3][256] with the row
   (bit_depth - 8) >> 1. The column was clamped, the row was not.
 - The color config is coded only in key frames and intra only frames; every
   other frame carries the previous one over. uncompressed_header_ is zeroed in
   the constructor and the per frame memset in ParseUncompressedHeader() is
   commented out on purpose, so bit_depth is still 0 if an inter frame reaches
   the parser before any frame that codes a color config. The row index is then
   (0 - 8) >> 1, which is -4, reading 2048 bytes ahead of each table.
 - This does not need a malformed stream. Feeding the parser a packet from the
   middle of a stream, as a seek to a non key frame would, is enough.
 - Check bit_depth for 8, 10 or 12 in ParseUncompressedHeader() before
   SetupSegDequant() uses it, and return PARSER_WRONG_STATE otherwise. Clamping
   alone would have kept decoding with the 8 bit tables for a frame that cannot
   be decoded at all.
 - Clamp the row in DcQ() and AcQ() as well. They return int and have no way to
   report an error, so the bound belongs there too, alongside the clamp already
   applied to the column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rameInit().

 - The loop filter ref and mode deltas are int8_t read as signed values, and the
   defaults alone include -1. Shifting them left by n_shift is undefined for a
   negative value in C++17. Spec 4.4 defines << as a zero filling two's
   complement shift, which for these magnitudes is multiplication by 2^n_shift,
   so scale by that multiplier instead. No output changes.
 - Spec 8.8.1 applies Clip3(0, MAX_LOOP_FILTER, x) to signed intermediates, but
   lvlSeg, intraLvl and interLvl were accumulated into uint8_t and only cast
   back to int inside the clamp. A negative result wrapped before the clamp
   could act on it: with lvl_seg 0 and a delta of -1 the level became
   (uint8_t)(-1), so clamp(255) selected 63, the maximum filter strength, where
   the spec asks for 0.
 - segmentation_params.feature_data is int16_t, so the same wrap applied one
   level up when a negative SEG_LVL_ALT_L delta drove lvl_seg below zero. Hold
   all three intermediates in int.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@therock-pr-bot

therock-pr-bot Bot commented Oct 1, 2026

Copy link
Copy Markdown

✅ All Policy Checks Passed

Check Status Details
📝 PR Description ✅ Pass —
⛔ Forbidden Files ✅ Pass —
🧪 Unit Test ⚠️ Warning Error: Source/code files changed without an accompanying unit test.
Expected: add at least one test file named like test_<name>.py / test_<name>.cpp (or <name>_test.*).
Current: code file(s) changed: projects/rocdecode/src/parser/vp9_parser.cpp, projects/rocdecode/src/parser/vp9_parser.h; no test file found
🚫 Draft PR 🔜 To Be Enabled —
🚩 Feature Flag 🔜 To Be Enabled —
📊 Code Coverage 🔜 To Be Enabled —

🎉 All policy checks passed!

📖 Need help? See the Policy FAQ for details on every check and how to fix failures.

🙋 Wish to Override Policy?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Zero-length frames and signed index truncation leave two out-of-bounds access paths unresolved.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Hardens VP9 parsing against malformed superframe sizes, invalid quantizer indexing, and loop-filter arithmetic errors.

Changes:

  • Validates aggregate superframe sizes and propagates failures.
  • Guards quantizer table indexing by bit depth.
  • Uses signed, defined loop-filter arithmetic.
File Description
projects/​rocdecode/​src/​parser/​vp9_parser.h Updates the superframe parser return contract.
projects/​rocdecode/​src/​parser/​vp9_parser.cpp Implements bounds checks and arithmetic fixes.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

 - chunk_data_size is uint32_t and RocdecSourceDataPacket::payload_size is too,
   so a caller can pass a chunk larger than INT_MAX. chunk_data_size -
   size_index + 1 evaluates in uint32_t, and narrowing that into an int offset
   is implementation defined above INT_MAX. Clang wraps it to a negative value,
   and p_stream[offset++] then reads ahead of the buffer.
 - Hold the index in size_t instead.
 - The exposure here is an oversized payload_size from the application rather
   than anything in the bitstream. The other two reads in this function,
   p_stream[chunk_data_size - 1] and p_stream[chunk_data_size - size_index],
   index with uint32_t expressions that widen to size_t without truncation, so
   they are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Undersized superframes can still trigger out-of-bounds reads before frame-size validation occurs.

Review effort: Balanced
Findings: 1 High severity

Open (1)

 - The uncompressed header syntax is driven by the bitstream, and
   Parser::GetBit() and Parser::ReadBits() take no length, so a frame whose
   advertised size is shorter than the header it codes read past the end of the
   picture data. ParseUncompressedHeader() only compares uncomp_header_size_
   with size once the whole header has been read, by which point the reads have
   already happened.
 - This does not need a superframe. The non superframe path sets frame_sizes_[0]
   to the chunk size with no minimum, so a one byte packet reaches
   ParseUncompressedHeader() with size 1 and the frame_sync_code alone reads
   past it. The aggregate superframe check added earlier bounds the walk between
   frames, not the reads inside one.
 - Add ReadBitsChecked() and GetBitChecked(), which refuse a read that would
   pass the end of the current frame, and route the 59 read sites and
   ReadSigned() through them. A refused read leaves bit_offset alone, records
   bitstream_overrun_ and yields 0, so no call site needs an error path and the
   five helpers that return void keep their signatures.
 - Reject the frame on bitstream_overrun_ at the show_existing_frame early
   return and once the header syntax is complete, before anything acts on values
   a refused read left at 0. The reads are already safe at that point; the check
   is what turns a truncated header into an error rather than a decode of zeros.
 - The limit is held in frame_data_size_in_bits_ and set once per frame, so the
   shared Parser:: helpers are untouched and the AVC, HEVC and AV1 parsers are
   unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeffqjiangNew jeffqjiangNew changed the title fix(rocDecode): Bound the VP9 superframe sizes and fix the quantizer and loop filter arithmetic fix(rocDecode): Bound the VP9 parser reads and fix the quantizer and loop filter arithmetic Oct 1, 2026
@jeffqjiangNew
jeffqjiangNew requested a balanced review from Copilot October 1, 2026 19:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Rejected truncated frames can mutate persistent VP9 state used by subsequent frames.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (1)

…parse.

 - The uncompressed header carries state forward between frames, and
   SetupPastIndependence() resets part of it part way through the parse: all of
   segmentation_params.feature_data and feature_enabled, the loop filter deltas
   and ref_frame_sign_bias. It writes straight into uncompressed_header_.
 - Every failure exit in ParseUncompressedHeader() comes after that point, the
   new overrun check and the pre-existing uncomp_header_size_ > size check
   included. ParsePictureData() logs the failure and moves on to the next frame
   in the chunk, so a truncated key or intra frame wiped the carried state and a
   following inter frame that omits those updates was then decoded against the
   wiped state.
 - Snapshot uncompressed_header_ on entry and restore it when the parse fails.
   Doing it in a wrapper covers all of the failure exits from one place instead
   of restoring at each, and it covers the syntax error paths that had the same
   problem before this branch.
 - y_dequant_, uv_dequant_ and lvl_lookup_ are left alone: they are derived and
   recomputed by every successful frame before anything reads them. pic_width_,
   new_seq_activated_ and reconfig_option_ are left alone as well, because the
   size change block that sets them already runs after the overrun check.
 - ParsePictureData() still skips a failed frame and continues with the rest of
   the chunk. With the state rolled back the following frames see the carried
   state they should.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Failed header parses can still leave sequence dimensions and reconfiguration state corrupted.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (1)

…ell.

 - The previous commit rolled back uncompressed_header_ only, on the reasoning
   that the size change block runs after the overrun check so a rejected frame
   never reaches it. That was wrong: the overrun check is not the last failure
   exit. The block assigns pic_width_ and pic_height_ and then returns
   PARSER_WRONG_STATE for an unsupported size up on a non key frame, and
   uncomp_header_size_ is assigned after the block with two more failure exits
   below it.
 - A failed frame therefore left pic_width_ and pic_height_ holding the size it
   had advertised. A following frame of that same size then compared equal,
   skipped the size change block entirely, and could be submitted against the
   older and smaller surfaces.
 - Extend the transaction to uncomp_header_size_, pic_width_, pic_height_,
   curr_surface_width_, curr_surface_height_, reconfig_option_ and
   new_seq_activated_, plus last_frame_type_ and frame_is_intra_, which the body
   also assigns. last_frame_type_ has no reader today, so it is inert rather
   than broken, but it carries the spec's LastFrameType and should not survive a
   frame that was rejected.
 - frame_data_size_in_bits_ and bitstream_overrun_ stay out of the transaction
   because the body sets both before its first read. y_dequant_, uv_dequant_ and
   lvl_lookup_ stay out because they are derived and recomputed by every
   successful frame before anything reads them. The comment on the wrapper
   records both exclusions so the rule is clear when the body gains state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The changes consistently address the identified safety and arithmetic defects without unresolved issues.

Review effort: Balanced
Findings: None

Resolved since last review (1)

@jeffqjiangNew
jeffqjiangNew merged commit 559173e into develop Oct 2, 2026
85 of 87 checks passed
@jeffqjiangNew
jeffqjiangNew deleted the users/jeffqjiangNew/rocDecode_vp9_parser_error_handle branch October 2, 2026 23:07
@systems-assistant

Copy link
Copy Markdown
Contributor

TheRock Submodule Bump Activity

Newest first

jeffqjiangNew added a commit that referenced this pull request Oct 6, 2026
…idy EbspToRbsp().

 - None of the four merged parser bounds PRs updated CHANGELOG.md: HEVC #12407,
   AVC #12477, AV1 #12493 and VP9 #12624 all landed without an entry. Comparable
   work in this project is recorded, so add one line per codec under the
   unreleased 1.10.0 Resolved issues, plus one for the shared NAL unit and SEI
   parsing this branch changes.
 - EbspToRbsp() reported end_bytepos as the size and PARSER_OK when the end came
   before the start, which is not a length under any reading. Return
   PARSER_INVALID_ARG with a size of 0 instead. Unreachable today, since all
   nine callers pass a begin of 0, but letting a failure be read as a length is
   the thing this branch set out to stop.
 - Mark the start code offset ordering check as deliberate defence rather than
   leaving it to be read as dead code. It is not reachable with the current
   callers, which reset both offsets per picture and only assign them values the
   scan bound already constrains.
 - Note that sei_message_type is uint8_t in the public RocdecSeiMessage, so an
   SEI type above 255 truncates on store. That is pre-existing and not what the
   wide accumulation is for, which is to stop the running total wrapping before
   the range checks see it.
 - reduce_count becomes size_t, since it is a byte count subtracted from a
   size_t span. count stays int: it is a zero run state of 0 to 2, not a size.
 - Restore the trailing newline on roc_video_parser.cpp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants