Skip to content

fix(rocDecode): Bound the shared NAL unit and SEI parsing in the base parser - #12673

Merged
jeffqjiangNew merged 29 commits into
developfrom
users/jeffqjiangNew/rocDecode_parser_error_handle
Oct 10, 2026
Merged

jeffqjiangNew merged 29 commits into
developfrom
users/jeffqjiangNew/rocDecode_parser_error_handle

Conversation

@jeffqjiangNew

@jeffqjiangNew jeffqjiangNew commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

This PR addresses 5 review findings in roc_video_parser, the base shared by the AVC and HEVC parsers.
Three are out-of-bounds reads reachable from a malformed or truncated packet. The other two
both corrupt the size that every downstream parse function is handed, which makes the length
checks inside those functions inert: an error sentinel that no caller checked, and an RBSP
length that was reported larger than the buffer holding it.

Technical Details

Invalid emulation prevention sequence — EbspToRbsp()

EbspToRbsp() returned (size_t)-1 when 0x000003 was followed by a byte above 0x03, and
none of its nine callers checked for it. They assigned it into rbsp_size_ and passed that
on. rbsp_size_ was an int, so the sentinel landed as −1, and every parse function it
feeds — ParseSps, ParsePps, ParseVps, ParseSliceHeader, ParseSeiMessage — takes
size_t, so it arrived as SIZE_MAX. Any length check inside those functions is then inert:
the SEI bounds below are four offset < size tests that all pass unconditionally when size
is SIZE_MAX.

It now returns a ParserResult with the size handed back through an out parameter, so a
failure cannot be read as a length. All nine call sites log and skip the NAL unit, which is
what they already did for a failed parameter set or slice header parse. rbsp_size_ becomes
size_t to match what it is passed to.

Reported RBSP length — EbspToRbsp()

The same function reported end_bytepos - begin_bytepos + reduce_count. Every byte counted
in reduce_count has just been removed from the buffer, so adding it gives a length larger
than the EBSP ever held — 00 00 03 01 is 3 bytes of RBSP but was reported as 5. Callers
pass that on as the size of rbsp_buf_, a fixed 1024 bytes that ebsp_size already fills in
the worst case, so the parse functions were told the buffer was larger than it is. Same class
of problem as the sentinel above.

The subtraction is now the right way round. The cabac_zero_word path breaks with the
trailing 0x03 still in the buffer and the spec discards it, so it is counted in
reduce_count before the break; that keeps reduce_count meaning the number of discarded
bytes and makes one subtraction correct at both exits.

This predates the branch, and conformance does not exercise it: the parse functions stop at
the end of the syntax they need rather than the end of the buffer, so an over-reported size
changes nothing for a well formed stream.

SEI message parsing — ParseSeiMessage()

The function took the NAL unit size but used it only in the loop condition at the bottom, so
a malformed SEI read past the end four ways: the two ff_byte scans ran unbounded; the
terminating byte after each was read without checking the offset; payload_size came
straight from the bitstream and was never compared against what was left, so the memcpy
appending the payload read an attacker-chosen length; and both accumulators were int taking
+= 255 in an unbounded loop, overflowing a signed type before anything caught it.

Every read is now bounded by size, payload_size must fit the bytes left after the
headers, and the accumulation is size_t so the running total cannot wrap before it is
checked. SEI is supplemental, so the parse stops at the first bad message and keeps the ones
already read; the result is returned for the record and the callers carry on with the
picture rather than failing it. That contract is written on the declaration.

Separately, sei_payload_buf_size_ was not updated when the payload buffer grew. Writes
stayed in range because the buffer is never smaller than the recorded size, so this was
repeated reallocation rather than an overflow, but the bookkeeping was wrong.

Start code scan — GetNalUnit()

The scan ran while curr_byte_offset_ < pic_data_size_ - 2. Both are uint32_t, so a
one-byte packet made the bound 0xFFFFFFFF and the loop read three bytes at a time far past
the end. AVC and HEVC both guard payload && payload_size, so a zero length packet cannot
arrive, but a one byte packet can; two bytes was already safe by accident. The size is now
tested separately.

NAL unit offsets and sizes — signedness

curr_start_code_offset_, next_start_code_offset_ and nal_unit_size_ were int while
pic_data_size_ and curr_byte_offset_ are uint32_t, so the arithmetic between them
converted signedness. All three are now uint32_t.

The int was load bearing, so it is replaced rather than dropped. nal_unit_size_ was
assigned from pic_data_size_ - curr_start_code_offset_, which already evaluates unsigned,
so an offset past the end gave a huge value that became negative entering the int and was
caught by the nal_unit_size_ >= 4 / >= 5 floors in the callers. Widening the member alone
would have let that through the floor and into a memcpy of RBSP_BUF_SIZE bytes from past
the end of the packet. GetNalUnit() now range checks the two offsets before deriving the
size, replacing the accidental backstop with a deliberate one.

ebsp_size and sei_ebsp_size were int for the same reason: with nal_unit_size_
unsigned, a NAL unit larger than INT_MAX would have made them negative, and sei_ebsp_size
reaches new uint8_t[] and memcpy() directly. Both are uint32_t now.

Issue Tracking

JIRA ID: ROCM-31875, AICV-310

Test Plan

Run /validate.

Test Result

validate passed:

╔══════════════════════════════╗
║ 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 4 commits October 1, 2026 16:57
…cket.

 - GetNalUnit() scanned with curr_byte_offset_ < pic_data_size_ - 2. Both are
   uint32_t, so a one byte packet made the bound 0xFFFFFFFF and the loop read
   pic_data_buffer_ptr_ three bytes at a time far past the end of the buffer.
 - AVC and HEVC are the only callers. Both guard payload && payload_size, so a
   zero length packet cannot arrive, but a one byte packet can. A two byte
   packet was already safe by accident, since 2 - 2 is 0 and the loop does not
   run.
 - Test the size separately instead. Rewriting the condition as
   curr_byte_offset_ + 2 < pic_data_size_ would have removed the underflow but
   added an overflow: the body advances the offset by 3, so on re-test it can
   equal pic_data_size_, and for a payload near 4 GB that sum wraps and the loop
   continues with an offset past the end.

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

 - curr_start_code_offset_, next_start_code_offset_ and nal_unit_size_ were int
   while pic_data_size_ and curr_byte_offset_ are uint32_t, so the arithmetic
   between them converted signedness. nal_unit_size_ is now uint32_t along with
   the two offsets.
 - The int type was load bearing, so it is replaced rather than simply dropped.
   nal_unit_size_ was assigned from pic_data_size_ - curr_start_code_offset_,
   which already evaluates unsigned, so an offset past the end of the picture
   gave a huge value that became negative on the way into the int and was then
   caught by the nal_unit_size_ >= 4 and >= 5 floors in the callers. Widening the
   member alone would have let that value through the floor and into a memcpy of
   RBSP_BUF_SIZE bytes from a source past the end of the packet. GetNalUnit()
   now range checks the two offsets before deriving the size from them, which
   replaces the accidental backstop with a deliberate one.
 - ebsp_size and sei_ebsp_size were int for the same reason: with nal_unit_size_
   unsigned, a NAL unit larger than INT_MAX would have made them negative, and
   sei_ebsp_size reaches new uint8_t[] and memcpy() directly. Both are uint32_t
   now, which also removes the int to unsigned comparison against
   sei_rbsp_buf_size_. EbspToRbsp() takes size_t, so those calls widen cleanly.
 - The callers' nal_unit_size_ floors and the RBSP_BUF_SIZE clamp read the same
   under unsigned, and the build is clean with no new sign compare warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
 - ParseSeiMessage() took the NAL unit size but used it only in the loop
   condition at the bottom. Nothing else consulted it, so for AVC and HEVC,
   which share this base, a malformed SEI NAL unit read past its end in four
   ways:
   - the two ff_byte scans for payload_type and payload_size ran while
     nalu[offset] was 0xFF with no bound, so a run of 0xFF walked off the end;
   - the terminating byte after each scan was read without checking the offset;
   - payload_size came straight from the bitstream and was never compared with
     what was left of the NAL unit, so the memcpy that appends the payload read
     an attacker chosen length past the end. This is the serious one;
   - payload_type and payload_size accumulated 255 at a time into an int, so a
     long enough run overflowed a signed type before anything caught it.
 - Bound every read by size, require payload_size to fit the bytes left after
   the headers, and accumulate in size_t so the running total cannot wrap before
   it is checked.
 - sei_payload_buf_size_ was not updated when the payload buffer grew, leaving
   every later message to compare against a stale bound. Writes stayed in range
   because the buffer is never smaller than the recorded size, so this was
   wasted reallocation rather than an overflow, but the bookkeeping was wrong.
 - The function now returns ParserResult so the condition is on the record.
   SEI is supplemental and does not affect the decode, so it stops at the first
   bad message, keeps the ones already parsed, and the callers carry on with the
   picture rather than failing it. The contract is written on the declaration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tead of returning a sentinel.

 - EbspToRbsp() returned (size_t)-1 when 0x000003 was followed by a byte above
   0x03, and none of its nine callers in the AVC and HEVC parsers checked for
   it. They assigned the result straight into rbsp_size_ and passed that to the
   parameter set, slice header and SEI parse functions.
 - rbsp_size_ was an int, so the sentinel landed there as -1, and every one of
   those parse functions takes size_t, so it arrived as SIZE_MAX. That turns the
   size they are given into no bound at all, which defeats the length checks
   inside them. The bounds just added to ParseSeiMessage() are an example: with
   size equal to SIZE_MAX every offset < size test passes and all four of them
   are inert.
 - Return a ParserResult and hand the size back through an out parameter, so the
   failure cannot be read as a length. All nine call sites now log and skip the
   NAL unit, which is what they already did for a failed parameter set or slice
   header parse.
 - rbsp_size_ becomes size_t to match the functions it is passed to, so there is
   no conversion left on that path either.

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

therock-pr-bot Bot commented Oct 2, 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/avc_parser.cpp, projects/rocdecode/src/parser/hevc_parser.cpp, projects/rocdecode/src/parser/roc_video_parser.cpp, projects/rocdecode/src/parser/roc_video_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

The converted RBSP length incorrectly adds removed bytes, allowing parsing beyond the valid buffer.

Review effort: Balanced
Findings: 1 High severity · 1 Low severity

Open (2)
What changed in this PR

Bounds AVC/HEVC NAL and SEI parsing against malformed or truncated packets.

Changes:

  • Adds NAL offset and SEI payload bounds checks.
  • Replaces the unchecked RBSP size sentinel with ParserResult.
  • Corrects parser size types and SEI buffer bookkeeping.
File Description
roc_video_parser.h Updates parser types and method contracts.
roc_video_parser.cpp Adds shared NAL, RBSP, and SEI validation.
hevc_parser.cpp Handles RBSP conversion failures for HEVC.
avc_parser.cpp Handles RBSP conversion failures for AVC.

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

jeffqjiangNew and others added 2 commits October 1, 2026 21:48
…m the RBSP size.

 - EbspToRbsp() reported end_bytepos - begin_bytepos + reduce_count. Every byte
   counted in reduce_count has been removed from the buffer, so adding it gave a
   length larger than the EBSP ever held: 00 00 03 01 is 3 bytes of RBSP but was
   reported as 5.
 - The callers pass that straight to the parameter set, slice header and SEI
   parse functions as the size of rbsp_buf_, which is a fixed 1024 bytes that
   ebsp_size already fills in the worst case. An over-reported size therefore
   tells those functions the buffer is larger than it is, which is the same
   class of problem as the sentinel fixed in the previous commit and undercuts
   the bounds added across this branch.
 - The cabac_zero_word path breaks with the trailing 0x03 still in the buffer,
   and the spec discards it, so count it in reduce_count before breaking. That
   keeps reduce_count meaning the number of discarded bytes and makes the one
   subtraction correct for both exits.
 - This predates the branch. Conformance did not catch it because the parse
   functions stop at the end of the syntax they need rather than at the end of
   the buffer, so an over-reported size changes nothing for a well formed
   stream. All 532 streams still pass.

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

 - The comment still described the old contract, that the function returns the
   converted size directly. Document the p_rbsp_size out parameter and the
   ParserResult return instead.
 - Record the two things a caller needs: the size is the converted range less
   the discarded emulation prevention bytes, and it is set to 0 on failure so it
   cannot be read as a length, which is the whole point of replacing the
   previous (size_t)-1 sentinel.

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

Exact-fit SEI buffer growth retains quadratic reallocation behavior and enables resource exhaustion.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (2)

 - Fitting the capacity to exactly what the current message needs leaves
   sei_payload_buf_size_ equal to sei_payload_size_ once that payload is
   appended, so the next message carrying any payload at all re-enters the grow
   branch and copies the whole accumulated payload again.
 - The number of SEI messages in one picture is bounded only by the packet size,
   and so is the total payload, which makes the copying quadratic in the packet
   size. A picture with one large payload followed by many small messages is
   enough to reach it.
 - Double the capacity until it covers what is needed instead, falling back to
   the exact requirement if the doubling overshoots uint32_t.
   sei_payload_size_ is reset per picture and the payloads come from a single
   packet, so the requirement itself always fits.
 - The exact fit came in with the previous commit on this branch, which set
   sei_payload_buf_size_ so it would stop being stale. Before that the recorded
   size stayed at INIT_SEI_PAYLOAD_BUF_SIZE, which had the same quadratic
   behaviour once the accumulated payload passed 1 MB, so this is a fix for both
   shapes rather than only for the new one.

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

🔵 Needs a closer look

Security-sensitive parsing changes for attacker-controlled input warrant final human review despite no additional defects found.

Review effort: Balanced
Findings: None

Resolved since last review (1)

…t().

 - a1253a1 stopped the start code scan underflowing by testing
   pic_data_size_ >= 3 in the loop condition. That keeps the scan from running,
   but it leaves the function returning through the start_code_num_ == 0 check
   below, which only fires because of an invariant held by the callers: both
   reset start_code_num_ before the loop, pic_data_size_ cannot change within a
   picture, and the caller returns as soon as GetNalUnit() reports
   PARSER_NOT_FOUND. So the first call is the only one that can see a packet
   shorter than a start code.
 - That chain holds today, but none of it is enforced here, and a caller change
   would break it quietly. Return PARSER_NOT_FOUND from the top instead, so the
   function covers the case on its own and the loop condition goes back to the
   simple form.
 - Behaviour is unchanged for every input: a packet shorter than three bytes
   cannot hold a start code, and both paths report PARSER_NOT_FOUND.

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 updated bounds, size calculations, and caller error handling are consistent and address the reported safety issues.

Review effort: Balanced
Findings: None

@rrawther rrawther 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.

added a comment

jeffqjiangNew and others added 3 commits October 7, 2026 21:32
Fixed type.

Co-authored-by: Aryan Salmanpour <aryan.salmanpour@amd.com>
…capacity.

 - Review feedback: the overflow fallback checked nothing. When the doubled size
   passed 0xFFFFFFFF it fell back to needed, which is itself an unchecked size_t,
   so the cast truncated exactly as silently as the value it was guarding.
 - The consequence is a write, not a wrong number. sei_payload_buf_size_ is what
   the allocation on the next line is sized from and what both copies are
   bounded by, so a truncated capacity under allocates and the copy of the
   existing payload and the append of the new one both run past the new buffer.
 - Take the larger of the doubled size and the requirement, and fail with
   PARSER_OUT_OF_RANGE if that cannot be recorded in the uint32_t size field.
   The old comment argued the requirement always fits; arguing it in a comment
   was the problem, since nothing enforced it.
 - The two other narrowings in the same function are now explicit, as the
   widening to size_t in this branch made them implicit. The first payload case
   is bounded by what was checked against the NAL unit, and the running total by
   the capacity the branches above just confirmed, so both are casts with the
   bound stated rather than further checks.

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

@rrawther rrawther 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.

I see some revew comments are not resolved. After that it can be merged

 - Review feedback: sei_message_size is uint32_t in the public
   RocdecSeiMessage, so storing payload_size there narrowed from size_t
   implicitly. The previous commit made the other narrowings in this function
   explicit and missed this one.
 - sei_message_type on the line above was in the same state: commented but not
   cast. Both are spelled out now, since the comment was describing a conversion
   the code did not show.
 - The comment distinguishes them, because they are not the same kind of
   conversion. sei_message_type is uint8_t, so a payload type above 255 is
   truncated and the value stored is not the one parsed. sei_message_size is
   exact, because payload_size was bounded against what is left of the NAL unit
   a few lines above.
 - No behaviour change: these are the conversions the compiler was already
   performing.

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

@rrawther rrawther 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.

Also please take a look at this
3. Issues found
None blocking. I re-audited the delta for new problems and found none:

All 9 EbspToRbsp call sites check the result and skip the NAL unit; I enumerated them and confirmed there is no 10th.
reduce_count cannot exceed end − begin — each increment is paired with one distinct discarded byte in the span (the memmove branch also decrements streamBuffer_end) — so the new size_t subtraction at roc_video_parser.cpp:234 cannot underflow.
The nal_payload_size hoist is arithmetically identical to what it replaced, just evaluated where the floor is visible.
No public header or dispatch-table change → no ABI version bump required, correctly omitted.
4. Suggestions
CHANGELOG.md:17 — "HVEC" is a typo for "HEVC". This is user-facing release-note text; worth fixing before merge. The same line also credits AV1 and VP9, which this PR does not touch (VP9 landed via #12624) — defensible as a consolidated entry, but if it's meant to describe this PR it overstates its scope.
roc_video_parser.h:231-232 — the doc comment names only the malformed-emulation-sequence cause of PARSER_INVALID_ARG; end_bytepos < begin_bytepos now returns it too. One clause would make the contract complete.
Unit tests — I agree with @AryanSalmanpour's request. EbspToRbsp is a pure function over a byte buffer and the corrected end − (begin + reduce_count) formula is exactly the kind of thing that silently regresses. My ad-hoc harness covers these cases today but lives outside the repo.
Follow-up, out of scope: es_reader.cpp:840 still carries a near-duplicate EbspToRbsp with the same return -1 sentinel that neither caller checks, and its callers at lines 611 and 713 memcpy a fixed 256 bytes out of the stream buffer unconditionally. Same bug class, separate PR.
Minor: roc_video_parser.cpp:188 is missing a space after the comma in the signature, and the header's parameter name is stream_buffer where the definition uses streamBuffer.

jeffqjiangNew and others added 5 commits October 8, 2026 12:00
 - Review feedback on (sei_message_count_ + 1) > sei_message_list_.size():
   signed against std::vector::size_type, which is the mixing this branch has
   been removing. Took the preferred option and changed the member rather than
   casting at the comparison. num_slices_ has the same shape and is included.
 - Both are counters that are only reset, incremented, compared against zero,
   used as an index, and handed to a uint32_t field of the public structures
   (RocdecSeiMessageInfo::sei_message_count, RocdecPicParams::num_slices).
   Nothing assigns them a negative value or relies on one.
 - The four loops of the form for (int slice_index = 0; slice_index <
   num_slices_; ...) take uint32_t indices now, so the change removes
   comparisons rather than moving them from one line to another.
 - num_slices_ - 1 in hevc_parser.cpp sits inside one of those loops, so it only
   runs when num_slices_ is at least 1 and cannot wrap now that the type is
   unsigned.
 - Counted with -Wsign-compare before and after, on the three files this
   touches: 5, 84 and 100 warnings become 4, 82 and 98. Five fewer, none added.

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

 - Review feedback: the macro was an unparenthesized signed expression,
   1024 * 1024, used on the right of unsigned comparisons in four places.
 - The parentheses are the part that matters. Without them the macro expands
   into its use site and any operator of higher precedence binds wrong:
   b / INIT_SEI_PAYLOAD_BUF_SIZE becomes b / 1024 * 1024, which for b of
   3000000 gives 2999296 instead of 2. None of the four current uses do that,
   but nothing stops the next one.
 - The u suffixes change no diagnostic. Counted -Wsign-compare on the three
   files that use the macro, before and after: 4, 82 and 98 both times. The
   operand was already a constant the compiler knows is non negative, so the
   comparisons never warned and never converted wrongly. Worth saying, since
   the comment this came from expects the signedness to be the problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
 - Review feedback: it was the last signed member in the chain. Every expression
   assigned to it is already unsigned arithmetic over members this branch
   widened, pic_data_start_offset_ in avc_parser.h being uint32_t as well, so
   only the destination was signed and each assignment converted.
 - Everything it feeds is unsigned too: RocdecPicParams::bitstream_data_len and
   the slice_data_size fields are all uint32_t. Checked all thirteen uses:
   nothing compares it, assigns it a negative, or tests for one, so unlike
   nal_unit_size_ earlier on this branch the type was doing no work.
 - Counted the effect with -Wsign-conversion, which is the diagnostic that
   applies here. -Wsign-compare does not move, because these are conversions on
   assignment rather than comparisons: avc 294 to 290, hevc 254 to 252, av1 182
   to 180, vp9 66 to 63. Eleven fewer, none added.

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

 - Review feedback: the comment explained why the branch has to return and not
   what returning costs. It exits before the end of packet finalization, so
   slices already accumulated for the current picture would be dropped rather
   than decoded.
 - Checked whether any can be: neither condition that reports PARSER_INVALID_
   FORMAT can leave slices accumulated. The short packet one tests
   pic_data_size_, which both parsers set once before the loop and never change
   within a packet, so it can only fire on the first pass, when num_slices_ is
   still 0 from the reset a few lines above it. The offset ordering one is the
   defensive check that is not reachable with the current callers.
 - So the drop costs nothing today, but that rests on where those two conditions
   can fire rather than on anything local. Record it, along with the note that a
   third condition able to fire mid packet would have to decide whether to
   finalize first.
 - Comments only, in both parsers, which have the same shape here.

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

 - Review feedback. The definition took streamBuffer where the declaration
   takes stream_buffer, and had no space after the first comma. The definition
   follows the declaration now, which is also the snake_case the rest of the
   file uses. The two derived locals keep their names; renaming them is noise
   in a diff this branch is already long enough.
 - The doc comment named only the malformed emulation prevention sequence as a
   cause of PARSER_INVALID_ARG. An end before the start returns it too, since
   the previous commit stopped that case reporting a length. Both are listed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeffqjiangNew
jeffqjiangNew requested review from AryanSalmanpour and rrawther and a balanced review from Copilot October 8, 2026 18:10

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.

🟡 Changes recommended

Extended SEI types are misreported, and valid large payload accumulation can be rejected.

2 open findings

🧠 Review effort: Balanced

jeffqjiangNew and others added 3 commits October 8, 2026 14:31
…t hold.

 - An extended payload type, written as one or more ff_bytes, is valid syntax
   but does not fit RocdecSeiMessage::sei_message_type, which is uint8_t. Type
   261 reached the callback as type 5, and the SEI consumers in utils read 5 as
   SEI_TYPE_USER_DATA_UNREGISTERED and parse the payload as that, so the
   callback was told a message was something it was not.
 - This predates the branch; the implicit conversion did the same. Making the
   cast explicit a few commits ago only made it visible.
 - Skip the message instead. The payload size was bounded just above, so the
   offset can step over it and the messages either side are still delivered.
   Neither the message nor its payload is recorded, which keeps
   sei_message_list_ and the concatenated sei_payload_buf_ in step for a caller
   walking the two together.
 - Widening the public field was the other option offered in review and is not
   taken here. It would repurpose the three reserved bytes, change what a
   consumer reading the field as uint8_t sees, and make this the only public
   header change in the branch. That is a decision for the API, not for a parser
   fix, and extended types can be delivered later without this dropping them
   wrongly in the meantime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ithub.com:ROCm/rocm-systems into users/jeffqjiangNew/rocDecode_parser_error_handle
…bit limit.

 - Review feedback: once the capacity is at least 2 GiB, one more doubling takes
   it past what sei_payload_buf_size_ can hold, so the check below rejected the
   growth even where the requirement itself was still representable, and the
   remaining SEI messages of the picture were dropped.
 - Cap the doubling at half the 32 bit limit. The capacity line below, which was
   written as defence, is what makes this work: when the loop stops early
   new_size is less than needed, so the capacity falls back to the exact
   requirement.
 - Checked the boundaries against the old code. A 3 GiB capacity needing 3.4 GiB
   was rejected and now allocates 3.4 GiB. A 2 GiB capacity needing exactly
   UINT32_MAX was rejected, the doubling having produced UINT32_MAX + 1, and now
   allocates exactly UINT32_MAX. A requirement of 5 GB is still rejected, which
   is right, and the ordinary growth paths are unchanged.

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.

🔵 Needs a closer look

Security-sensitive bitstream parsing and malformed-input handling warrant final human review.

0 open findings

2 resolved since last review

🧠 Review effort: Balanced

@AryanSalmanpour AryanSalmanpour left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-review at fe19a15

Re-reviewed against 3d0a553, the commit of my earlier review. Every point raised there has been addressed — seven in code, one in comments — and I found no new defects in the changes.

Worth calling out as better than what I suggested:

  • The SEI buffer growth is now correct rather than merely guarded. Capping the doubling at 0xFFFFFFFF/2 and falling back to the exact requirement means the capacity check can no longer reject a growth whose requirement would itself have fit, and the >4 GB case fails loudly instead of narrowing. I ran the loop through its edges, including needed just below and just above 0xFFFFFFFF; it never produces a capacity that overflows the uint32_t field.
  • Retyping sei_message_count_ and num_slices_ to uint32_t, rather than casting at the comparison, removes the sign mismatch at the source and carried through to the four dependent for (int slice_index …) loops in both parsers. num_slices_ - 1 at hevc_parser.cpp:475 is safe because it only evaluates inside the slice_index < num_slices_ loop, so the zero case cannot wrap.
  • Skipping an SEI message whose payload_type exceeds 0xFF is a sharper answer than the truncation note I left. Delivering type 261 as type 5 would have had the consumers read it as SEI_TYPE_USER_DATA_UNREGISTERED, so dropping it with a log is the right call, and stepping over an already bounds-checked payload keeps the surrounding messages intact.

On the early return from the PARSER_INVALID_FORMAT branch: the new comment argues that neither reporting condition can fire with slices already accumulated. I checked that rather than take it on trust — pic_data_size_ is assigned exactly once per call, at avc_parser.cpp:107 and hevc_parser.cpp:572, before the NAL loop, so the short-packet test can only fire on the first pass while num_slices_ is still 0. The claim holds, and flagging the constraint for whoever adds a third condition is the useful part.

The one thing I raised that was not taken is the observation that the pic_data_size_ < 3 check is loop-invariant but re-evaluated per NAL unit. That was a nit and I do not think it is worth pursuing.

Resolving my threads accordingly. The standing gap remains test coverage: EbspToRbsp is a pure function over a byte buffer, and a handful of table-driven cases (00 00 03 01, a trailing 00 00 03, consecutive escapes, a malformed 00 00 03 FF, end < begin) would pin this behaviour cheaply against regression.

@jeffqjiangNew
jeffqjiangNew merged commit 7433fed into develop Oct 10, 2026
70 of 71 checks passed
@jeffqjiangNew
jeffqjiangNew deleted the users/jeffqjiangNew/rocDecode_parser_error_handle branch October 10, 2026 02:09
anujshuk-amd pushed a commit that referenced this pull request Oct 12, 2026
… parser (#12673)

## Motivation

This PR addresses 5 review findings in `roc_video_parser`, the base
shared by the AVC and HEVC parsers.
Three are out-of-bounds reads reachable from a malformed or truncated
packet. The other two
both corrupt the size that every downstream parse function is handed,
which makes the length
checks inside those functions inert: an error sentinel that no caller
checked, and an RBSP
length that was reported larger than the buffer holding it.

## Technical Details

**Invalid emulation prevention sequence — `EbspToRbsp()`**

`EbspToRbsp()` returned `(size_t)-1` when `0x000003` was followed by a
byte above `0x03`, and
none of its nine callers checked for it. They assigned it into
`rbsp_size_` and passed that
on. `rbsp_size_` was an `int`, so the sentinel landed as −1, and every
parse function it
feeds — `ParseSps`, `ParsePps`, `ParseVps`, `ParseSliceHeader`,
`ParseSeiMessage` — takes
`size_t`, so it arrived as `SIZE_MAX`. Any length check inside those
functions is then inert:
the SEI bounds below are four `offset < size` tests that all pass
unconditionally when `size`
is `SIZE_MAX`.

It now returns a `ParserResult` with the size handed back through an out
parameter, so a
failure cannot be read as a length. All nine call sites log and skip the
NAL unit, which is
what they already did for a failed parameter set or slice header parse.
`rbsp_size_` becomes
`size_t` to match what it is passed to.

**Reported RBSP length — `EbspToRbsp()`**

The same function reported `end_bytepos - begin_bytepos + reduce_count`.
Every byte counted
in `reduce_count` has just been removed from the buffer, so adding it
gives a length larger
than the EBSP ever held — `00 00 03 01` is 3 bytes of RBSP but was
reported as 5. Callers
pass that on as the size of `rbsp_buf_`, a fixed 1024 bytes that
`ebsp_size` already fills in
the worst case, so the parse functions were told the buffer was larger
than it is. Same class
of problem as the sentinel above.

The subtraction is now the right way round. The `cabac_zero_word` path
breaks with the
trailing `0x03` still in the buffer and the spec discards it, so it is
counted in
`reduce_count` before the break; that keeps `reduce_count` meaning the
number of discarded
bytes and makes one subtraction correct at both exits.

This predates the branch, and conformance does not exercise it: the
parse functions stop at
the end of the syntax they need rather than the end of the buffer, so an
over-reported size
changes nothing for a well formed stream.

**SEI message parsing — `ParseSeiMessage()`**

The function took the NAL unit size but used it only in the loop
condition at the bottom, so
a malformed SEI read past the end four ways: the two `ff_byte` scans ran
unbounded; the
terminating byte after each was read without checking the offset;
`payload_size` came
straight from the bitstream and was never compared against what was
left, so the `memcpy`
appending the payload read an attacker-chosen length; and both
accumulators were `int` taking
`+= 255` in an unbounded loop, overflowing a signed type before anything
caught it.

Every read is now bounded by `size`, `payload_size` must fit the bytes
left after the
headers, and the accumulation is `size_t` so the running total cannot
wrap before it is
checked. SEI is supplemental, so the parse stops at the first bad
message and keeps the ones
already read; the result is returned for the record and the callers
carry on with the
picture rather than failing it. That contract is written on the
declaration.

Separately, `sei_payload_buf_size_` was not updated when the payload
buffer grew. Writes
stayed in range because the buffer is never smaller than the recorded
size, so this was
repeated reallocation rather than an overflow, but the bookkeeping was
wrong.

**Start code scan — `GetNalUnit()`**

The scan ran while `curr_byte_offset_ < pic_data_size_ - 2`. Both are
`uint32_t`, so a
one-byte packet made the bound `0xFFFFFFFF` and the loop read three
bytes at a time far past
the end. AVC and HEVC both guard `payload && payload_size`, so a zero
length packet cannot
arrive, but a one byte packet can; two bytes was already safe by
accident. The size is now
tested separately.

**NAL unit offsets and sizes — signedness**

`curr_start_code_offset_`, `next_start_code_offset_` and
`nal_unit_size_` were `int` while
`pic_data_size_` and `curr_byte_offset_` are `uint32_t`, so the
arithmetic between them
converted signedness. All three are now `uint32_t`.

The `int` was load bearing, so it is replaced rather than dropped.
`nal_unit_size_` was
assigned from `pic_data_size_ - curr_start_code_offset_`, which already
evaluates unsigned,
so an offset past the end gave a huge value that became negative
entering the `int` and was
caught by the `nal_unit_size_ >= 4` / `>= 5` floors in the callers.
Widening the member alone
would have let that through the floor and into a `memcpy` of
`RBSP_BUF_SIZE` bytes from past
the end of the packet. `GetNalUnit()` now range checks the two offsets
before deriving the
size, replacing the accidental backstop with a deliberate one.

`ebsp_size` and `sei_ebsp_size` were `int` for the same reason: with
`nal_unit_size_`
unsigned, a NAL unit larger than `INT_MAX` would have made them
negative, and `sei_ebsp_size`
reaches `new uint8_t[]` and `memcpy()` directly. Both are `uint32_t`
now.

## Issue Tracking

JIRA ID: ROCM-31875, AICV-310

## Test Plan

Run /validate.

## Test Result

validate passed:

╔══════════════════════════════╗
║ 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

- [ ] Look over the contributing guidelines at
https://github.com/ROCm/rocm-systems/blob/develop/CONTRIBUTING.md.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Aryan Salmanpour <aryan.salmanpour@amd.com>
@systems-assistant

Copy link
Copy Markdown
Contributor

TheRock Submodule Bump Activity

Newest first

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.

5 participants