-
Notifications
You must be signed in to change notification settings - Fork 12
state: harden FromBytes against truncated input + 100% coverage #425
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
poolpOrg
wants to merge
1
commit into
PlakarKorp:main
Choose a base branch
from
PlakarKorpAgentic:harden/state-corruption-paths
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 ET_CONFIGURATION allocates
lengthbytes up front without bounds — 4 GiB DoS on a corrupted state file. In bothdeserializeFromStream(state.go:726) anddeserializeFromStreamv100(state.go:857), the ET_CONFIGURATION arm doesce_buf := make([]byte, length)withlengthbeing auint32read directly from the (attacker-controlled) stream. Every other entry type in the same switch was hardened by this PR to gate onlength != XxxSerializedSizebefore allocating into a pre-sized fixed buffer; ET_CONFIGURATION is the only branch left that allocateslengthbytes up front, so a 37-byte crafted state file withlength=0xFFFFFFFFforces an immediate ~4 GiB allocation beforeio.ReadFullerrors out — exactly the "corrupted on-disk state file a process-killer" threat model the PR description names. Fix is one-liner mirroring the existing pattern: caplengthat the structural maximum for a legitimate ConfigurationEntry (1 + 255 + 2 + 65535 + 8 = 65801bytes) beforemake, in both deserializers.Extended reasoning...
What the bug is
Inside the per-entry switch of both
deserializeFromStream(state.go:684-744) anddeserializeFromStreamv100(state.go:815-875), theET_CONFIGURATIONcase is the only branch that allocates an entry buffer using the attacker-controlledlengthfield from the wire, with no upper bound:The PR explicitly hardens every other entry type in the same loop — ET_DELETE (state.go:670), ET_LOCATIONS (state.go:680), ET_COLOURED (state.go:691), ET_PACKFILE (state.go:710) — with a
if length != XxxSerializedSize { return error }guard before any read, and reads into a pre-allocated fixed-size buffer (del_buf,de_buf,coloured_buf,pe_buf). ET_CONFIGURATION sits inside that same hardening loop but skips the guard entirely.Why existing safeguards don't catch it
ConfigurationEntryFromBytesperforms its defensive checks after the allocation — it protects against bad content but not against bad declared size.TestDeserializeFromStream_BadConfigContentonly exerciseslength=1, which never approaches the dangerous regime.TestDeserializeFromStream_AllByteFlipsSafedoes flip bytes in the length field, but it only asserts no-panic vianotPanic. A successful 4 GiB allocation followed by a cleanio.ReadFullEOF error does not panic — it returns an error andnotPanicis satisfied. On CI hosts with < 4 GiB free RAM this may surface as intermittent OOM-killed test runs rather than a clear failure.Concrete step-by-step proof
Craft this 37-byte payload:
Trace through
deserializeFromStreamin state.go:ls.Metadata.Parent— OK.et_buf, gets0x05→entryType = ET_CONFIGURATION.readUint32()which reads bytes 33–36 →length = 0xFFFFFFFF = 4 294 967 295.ce_buf := make([]byte, 4294967295)allocates and zeroes ~4 GiB before any validation.io.ReadFull(r, ce_buf)then fails on the short stream and returns an error — but the 4 GiB has already been committed.On hosts with less than 4 GiB free this is an immediate OOM-kill of the kloset process. On larger hosts it's a multi-second stall and severe memory pressure on co-tenants (page-cache eviction, swap thrash). The same shape applies verbatim to
deserializeFromStreamv100at state.go:857, no header byte needed.Reachability — production callers
This is reachable along exactly the surfaces the PR description names:
repository/repository.go:386—MergeStateis called with a reader returned byr.GetStateover remote storage.repository/repository.go:422—MergeStateis called with an on-disk state file viaOpenStateFromStateFile.A malicious remote storage backend, a corrupted disk, or a tampered state file all reach this branch.
Fix
Same pattern the PR already uses for the fixed-size entries — cap before allocate. The legitimate maximum for a
ConfigurationEntryis structurally bounded becausekeyLenis auint8(max 255) andvalueLenis auint16(max 65535):Any
lengthlarger than that cannot round-trip fromConfigurationEntry.ToBytesand should be rejected:Apply the same one-liner at state.go:857 inside
deserializeFromStreamv100. While here, it would also be worth strengtheningTestDeserializeFromStream_AllByteFlipsSafe(and the v100 counterpart) so that an arbitrarily-large declaredlengthproduces a fast error rather than an attempted allocation — e.g. by asserting that decode completes within a small memory budget, or by adding a targetedlength=0xFFFFFFFFregression test alongside the existinglength=1case.