Skip to content

Guard GetValFromStream against out-of-bounds reads in release builds#3821

Open
mcfnord wants to merge 1 commit into
jamulussoftware:mainfrom
mcfnord:fix-3819-getvalfromstream-bounds
Open

Guard GetValFromStream against out-of-bounds reads in release builds#3821
mcfnord wants to merge 1 commit into
jamulussoftware:mainfrom
mcfnord:fix-3819-getvalfromstream-bounds

Conversation

@mcfnord

@mcfnord mcfnord commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Fixes #3819

GetValFromStream validates its arguments only with Q_ASSERT, which is compiled out in release builds — the configuration everyone actually ships. CVector derives from std::vector and uses its unchecked operator[], so a truncated or malformed network message can drive vecIn[iPos] past the end of the buffer with no protection at all in release.

Approach

Keep the asserts (developer errors still fail loudly in debug builds) and add a runtime guard that returns 0 when iNumOfBytes is outside 1..4 or when fewer than iNumOfBytes bytes remain:

Q_ASSERT ( ( iNumOfBytes > 0 ) && ( iNumOfBytes <= 4 ) );
Q_ASSERT ( vecIn.Size() >= iPos + iNumOfBytes );

if ( ( iNumOfBytes < 1 ) || ( iNumOfBytes > 4 ) || ( vecIn.Size() < iPos + iNumOfBytes ) )
{
    return 0;
}

Returning 0 (rather than crashing) keeps the existing "malformed input is tolerated, not fatal" posture of the surrounding parser — the sibling GetStringFromStream already returns an error code on short input rather than aborting. This is the minimal, hot-path-safe change and touches no call sites.

Open question for maintainers, per the issue: if a hard failure / disconnect on malformed input is preferred over silently returning 0, that would be a larger change (the function has no error channel today, so it would need a signature change across ~50 call sites). Happy to go that direction if you'd rather.

Testing

  • clang-format-14 clean
  • Headless build + server smoke test (clean startup, clean SIGTERM)

GetValFromStream validated its arguments only with Q_ASSERT, which is
compiled out in release builds (the shipped configuration). CVector uses
std::vector's unchecked operator[], so a truncated or malformed network
message could drive a read past the end of the buffer with no protection.

Add a runtime guard alongside the existing asserts: return 0 when
iNumOfBytes is out of the 1..4 range or when fewer than iNumOfBytes bytes
remain. The asserts are kept so developer errors still fail loudly in
debug builds, while release builds no longer perform the out-of-bounds
read.

Fixes jamulussoftware#3819
@ann0see
ann0see requested review from ann0see and pljones July 21, 2026 20:44
Comment thread src/protocol.cpp
Q_ASSERT ( vecIn.Size() >= iPos + iNumOfBytes );

// The asserts above are compiled out in release builds, so also guard at
// runtime: CVector uses unchecked operator[], and reading past the end of a

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.

Might be removed...

Comment thread src/protocol.cpp
// truncated/malformed network message would be an out-of-bounds access.
if ( ( iNumOfBytes < 1 ) || ( iNumOfBytes > 4 ) || ( vecIn.Size() < iPos + iNumOfBytes ) )
{
return 0;

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.

Here's the question what it should actually return...

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.

This is conflating an error indication with a fetched value. In my view, the correct fix would be for the GetValFromStream() and GetCountryFromStream() functions to follow the format of GetStringFromStream(), where the output variable is passed by reference as a parameter, and the return value is a bool indicating error status (true) or success (false). Every fetch would then be validated and could abort the Evaluate function as soon as a malformation, bounds overflow or bad value was detected.

This would require all calls within protocol.cpp to be reworked, but I think it's worth doing, and shouldn't take too long. I'm happy to take that on if agreed,

Also, the runtime checks on iNumBytes are only checking against programmer error, not input data error, since every call to GetValFromStream() passes a constant 1, 2 or 4 as iNumBytes.

@ann0see ann0see Jul 22, 2026

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.

The data from the wire is definitely unchecked at the moment then?

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.

It looks like some values are checked, and others are just used without checking.

@ann0see ann0see added this to Tracking Jul 21, 2026
@github-project-automation github-project-automation Bot moved this to Triage in Tracking Jul 21, 2026
@ann0see

ann0see commented Jul 21, 2026

Copy link
Copy Markdown
Member

It's probably worth understanding where it is called and what would happen if 0 is returned.

@ann0see ann0see added the AI AI generated or potentially AI generated label Jul 21, 2026
@mcfnord

mcfnord commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Note

📡 STAND BY FOR AN LLM-AUTHORED MESSAGE.

Good questions — here's the call-site trace behind returning 0.

Where it's called. GetValFromStream has ~60 call sites, all inside Evaluate* / Parse* message handlers. Every one of them is reached only after ParseMessageFrame has (a) checked the frame's declared length against the datagram size (iLenBy != iNumBytesIn - MESS_LEN_WITHOUT_DATA_BYTE → error) and (b) verified the CRC. So on well-formed traffic iPos + iNumOfBytes ≤ Size() always holds and the guard never fires — zero behavioral change for real clients.

The call sites come in two shapes:

  1. List loopsEvaluateCLConnClientsListMes, the server-list parsers, etc. (while ( iPos < iDataLen ) at lines 1192 / 2095 / 2203 / 2446). Each already does its own ( iDataLen - iPos ) < N → return true bounds check at the top of every iteration, so it never under-reads. The new guard is redundant here and, importantly, cannot fire inside these loops, so it can't affect loop termination.

  2. Fixed-layout readers — the straight-line handlers that read a known field sequence, trusting the frame length/CRC check above them. These are the ones a truncated-but-CRC-valid or version-mismatched body could push past the end. This is the case the guard exists for.

What happens when 0 is returned. It only happens on already-malformed input. There, 0 flows into a field that is then either range-checked/clamped downstream (e.g. the iChanID bounds check from #3810, or the instrument/skill/country validation) or benignly stored. iPos is left unadvanced, so any following reads in the same fixed message also return 0 — a harmless zero-cascade, not a hang (no caller loops on iPos without its own size guard). In every case 0 is strictly safer than the status quo, which is an unchecked std::vector::operator[] read past the end — undefined behavior, and the direct cause of the #3819 / #3747 out-of-bounds crashes.

On your two inline notes:

  • "Might be removed" (the Q_ASSERTs): I'd keep them. They catch a developer mistake — a caller passing iNumOfBytes outside 1..4 — loudly in debug builds. The runtime guard covers a different audience: network malformation in release builds. Both are cheap; they don't overlap.
  • "the question what it should actually return": 0 matches the parser's existing "tolerate malformed input, don't abort" posture — the sibling GetStringFromStream already returns an error code on short input rather than crashing, and every caller here already treats these values as untrusted. The alternative — a hard disconnect on malformed input — needs an error channel the function doesn't have today, which is a signature change across ~50 call sites. Happy to go that way if you'd prefer it, but 0 is the minimal, hot-path-safe fix that closes the crash.

@mcfnord
mcfnord marked this pull request as ready for review July 22, 2026 05:14
@ann0see

ann0see commented Jul 22, 2026

Copy link
Copy Markdown
Member

So on well-formed traffic iPos + iNumOfBytes ≤ Size() always holds and the guard never fires — zero behavioral change for real clients.

If this is true, we could probably get away by just adding a @requires as documentation

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI AI generated or potentially AI generated

Projects

Status: Triage

Development

Successfully merging this pull request may close these issues.

GetValFromStream uses ASSERT which is likely compiled out during release builds

3 participants