fix: cap compact log entry zero-prefix data expansion#11110
Conversation
| int zeroPrefix = decoderContext.DecodeInt(); | ||
| ReadOnlySpan<byte> rlpData = decoderContext.DecodeByteArraySpan(); | ||
|
|
||
| if (zeroPrefix < 0 || zeroPrefix > RlpLimit.Limit - rlpData.Length) |
There was a problem hiding this comment.
Isn this whta the decoderContext.SkipItem() do?
There was a problem hiding this comment.
Sorry, I mean decoderContext.Check().
There was a problem hiding this comment.
Don't think Check can help here, but using
Rlp.GuardLimit(zeroPrefix, RlpLimit.Limit - rlpData.Length, RlpLimit);
will make it follow general approach of hiding exception behind an Rlp* method. Negativity check will soon be removed anyway, as all DecodeInt will be replaced with DecodeUInt.
Alternatively, just moving exception-throwing to RlpHelper should also work.
LukaszRozmej
left a comment
There was a problem hiding this comment.
Review Summary
Clean, well-scoped fix. The approach — validating the decoded zeroPrefix against the existing LogEntry 16 MB limit before allocating — is correct, and extracting DecodeCompactData cleanly deduplicates the two call sites. Regression tests cover both paths.
Good
- The bound check
zeroPrefix > RlpLimit.Limit - rlpData.Lengthis overflow-safe: it avoids computingzeroPrefix + rlpData.Length(which would wrap for largezeroPrefix) and, becauserlpData.Length >= 0, the RHS is always<= RlpLimit.Limit, so it also correctly rejectsrlpData.Lengthvalues that on their own exceed the limit. - The
zeroPrefix < 0guard is necessary —DecodeIntreturns a signed int, and a crafted RLP could produce a negative value that would otherwise bypass the upper-bound comparison (via negative arithmetic) or cause a negative allocation attempt. - Tests exercise both
DecodeandDecodeLogEntryStructRef, hitting the two places the same helper is now used.
Minor observations (non-blocking)
-
DecodeLogEntryStructRefdoes not callGuardLimit(logEntryLength, RlpLimit)the wayDecodedoes at line 27. That's pre-existing and out of scope here, but the asymmetry is worth noting — the struct-ref path still relies solely on the newDecodeCompactDatacheck to bound the data buffer; the topics region is only bounded by the surrounding RLP. Consider a follow-up that mirrors theGuardLimit(logEntryLength, RlpLimit)call for consistency. -
Local
dataLengthinDecodeCompactDatais used once — it could be inlined (new byte[zeroPrefix + rlpData.Length]). Minor stylistic nit only; safe because the preceding check guarantees the sum fits inintwithout overflow. -
Test helper name
CreateCompactLogEntryWithTooLargeZeroPrefixis accurate, but strictly the payload is encoded with the standard RLP encoder — not viaCompactLogEntryDecoder.Encode. That's fine and intentional (it's meant to be malformed), but a brief comment clarifying the wire format shape ([address, topics, zeroPrefix, rlpData]) would help future readers understand what's being simulated.
Nothing else flagged
- No impact on the encode path.
- The happy-path roundtrip tests already in
LogEntryDecoderTestscontinue to exercise the non-malicious compact encoding viaReceiptDecoder/ receipt storage paths — no need to duplicate.
LGTM after (or without) addressing the minor observations.
alexb5dh
left a comment
There was a problem hiding this comment.
Looks ok, but not critical, as only used for decoding from Receipts DB.
Agree on addressing #1 and #2 from minor observations.
| int zeroPrefix = decoderContext.DecodeInt(); | ||
| ReadOnlySpan<byte> rlpData = decoderContext.DecodeByteArraySpan(); | ||
|
|
||
| if (zeroPrefix < 0 || zeroPrefix > RlpLimit.Limit - rlpData.Length) |
There was a problem hiding this comment.
Don't think Check can help here, but using
Rlp.GuardLimit(zeroPrefix, RlpLimit.Limit - rlpData.Length, RlpLimit);
will make it follow general approach of hiding exception behind an Rlp* method. Negativity check will soon be removed anyway, as all DecodeInt will be replaced with DecodeUInt.
Alternatively, just moving exception-throwing to RlpHelper should also work.
|
Applied the guard changes and added a regression test. Let me know if anything still looks off. Thanks for your review! |
|
@claude review |
|
Claude finished @LukaszRozmej's task in 3m 56s —— View job Code Review
This is a clean, well-scoped security fix addressing a heap-amplification vulnerability in What the PR does
The fix extracts CompactLogEntryDecoder.csCommit c3410d3 — initial fix
if (zeroPrefix < 0 || zeroPrefix > RlpLimit.Limit - rlpData.Length)Commit 4201cb4 — address review observations Refactors the check to use if (zeroPrefix < 0)
throw new RlpLimitException(...);
Rlp.GuardLimit(zeroPrefix, RlpLimit.Limit - rlpData.Length, RlpLimit);Splitting the checks is correct and necessary. The The second commit also adds LogEntryDecoderTests.csThree new tests added. Verified
Minor observations (non-blocking)
SummaryAll three of @LukaszRozmej's prior observations have been addressed. The fix is correct, the test coverage is solid, and the implementation is consistent with codebase conventions. LGTM. |
| @@ -1 +1 @@ | |||
| // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited | |||
There was a problem hiding this comment.
Remove duplication in tests:
// SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited
// SPDX-License-Identifier: LGPL-3.0-only
using System;
using Nethermind.Blockchain.Receipts;
using Nethermind.Core.Crypto;
using Nethermind.Core.Extensions;
using Nethermind.Core.Test.Builders;
using Nethermind.Serialization.Rlp;
using NUnit.Framework;
namespace Nethermind.Core.Test.Encoding;
public class LogEntryDecoderTests
{
private static LogEntry CreateSampleLogEntry() =>
new(TestItem.AddressA, new byte[] { 1, 2, 3 }, new[] { TestItem.KeccakA, TestItem.KeccakB });
private static void AssertLogEntriesEqual(LogEntry expected, LogEntry actual)
{
Assert.That(actual.Data, Is.EqualTo(expected.Data), "data");
Assert.That(actual.Address, Is.EqualTo(expected.Address), "address");
Assert.That(actual.Topics, Is.EqualTo(expected.Topics), "topics");
}
[TestCase(true, false)]
[TestCase(false, false)]
[TestCase(false, true)]
public void Can_do_roundtrip(bool valueDecode, bool useDecoderInstance)
{
LogEntry logEntry = CreateSampleLogEntry();
Rlp rlp = useDecoderInstance
? LogEntryDecoder.Instance.Encode(logEntry)
: Rlp.Encode(logEntry);
LogEntry decoded;
if (useDecoderInstance)
{
Rlp.ValueDecoderContext ctx = new(rlp.Bytes);
decoded = LogEntryDecoder.Instance.Decode(ref ctx)!;
}
else
{
decoded = valueDecode
? Rlp.Decode<LogEntry>(rlp.Bytes.AsSpan())
: Rlp.Decode<LogEntry>(rlp);
}
AssertLogEntriesEqual(logEntry, decoded);
}
[Test]
public void Can_do_roundtrip_ref_struct()
{
LogEntry logEntry = CreateSampleLogEntry();
Rlp rlp = Rlp.Encode(logEntry);
Rlp.ValueDecoderContext valueDecoderContext = new(rlp.Bytes);
LogEntryDecoder.DecodeStructRef(ref valueDecoderContext, RlpBehaviors.None, out LogEntryStructRef decoded);
Assert.That(Bytes.AreEqual(logEntry.Data, decoded.Data), "data");
Assert.That(logEntry.Address == decoded.Address, "address");
Span<byte> buffer = stackalloc byte[32];
KeccaksIterator iterator = new(decoded.TopicsRlp, buffer);
for (int i = 0; i < logEntry.Topics.Length; i++)
{
iterator.TryGetNext(out Hash256StructRef keccak);
Assert.That(logEntry.Topics[i] == keccak, $"topics[{i}]");
}
}
[Test]
public void Can_handle_nulls()
{
Rlp rlp = Rlp.Encode((LogEntry)null!);
LogEntry decoded = Rlp.Decode<LogEntry>(rlp);
Assert.That(decoded, Is.Null);
}
[Test]
public void Rejects_extra_topic_items_inside_topics_sequence()
{
Rlp malformed = Rlp.Encode(
Rlp.Encode(TestItem.AddressA.Bytes),
Rlp.Encode(Rlp.Encode(TestItem.KeccakA.Bytes), Rlp.OfEmptyByteArray),
Rlp.OfEmptyByteArray);
Assert.Throws<RlpException>(() =>
{
Rlp.ValueDecoderContext ctx = new(malformed.Bytes);
LogEntryDecoder.Instance.Decode(ref ctx);
});
}
[TestCase(false)]
[TestCase(true)]
public void Compact_decoder_rejects_zero_prefix_that_expands_data_beyond_limit(bool useStructRef)
{
Rlp malformed = CreateCompactLogEntryWithTooLargeZeroPrefix();
Assert.Throws<RlpLimitException>(() =>
{
Rlp.ValueDecoderContext ctx = new(malformed.Bytes);
if (useStructRef)
CompactLogEntryDecoder.DecodeLogEntryStructRef(ref ctx, RlpBehaviors.None, out _);
else
CompactLogEntryDecoder.Decode(ref ctx);
});
}
[Test]
public void Compact_struct_ref_decoder_rejects_log_entry_length_beyond_limit()
{
byte[] malformed = CreateCompactLogEntryWithTooLargeDeclaredLength();
Assert.Throws<RlpLimitException>(() =>
{
Rlp.ValueDecoderContext ctx = new(malformed);
CompactLogEntryDecoder.DecodeLogEntryStructRef(ref ctx, RlpBehaviors.None, out _);
});
}
private static Rlp CreateCompactLogEntryWithTooLargeZeroPrefix() => Rlp.Encode(
Rlp.Encode(TestItem.AddressA.Bytes),
Rlp.OfEmptyList,
Rlp.Encode((int)16.MB),
Rlp.Encode(new byte[] { 1 }));
private static byte[] CreateCompactLogEntryWithTooLargeDeclaredLength()
{
int declaredLength = (int)16.MB + 1;
return
[
0xfa,
(byte)(declaredLength >> 16),
(byte)(declaredLength >> 8),
(byte)declaredLength,
];
}
}
Changes
LogEntry16 MB RLP limitDecodeandDecodeLogEntryStructRefso malformed receipts cannot trigger oversized heap allocationsTypes of changes
What types of changes does your code introduce?
Testing
Requires testing
If yes, did you write tests?
Notes on testing
dotnet test --project src/Nethermind/Nethermind.Core.Test/Nethermind.Core.Test.csproj -c release --filter FullyQualifiedName~LogEntryDecoderTestsDocumentation
Requires documentation update
Requires explanation in Release Notes