Prevent deserialization memory exhaustion and ReadVarInt infinite loop (CWE-400)#105
Prevent deserialization memory exhaustion and ReadVarInt infinite loop (CWE-400)#105hairetikos wants to merge 1 commit intoZclassicCommunity:masterfrom
Conversation
Updated deserialization functions to use a limit on the maximum number of elements accepted, preventing potential memory exhaustion attacks.
Security Audit: PR #105 — "Prevent deserialization memory exhaustion and ReadVarInt infinite loop (CWE-400)"Auditor: AI-assisted review (Claude/Opus)
Executive SummaryThe PR correctly identifies and partially fixes two real vulnerabilities: unbounded container allocation and a potential infinite loop in Inline suggested changes for FINDING-001 —
|
| Vector | Element Size | Max Elements | vs. 2M Limit |
|---|---|---|---|
vShieldedSpend |
384 bytes | ~265 | Safe |
vShieldedOutput |
948 bytes | ~107 | Safe |
vjoinsplit |
~700+ bytes | ~145 | Safe |
vin |
~41 bytes | ~2,487 | Safe |
vout |
~9 bytes | ~11,333 | Safe |
Risk & Impact Summary
| # | Finding | Severity | Impact | Consensus Risk | P2P Exploitable? |
|---|---|---|---|---|---|
| 001 | n++ overflow in ReadVarInt |
HIGH | Silent wrong return value; corrupted CCoins deserialization |
Low (mitigated by iteration guard) | Indirectly |
| 002 | nSolution 320 MiB allocation |
HIGH | 160 headers x 2 MiB = 320 MiB from single peer | None (Equihash check post-deser) | Yes |
| 003 | Signed type UB in ReadVarInt |
MEDIUM | UB; compiler may optimize unexpectedly | Low | Indirectly |
| 004 | Bare ReadCompactSize in main.cpp |
MEDIUM | Malformed headers silently accepted | None | Yes |
| 005 | LimitedString not hardened |
MEDIUM | Inconsistent coverage on P2P alert path | None | Minimal |
| 006 | Duplicate error messages | LOW | Harder debugging | None | N/A |
| 007 | Naming inconsistency | LOW | Developer confusion | None | N/A |
| 008 | Iteration guard on disk paths | INFO | Positive finding | N/A | N/A |
Testing Required Before Merge
Mandatory:
- Full chain sync regression — Genesis to tip on mainnet. Confirm no valid block rejected (especially verify
MAX_EQUIHASH_SOLUTION_SIZE = 1408covers Equihash(200,9) = 1344 pre-Bubbles and Equihash(192,7) = 400 post-Bubbles). ReadVarIntround-trip test — Boundary values foruint32_t/uint64_t(0,0x7F,0x80,MAX-1,MAX).ReadVarIntoverflow negative test — Crafted stream triggeringn++wrap must throw.ReadCompactSizeWithLimitnegative test — Element countMAX_DESER_ELEMENT_COUNT + 1must throw for each container type.
Recommended:
- Malicious
headersstress test — 160 headers with >1408-bytenSolution. Verify disconnect. - Cross-version peer compatibility — Patched vs unpatched nodes exchanging blocks/headers.
Files Summary
| File | In This PR? | Changes Needed |
|---|---|---|
src/serialize.h |
Yes | Inline suggestions attached (FINDING 001-003, 006-007) |
src/serialize.h (LimitedString) |
Line not in diff | FINDING-005 fix shown above |
src/main.cpp:6117 |
No | FINDING-004 fix shown above |
src/primitives/block.h |
No | FINDING-002 nSolution bound shown above |
Overall Verdict
Directionally correct, needs targeted fixes before merge. The PR addresses real vulnerabilities. FINDING-001 is a one-line fix. FINDING-002 needs a per-field limit. See inline suggested changes and the additional file fixes above.
This review was generated by an AI assistant and should be independently verified. It is not a substitute for professional security auditing.
VictorLux
left a comment
There was a problem hiding this comment.
Code Review — Proposed Fixes for All Findings
Inline suggestions below address FINDING-001 through FINDING-007 for src/serialize.h.
Additional files needing changes (not in this PR's diff):
src/main.cpp:6117 (FINDING-004)
// CURRENT:
ReadCompactSize(vRecv); // ignore tx count; assume it is 0.
// PROPOSED:
ReadCompactSizeWithLimit(vRecv, 0); // tx count must be 0 in headers msgsrc/primitives/block.h (FINDING-002 — nSolution bound)
After READWRITE(nSolution); (line 51), add:
if (ser_action.ForRead() && nSolution.size() > MAX_EQUIHASH_SOLUTION_SIZE) {
throw std::ios_base::failure("nSolution size " +
std::to_string(nSolution.size()) +
" exceeds Equihash maximum " +
std::to_string(MAX_EQUIHASH_SOLUTION_SIZE));
}src/serialize.h — LimitedString (FINDING-005)
Line 501-510 (not in diff). Replace:
void Unserialize(Stream& s)
{
size_t size = ReadCompactSize(s);
if (size > Limit) {
throw std::ios_base::failure("String length limit exceeded");
}
string.resize(size);
if (size != 0)
s.read((char*)&string[0], size);
}With:
void Unserialize(Stream& s)
{
size_t size = ReadCompactSizeWithLimit(s, Limit);
string.resize(size);
if (size != 0)
s.read((char*)&string[0], size);
}| /** | ||
| * Maximum element count accepted during deserialization of untrusted data. | ||
| * Set to 2 MiB (matching MAX_PROTOCOL_MESSAGE_LENGTH from net.h) since no | ||
| * single P2P message can carry more data than this on the wire. | ||
| * Disk serialization (SER_DISK) callers can bypass this via the original | ||
| * ReadCompactSize which still uses MAX_SIZE. | ||
| */ | ||
| static const unsigned int MAX_DESER_ELEMENTS = 2 * 1024 * 1024; |
There was a problem hiding this comment.
FINDING-002 + FINDING-007: Rename to MAX_DESER_ELEMENT_COUNT (it's element count, not bytes), clarify the comment about units mismatch, and add MAX_EQUIHASH_SOLUTION_SIZE to prevent 320 MiB nSolution allocation via malicious headers messages (160 headers x 2 MiB each).
| /** | |
| * Maximum element count accepted during deserialization of untrusted data. | |
| * Set to 2 MiB (matching MAX_PROTOCOL_MESSAGE_LENGTH from net.h) since no | |
| * single P2P message can carry more data than this on the wire. | |
| * Disk serialization (SER_DISK) callers can bypass this via the original | |
| * ReadCompactSize which still uses MAX_SIZE. | |
| */ | |
| static const unsigned int MAX_DESER_ELEMENTS = 2 * 1024 * 1024; | |
| /** | |
| * Maximum element count accepted during deserialization of untrusted data. | |
| * NOTE: This is an ELEMENT COUNT, not a byte count. For byte-sized | |
| * containers (vector<unsigned char>, basic_string<char>) the numeric value | |
| * matches MAX_PROTOCOL_MESSAGE_LENGTH (net.h). For non-byte containers | |
| * (vector<CTransaction>, map<K,V>, etc.) each element is larger than one | |
| * byte, so the effective byte cap is higher — the outer | |
| * MAX_PROTOCOL_MESSAGE_LENGTH enforced in net.cpp provides the byte limit. | |
| * Disk serialization (SER_DISK) callers can bypass this via the original | |
| * ReadCompactSize which still uses MAX_SIZE. | |
| */ | |
| static const unsigned int MAX_DESER_ELEMENT_COUNT = 2 * 1024 * 1024; | |
| /** | |
| * Maximum Equihash solution size (bytes) accepted during deserialization. | |
| * Equihash(200,9) = 1344 bytes (pre-Bubbles), Equihash(192,7) = 400 bytes | |
| * (post-Bubbles height 585318+). Use the larger value + small margin. | |
| * Without this bound, a malicious headers message can allocate up to | |
| * 160 * MAX_DESER_ELEMENT_COUNT = 320 MiB of nSolution data. | |
| */ | |
| static const unsigned int MAX_EQUIHASH_SOLUTION_SIZE = 1408; |
Then use MAX_EQUIHASH_SOLUTION_SIZE in primitives/block.h after READWRITE(nSolution) (see review body).
| uint64_t nSizeRet = ReadCompactSize(is); | ||
| if (nSizeRet > nMaxElements) | ||
| throw std::ios_base::failure("ReadCompactSize(): element count exceeds context limit"); |
There was a problem hiding this comment.
FINDING-006: Include the actual values in the error message so operators can distinguish which guard fired (inner ReadCompactSize vs outer limit check) and see the exact size/limit during incidents.
| uint64_t nSizeRet = ReadCompactSize(is); | |
| if (nSizeRet > nMaxElements) | |
| throw std::ios_base::failure("ReadCompactSize(): element count exceeds context limit"); | |
| uint64_t nSizeRet = ReadCompactSize(is); | |
| if (nSizeRet > nMaxElements) | |
| throw std::ios_base::failure( | |
| "ReadCompactSizeWithLimit(): size " + std::to_string(nSizeRet) + | |
| " exceeds element limit " + std::to_string(nMaxElements)); |
| I n = 0; | ||
| // Maximum possible VarInt encoding length for type I: | ||
| // Each byte encodes 7 bits, so sizeof(I)*8 bits needs at most | ||
| // ceil(sizeof(I)*8/7) bytes. Add 1 for safety. | ||
| const unsigned int max_iters = (sizeof(I) * 8 + 6) / 7; | ||
| unsigned int count = 0; | ||
| while(true) { | ||
| if (++count > max_iters) | ||
| throw std::ios_base::failure("ReadVarInt(): too many bytes"); | ||
| unsigned char chData = ser_readdata8(is); | ||
| // Overflow check: if n already uses the top 7 bits, shifting | ||
| // left by 7 more would overflow type I. | ||
| if (n > (std::numeric_limits<I>::max() >> 7)) | ||
| throw std::ios_base::failure("ReadVarInt(): overflow"); | ||
| n = (n << 7) | (chData & 0x7F); | ||
| if (chData & 0x80) | ||
| n++; |
There was a problem hiding this comment.
FINDING-001 + FINDING-003: Three changes here:
-
static_assert(FINDING-003): Prevents signed-type UB at compile time. All currentVARINT()callers use unsigned types (uint32_tvianCode/nVersion/nHeightincoins.h,unsigned intinCScriptCompressor). -
n++overflow guard (FINDING-001): Aftern = (n << 7) | (chData & 0x7F), ifn == MAXandchData & 0x80, thenn++wraps to 0. This one-line check prevents silent corruption. -
Braces on if/else: Required for the added guard block. Note: the
else return n;on the line immediately after this hunk should also get braces for consistency.
| I n = 0; | |
| // Maximum possible VarInt encoding length for type I: | |
| // Each byte encodes 7 bits, so sizeof(I)*8 bits needs at most | |
| // ceil(sizeof(I)*8/7) bytes. Add 1 for safety. | |
| const unsigned int max_iters = (sizeof(I) * 8 + 6) / 7; | |
| unsigned int count = 0; | |
| while(true) { | |
| if (++count > max_iters) | |
| throw std::ios_base::failure("ReadVarInt(): too many bytes"); | |
| unsigned char chData = ser_readdata8(is); | |
| // Overflow check: if n already uses the top 7 bits, shifting | |
| // left by 7 more would overflow type I. | |
| if (n > (std::numeric_limits<I>::max() >> 7)) | |
| throw std::ios_base::failure("ReadVarInt(): overflow"); | |
| n = (n << 7) | (chData & 0x7F); | |
| if (chData & 0x80) | |
| n++; | |
| static_assert(std::is_unsigned<I>::value, | |
| "ReadVarInt requires an unsigned integer type"); | |
| I n = 0; | |
| // Maximum possible VarInt encoding length for type I: | |
| // Each byte encodes 7 bits, so sizeof(I)*8 bits needs at most | |
| // ceil(sizeof(I)*8/7) bytes. Add 1 for safety. | |
| const unsigned int max_iters = (sizeof(I) * 8 + 6) / 7; | |
| unsigned int count = 0; | |
| while(true) { | |
| if (++count > max_iters) | |
| throw std::ios_base::failure("ReadVarInt(): too many bytes"); | |
| unsigned char chData = ser_readdata8(is); | |
| // Overflow check: if n already uses the top 7 bits, shifting | |
| // left by 7 more would overflow type I. | |
| if (n > (std::numeric_limits<I>::max() >> 7)) | |
| throw std::ios_base::failure("ReadVarInt(): overflow"); | |
| n = (n << 7) | (chData & 0x7F); | |
| if (chData & 0x80) { | |
| if (n == std::numeric_limits<I>::max()) | |
| throw std::ios_base::failure("ReadVarInt(): overflow on increment"); | |
| n++; | |
| } else { | |
| return n; | |
| } |
Note: This suggestion extends 2 lines past the diff hunk to include the else branch with braces. If GitHub can't apply it automatically, the full change is in the patch at the bottom of the audit comment.
| void Unserialize(Stream& is, std::basic_string<C>& str) | ||
| { | ||
| unsigned int nSize = ReadCompactSize(is); | ||
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENTS); |
There was a problem hiding this comment.
FINDING-007: Rename MAX_DESER_ELEMENTS → MAX_DESER_ELEMENT_COUNT (consistent with the constant rename above).
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENTS); | |
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENT_COUNT); |
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENTS); | ||
| unsigned int i = 0; | ||
| while (i < nSize) | ||
| { |
There was a problem hiding this comment.
FINDING-007: Rename.
| { | |
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENT_COUNT); |
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENTS); | ||
| unsigned int i = 0; | ||
| while (i < nSize) | ||
| { |
There was a problem hiding this comment.
FINDING-007: Rename.
| { | |
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENT_COUNT); |
| { | ||
| v.clear(); | ||
| unsigned int nSize = ReadCompactSize(is); | ||
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENTS); |
There was a problem hiding this comment.
FINDING-007: Rename.
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENTS); | |
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENT_COUNT); |
| { | ||
| m.clear(); | ||
| unsigned int nSize = ReadCompactSize(is); | ||
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENTS); |
There was a problem hiding this comment.
FINDING-007: Rename.
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENTS); | |
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENT_COUNT); |
| { | ||
| m.clear(); | ||
| unsigned int nSize = ReadCompactSize(is); | ||
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENTS); |
There was a problem hiding this comment.
FINDING-007: Rename.
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENTS); | |
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENT_COUNT); |
| { | ||
| l.clear(); | ||
| unsigned int nSize = ReadCompactSize(is); | ||
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENTS); |
There was a problem hiding this comment.
FINDING-007: Rename.
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENTS); | |
| unsigned int nSize = ReadCompactSizeWithLimit(is, MAX_DESER_ELEMENT_COUNT); |
NOTE: this is a fix for an extreme (D)DoS scenario, it theoretically would overwhelm a system with limited RAM like a 4GB VPS... but still might be good to merge. please use Claude Opus/other LLM to scrutinize this fix, as maybe it could be more elegant
Updated deserialization functions to use a limit on the maximum number of elements accepted, preventing potential memory exhaustion attacks.
(fixes an extreme DDoS scenario: if an attacker made hundreds or thousands of simultaneous connections and overwhelmed the node with data, it could've caused RAM/mem exhaustion, leading to a frozen system and possible node termination if for example the Linux OOM killer decided to kill zclassicd to free up memory)