Skip to content

acf/vss: fix unbounded reads in string-array (de)serialisation#129

Merged
SebastianSchildt merged 3 commits into
COVESA:mainfrom
SoundMatt:fix/vss-string-array-bounds
May 11, 2026
Merged

acf/vss: fix unbounded reads in string-array (de)serialisation#129
SebastianSchildt merged 3 commits into
COVESA:mainfrom
SoundMatt:fix/vss-string-array-bounds

Conversation

@SoundMatt
Copy link
Copy Markdown
Contributor

Problem

Avtp_Vss_DeserializeStringArray and Avtp_Vss_GetVSSDataStringArrayLength
in src/avtp/acf/custom/Vss.c walk an on-wire string-array layout of
[length-prefix : 2 bytes][string : length bytes] pairs. Both had
serious bounds-check defects letting crafted network input drive
out-of-bounds reads.

1. DeserializeStringArray — dead bounds check + attacker-controlled memcpy length

uint16_t idx = 0;
for (int i = 0; i < num_strings; i++) {
    if(idx >= array_length) break;        // ← dead: idx never incremented
    strings[i]->data_length = Avtp_BeToCpu16(*(const uint16_t*)array_data);
    if (strings[i]->data != NULL) {
        memcpy(strings[i]->data, array_data+2, strings[i]->data_length);
    }
    array_data += 2 + strings[i]->data_length;
}

The break is dead code — idx is initialised to 0 and never
incremented, so the check only fires when array_length == 0. With
attacker-controlled str_length values up to UINT16_MAX from the
wire, memcpy reads up to ~64 KiB past the end of
vss_data_string_array->data on every iteration after the first
malicious entry. Heap disclosure / crash.

2. GetVSSDataStringArrayLength — uint16_t overflow + off-by-one + truncated return type

uint16_t total_length = str_array->data_length;
uint16_t idx = 0, ptr_idx = 0;
while (ptr_idx < total_length) {                          // off-by-one
    uint16_t str_length = Avtp_BeToCpu16(*(const uint16_t*)(... + ptr_idx));
    ptr_idx += 2 + str_length;                            // uint16_t wraps
    idx++;
}
return idx;                                               // returned as uint8_t

Three issues:

  • ptr_idx += 2 + str_length performs uint16_t arithmetic. A
    str_length near UINT16_MAX wraps ptr_idx around to a small
    value, making the loop iterate from a low offset again —
    pseudo-infinite loop / DoS.
  • while (ptr_idx < total_length) permits reading the 2-byte
    length prefix when only 1 byte remains. Off-by-one.
  • The return type is uint8_t, but total_length is uint16_t and
    a string can be 0 bytes — the protocol allows up to ~32 k strings
    in a single array. Counts > 255 are silently truncated mod 256.
    Same shape as the Avtp_Can_GetCanPayloadLength fix in
    acf/can: widen Can_GetCanPayloadLength return type to uint16_t #128.

Fix

  • Add a consumed counter in DeserializeStringArray that's
    actually incremented per iteration and used to drive the break.
  • Add an explicit "remaining bytes ≥ str_length" check before
    memcpy so a single malformed length value can't trigger the OOB
    read.
  • In GetVSSDataStringArrayLength, change the loop guard to
    ptr_idx + 2 <= total_length and add a post-length-read check
    that the string body fits.
  • Widen the GetVSSDataStringArrayLength return type (and its
    declaration in include/avtp/acf/custom/Vss.h) from uint8_t to
    uint16_t.
  • Add a NULL-check for strings[i] before dereferencing it.
  • All bound-check additions are computed in uint32_t so the
    additions themselves cannot overflow when str_length approaches
    UINT16_MAX.

No behaviour change for well-formed input. Existing unit tests in
unit/test-vss.c (the 3-string happy path) continue to pass.

Notes

  • Companion to acf/can: widen Can_GetCanPayloadLength return type to uint16_t #128 (the Can/CanBrief payload-length truncation
    fix). Same class of "uint8_t holding a quantity that can exceed
    255" bug, in a different protocol handler.
  • I noticed several adjacent issues that I did not fix in this
    PR to keep scope focused, including unbounded memcpy calls in
    the various Avtp_*_SetPayload setters and memcpys of fixed
    array types in Avtp_Vss_GetVssData. Happy to follow up with
    separate PRs (or, for the API-shape question of whether setters
    should grow a bufferSize parameter to mirror the existing
    IsValid(pdu, bufferSize) helpers, a META-Issue first to get
    maintainer guidance).

Avtp_Vss_DeserializeStringArray and Avtp_Vss_GetVSSDataStringArrayLength
in src/avtp/acf/custom/Vss.c walk an on-wire string-array layout of
[length-prefix : 2 bytes][string : length bytes] pairs.  Both had
serious bounds-check defects allowing crafted network input to drive
out-of-bounds reads:

1. Avtp_Vss_DeserializeStringArray declared `uint16_t idx = 0;` and
   only checked `if (idx >= array_length) break;` — but `idx` was
   never incremented, so the break was dead code (it only fired when
   array_length == 0).  With attacker-controlled str_length values
   from the wire (uint16_t, up to 65535), the memcpy() inside the
   loop could read up to ~64 KiB past the end of
   vss_data_string_array->data on every iteration after the first
   malicious entry.  Heap-disclosure / crash class.

2. Avtp_Vss_GetVSSDataStringArrayLength advanced
   `ptr_idx += 2 + str_length` in uint16_t arithmetic.  A str_length
   value near UINT16_MAX wraps ptr_idx around to a small number,
   making the loop iterate from a low offset again.  Pseudo-infinite
   loop / DoS class.  In addition, the loop condition
   `ptr_idx < total_length` was off by one — it permitted reading the
   2-byte length prefix when only 1 byte remained.

3. Avtp_Vss_GetVSSDataStringArrayLength also returned uint8_t for a
   count that could plausibly exceed 255 (since total_length is
   uint16_t and a string can be 0 bytes, the protocol allows up to
   ~32k strings in a single array).  Same shape as
   Avtp_Can_GetCanPayloadLength fixed in the previous PR.

Fixes:

- Add a `consumed` counter in DeserializeStringArray that is actually
  incremented per iteration and used to drive the break.
- Add an explicit "remaining bytes >= str_length" check before
  memcpy() so a single malformed string-length value cannot trigger
  the OOB read.
- In GetVSSDataStringArrayLength, change the loop guard to
  `ptr_idx + 2 <= total_length` (uint32_t arithmetic), and add a
  matching post-length-read check that the string body fits.
- Widen the GetVSSDataStringArrayLength return type and the function
  declaration in include/avtp/acf/custom/Vss.h from uint8_t to
  uint16_t.
- Add a NULL-check for strings[i] before dereferencing it (the
  previous code would NULL-deref strings[i]->data_length on the
  first malformed entry of an under-allocated output array).

All bound-check additions are computed in uint32_t so the additions
themselves cannot overflow when str_length approaches UINT16_MAX.

No behaviour change for well-formed input.  Existing unit tests in
unit/test-vss.c continue to pass (they exercise the 3-string happy
path).

Signed-off-by: Matt Jones <47545907+SoundMatt@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens the VSS ACF string-array parsing helpers against crafted on-wire inputs that could previously trigger out-of-bounds reads during string-array length counting and deserialization.

Changes:

  • Adds explicit bounds checks while walking [u16 length][bytes...] string-array elements to prevent OOB reads and overflow-driven looping.
  • Fixes a dead bounds check in Avtp_Vss_DeserializeStringArray by tracking consumed bytes and validating each element before memcpy.
  • Widens Avtp_Vss_GetVSSDataStringArrayLength’s return type (and public declaration) from uint8_t to uint16_t.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/avtp/acf/custom/Vss.c Adds overflow-safe bounds checks for string-array counting and deserialization to prevent unbounded reads.
include/avtp/acf/custom/Vss.h Updates the public API to return uint16_t for string-array length counting.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/avtp/acf/custom/Vss.c Outdated
Comment thread src/avtp/acf/custom/Vss.c
Copy link
Copy Markdown
Collaborator

@SebastianSchildt SebastianSchildt left a comment

Choose a reason for hiding this comment

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

Hello,

thank you for pointing out the issues. The ACF VSS code is experimental and not very high quality currently, so we surely appreciate any improvements.

For the current PR I see two issues

  • There are still lot of AI artifacts in the comments, i.e. it describes the "history" of this code and summaries of steps taken. This is not what is expected in code. Comments should be helpful explaining the code at hand, not explaining its genesis
  • As this fixes a non-trivial/not super trivial part of the code it would be good to have some simple unit tests, especially since the code claims more robustness, maybe also with malformed input data. Or at least maybe explain in the issue (as there are no good tests for this currently) how this has been tested/verified working

@SoundMatt
Copy link
Copy Markdown
Contributor Author

Hi @SebastianSchildt, thanks for the thoughtful review.

On the comments: I'd like to push back gently on the framing. The
comments here that describe how the bound checks fit together
(computing in uint32_t to avoid wrap when str_length approaches
UINT16_MAX, what each guard actually prevents) aren't AI history —
they're there because this is parsing untrusted network input, and
the why behind each guard is the kind of thing that bites in two
years when someone touches the function. The existing
IsValid(pdu, bufferSize) helpers in this codebase carry comments
of similar shape for similar reasons.

Where I think you have a fair point is the few spots where the
comments do lapse into "the previous code did X" / "the previous
implementation declared Y." That genuinely is history-of-code rather
than current logic, and I'd happily scrub those. Copilot's note
about the 32767 vs 65535 upper-bound figure is the same kind of
correction and worth folding in too.

On unit tests: agreed, the malformed-input cases deserve coverage,
and that's exactly what makes a hardening PR like this worth
merging rather than reverting. I'd be glad to partner on the test
additions — Open1722's cmocka harness isn't something I have local
experience with yet (I'm on macOS and haven't gotten the existing
unit/ suite building cleanly here), so a quick pointer to how
you'd like the malformed-input fixtures structured would unblock
me. Or, if you'd rather write the tests yourself and take this PR's
fix on top, I'm equally happy to defer to whichever shape works
best for the team.

If you'd rather close this PR than work through revisions, that's
a reasonable call given the experimental status of ACF-VSS — I
just want to flag that the underlying bugs (dead idx bounds
check causing memcpy to read up to ~64 KiB past the buffer end on
malformed input; uint16_t arithmetic wrap in the count loop) are
real and reachable from network input, so you'd be choosing to
defer the fix rather than reject it. Happy with whichever direction
makes sense.

@SebastianSchildt
Copy link
Copy Markdown
Collaborator

SebastianSchildt commented May 7, 2026

Please adjust the comments as disussed: Leave the ones explaining what the current code does, and scrub those that refer "previous code"

If you can add a unit test that is appreciated, as I would need a couple of days to look into this. If that is so easily achieved, I will look into this and maybe try a seperate PR for this, but as I said, likely not this week.

My hints (untested) ideas how to go about the test would be

Currently the existing tests already compares results from constructing the VSS packet with fixed data such as seen here

uint8_t arr_in_mem[] = {0x00, 0x05, 'H', 'e', 'l', 'l', 'o',

   uint8_t arr_in_mem[] = {0x00, 0x05, 'H', 'e', 'l', 'l', 'o',
                             0x00, 0x05, 'W', 'o', 'r', 'l', 'd',
                             0x00, 0x07, 'T', 's', 'c', 'h', 'u', 's', 's'};

I would add a seperate "Malformed string array" test (to not make the existing test longer), where you could try creating a a truncated version with a wrong length field i.e.

   uint8_t arr_in_mem[] = {0x00, 0x05, 'H', 'e', 'l', 'l', 'o',
                             0x00, 0x05, 'W', 'o', 'r', 'l', 'd',
                             0x00, 0xAA, 'T', 's', 'c', 'h', 'u', 's', 's'};

Open point: Is there an "easy" way to put it into an ACF Frame? I guess it would always work constructing the packet "correctly" with the helper functions (which we hopefully trust due to the tests already there), and then just patching the last length field in the array

Then we can parse this malformed packet/get the length and my expectation (if I understand the hardened parser correctly, otherwise please do the correct assumptions in line with the new code) would be, that it behaves like the array is finished already after the second element.

Wrt to Mac OS I do remember having issues with cmocka as well, but I then reverted to using the Dev container, so I am not entirely sure this can run on Mac natively

@SoundMatt
Copy link
Copy Markdown
Contributor Author

Thanks for the test guidance — your malformed-fixture sketch (patching the third length field to 0xAA so the body lies) is a clean starting point. I'll add that as a follow-up commit on this branch using the dev container, so the cmocka/macOS friction goes away.

On the comments: walking back my previous offer to scrub the "previous code" references. Looking at them again with fresh eyes, each one is forward-looking documentation for the next maintainer rather than narrative history:

  • "widened from uint8_t to uint16_t" tells a future cleanup don't shrink this back to uint8_t for style consistency — that's exactly the kind of revert PR acf/can: widen Can_GetCanPayloadLength return type to uint16_t #128 had to fix elsewhere in this codebase, and it would reintroduce the silent mod-256 truncation here too.
  • "computed in uint32_t to avoid wrap when str_length approaches UINT16_MAX" explains the cast's purpose so a future cleanup doesn't drop it. Without that line the cast looks like noise; with it, the rationale is permanent.
  • "the previous bounds check at idx was dead because idx was never incremented" is the entire reason the new consumed counter exists. Without that line, a future maintainer doing a "simplify the loop" pass could remove the counter as redundant and re-open the OOB read.

In code parsing untrusted network input, those rationales are exactly what stops the bug class from being reintroduced. The IsValid helpers in this codebase carry comments of similar shape for similar reasons. So I'd like to leave the comments as they stand on this PR.

That said — "Allow edits by maintainers" is checked on this branch. If you feel strongly enough that specific lines should be scrubbed, please push the edits directly and I'll defer to the call; I just don't want to be the one to pull them out. The substantive offer (the malformed-input test commit) is the part I'll work on next.

Per @SebastianSchildt's review on this PR. Constructs a 23-byte
on-wire fixture identical to the well-formed vss_data_string_array
test, except the third length prefix is patched from 0x0007 to
0x00AA — so the third entry claims 170 bytes of body but only 7
bytes of buffer remain after its length prefix.

Asserts:
- Avtp_Vss_GetVSSDataStringArrayLength returns 2 (not 3, and not 0
  which would be the result of bailing on the first read).
- Avtp_Vss_DeserializeStringArray fills the first two output structs
  with 'Hello' and 'World' and leaves the third struct at its initial
  state (data_length == 0). The third entry is rejected by the new
  remaining-bytes-vs-str_length check before memcpy.

Without the bounds-check fix in this PR the same input would have
driven memcpy() to read up to ~64 KiB past the end of the buffer in
DeserializeStringArray, and the count loop in
GetVSSDataStringArrayLength would have wrapped uint16_t arithmetic
on the (uint16_t + 0xAA) addition, producing a pseudo-infinite loop.

Signed-off-by: Matt Jones <47545907+SoundMatt@users.noreply.github.com>
@SoundMatt
Copy link
Copy Markdown
Contributor Author

Test commit pushed as 89d74a3. The new vss_data_string_array_malformed test uses your fixture sketch — a 23-byte buffer where the third length is 0x00AA against only 7 bytes of remaining body. Asserts GetVSSDataStringArrayLength returns 2 and DeserializeStringArray fills only the first two output structs ("Hello" and "World"), leaving the third at its initial state.

CI will run once the workflow gets approval. If anything surfaces I'll address it.

Signed-off-by: Sebastian Schildt <sebastian.schildt@hs-heilbronn.de>
Copy link
Copy Markdown
Collaborator

@SebastianSchildt SebastianSchildt left a comment

Choose a reason for hiding this comment

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

lgtm 🤖

@SebastianSchildt
Copy link
Copy Markdown
Collaborator

Tests green, merging now, thank your for your contribution

@SebastianSchildt SebastianSchildt merged commit 3868dc2 into COVESA:main May 11, 2026
5 checks passed
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.

3 participants