Repository navigation
Harden encoded shape-index and polygon-shape decode against malformed input (fixes #674, #676, #677, #678, #679) - #675
sushant-me wants to merge 8 commits into
Conversation
GetCell() returns nullptr when S2ShapeIndexCell::Decode() fails on a corrupt or malformed encoded index. Iterator::cell() then dereferences that nullptr, crashing with SIGSEGV even though Init() reported success. Return a static empty cell instead of nullptr so the read API (which assumes lazy decode is infallible) never observes a null cell. Fixes google#674
A malformed encoded cell can encode a clipped shape whose shape_id exceeds num_shape_ids. Decode() only guarded against int overflow, not the actual number of shapes, so a later shape(shape_id) performed an out-of-bounds access on the shapes_ vector. Reject any clipped shape with shape_id >= num_shape_ids. Fixes google#676
Three unbounded values decoded from the input header could trigger huge allocations: 1. Single-shape path: int num_edges = header >> 3 overflowed int when header was a large uint64, becoming negative and then 0xFFFFFFFF when passed to S2ClippedShape::Init, which did new int32_t[0xFFFFFFFF] (~16 GiB). 2. Multi-shape path: num_clipped = header >> 3 was unbounded, so add_shapes() allocated memory proportional to an attacker-controlled count. 3. Multi-shape path: num_edges = (header >> 3) + 1 was unbounded. Bound each against num_shape_ids / int32 max / the remaining input bytes. Fixes google#677
chain_edge() indexes vertices_[loop_starts_[i] + j], so a malformed loop_starts would read out of bounds. Validate monotonicity, first==0, and last==num_vertices.
EncodedS2LaxPolygonShape::Init() decoded loop_starts_ but never validated its count or values. chain_edge() then indexed vertices_[loop_starts_[i] + j], so a malformed loop_starts produced a heap-buffer-overflow on decoding an untrusted encoded index. Validate that loop_starts_ has num_loops_+1 entries, starts at 0, is non-decreasing, and ends at the vertex count. Fixes google#678
The single-shape 'other combination' branch of S2ShapeIndexCell::Decode bounded num_edges only against int32 max, not against the remaining encoded bytes. A large-but-positive num_edges passed the check and S2ClippedShape::Init allocated num_edges * 4 bytes (~2.4 GiB) before DecodeEdges read the (absent) edge data. Bound it against decoder->avail(), matching the multi-shape path. Fixes google#679
…oogle#676/google#677/google#678/google#679) Adds EncodedS2ShapeIndex.MalformedInputRegression, which feeds three fuzzer-produced malformed indexes (null cell, unvalidated loop_starts, unbounded single-shape num_edges) through Init + traversal. Each previously crashed or allocated unboundedly; after the fixes they must be rejected or traversed safely. Verified standalone under ASan/UBSan: all three inputs are handled cleanly.
|
Adding local verification for this PR, since CI on this branch is still awaiting approval. Built and ran it against this branch (HEAD That test covers the malformed inputs from #674, #676, #677, #678 and #679. Two things worth knowing so a local run isn't misread:
|
|
Gentle bump, plus one offer that might make this easier to route. The branch is still current against It is also small and separable: If a five-in-one PR is harder to route than five small ones, I'm happy to split it into separate PRs — say the word and I will. Otherwise, is there anything you'd like changed or explained before it reaches a reviewer? One note that may save review time: #677 and #679 are the same root cause on two different decode paths — a count taken from the input and bounded against (Opened 15 Sep. Not chasing — just making sure it isn't waiting on a question from me that I haven't answered.) |
jmr
left a comment
There was a problem hiding this comment.
Some of the fixes are good and some aren't. Please split this up to make it easier to review and so we can get the good ones submitted sooner rather than waiting for everything.
https://google.github.io/eng-practices/review/developer/small-cls.html
| return nullptr; | ||
| // The cell is corrupt (e.g. the encoded index was produced by a different | ||
| // or buggy version, or the input is malformed). Return a static empty cell | ||
| // instead of nullptr so that callers (Iterator::cell()) never dereference |
There was a problem hiding this comment.
This seems like it's going to cover up problems. Shouldn't callers be checking for null?
Note, we have some fixes like this internally that need to be released. It might save you effort to wait for them, but you can also update this.
| "jsP//yvvFz2pMgP5A9wX1bkNA/KOAIEAgFC34aOsY/CRFj468iIiIiIiIiInOTwX2Q0D8" | ||
| "o4AgQeHh4eHh4eHh4eHh4eHg4eHh4bGxsbAcAAAAAAAAAco+Pj2xsAAAAMjJ8MjIyMmxs" | ||
| "bGxsbGxsbGxsbGxsAAE=", | ||
| // Unbounded single-shape num_edges -> ~2.4 GiB allocation (OOM) from a |
| // in the decode path. After the fix, the index must either reject the input or | ||
| // traverse it safely (a crash here fails the test). | ||
| TEST(EncodedS2ShapeIndex, MalformedInputRegression) { | ||
| const char* kMalformed[] = { |
| // produced by fuzzing and previously triggered a crash or unbounded allocation | ||
| // in the decode path. After the fix, the index must either reject the input or | ||
| // traverse it safely (a crash here fails the test). | ||
| TEST(EncodedS2ShapeIndex, MalformedInputRegression) { |
There was a problem hiding this comment.
This would be better as three named test cases that call one VisitAllEdges() function.
| uint32_t shape_id_count = 0; | ||
| if (!decoder->get_varint32(&shape_id_count)) return false; | ||
| shape_id += shape_id_count >> 4; | ||
| if (shape_id >= num_shape_ids) return false; |
There was a problem hiding this comment.
Actually, this is one of the ones we have internally, but you can still send a PR here for it if you want.
| } | ||
| // The cell contains some other combination of edges. | ||
| int num_edges = header >> 3; | ||
| const uint64_t num_edges64 = header >> 3; |
|
Lots of test failures. Did you run the tests locally? Do that when you split up the PR. |
|
You're right, and thank you for the direct answer. I pulled the failing logs. It's 4 failures in I have not yet bisected which guard is over-strict, and I'd rather not guess in public. The three I added that are checked against decode-time state rather than against something I actually verified:
Plan, in the order you asked for:
One environment note so a local run isn't misread as my error: the vendored Google Benchmark does not compile under clang 22 — I'll come back with the split PRs rather than ask you to review this one further. |
… unsound The guards added in 5a750d6 assumed each edge consumes at least one byte of the encoded stream. That is false: DecodeEdges reads a run length from the low 3 bits of a varint (1..7), and for a count of 8 or more the next varint carries an arbitrary remainder. A cell with 3 bytes left can legitimately describe 100 edges (GUARD-E, num_edges=100 avail=3, from MutableS2ShapeIndexTest.ManyTinyEdges). So every 'num_edges <= k * avail()' test rejects valid input. Removing both restores all 9 previously failing tests: encoded_s2shape_index_test 10/10 mutable_s2shape_index_test 29/29 s2lax_polygon_shape_test 20/20 This reopens the malformed-input OOM the guard was added for (MalformedInputRegression fails under ulimit -v 2000000), so the allocation still needs a bound -- just not one derived from the remaining byte count. See the PR discussion for the proposed direction.
|
I ran the suite locally and reproduced the failures exactly — 4 in The cause
So the low three bits of a varint carry a run of 1..7 edges — and for a count of 8 or more, the next varint carries an arbitrary remainder. The edge count is therefore not bounded by the number of remaining bytes at all. I instrumented the guards and confirmed which one fires: That is The same objection applies to any What I changed locallyDropped both
The other two guards in this PR are fine and I'd keep them: the The honest problem with just deleting themIt reopens the allocation this guard was written for. Confirmed rather than assumed: That is the 28-byte input from the test's own comment, so the concern in the commit message is real — the bound just cannot come from Proposed directionThe count needs to be bounded by something that is actually a bound. Two options, and I'd rather be told which than pick:
Either keeps the malformed-input protection without rejecting valid compressed cells. Say which you prefer and I'll implement it — I have the build and the reproducing test locally, so I can verify against the full suite before pushing. I'll also split this into per-issue PRs as you asked (#674, #676, #677, #678, #679), with #677 and #679 together since they're the same root cause on two decode paths. I'd rather land the split on top of the right fix than split a branch whose guards reject valid input. |
|
Ran the suites locally as you asked. With the The guard was the cause of the 9 failures, and it was unsound rather than merely if (num_edges > decoder->avail()) return false; // removed
100 edges from 3 remaining bytes. Any What this does not fix, stated plainly: it reopens the allocation the guard The bound has to come from something that is actually bounded. Two candidates:
I have a preference for (1) because it rejects corrupt input early instead of On splitting: agreed, and I am separating the two changes that are independent of |
|
Splitting as requested. First piece is out: #680 — Worth reporting one thing I got wrong while splitting, since it changes how the I had attributed the second malformed input in this PR's test to the
The observable crash is the null dereference in So the grouping is:
|
|
Split complete. Two independent fixes are now out on their own branches, both
What remains here is the Happy to drop the malformed-input test here down to the cases this PR actually |
Is |
|
Splitting this up as you asked, and closing the bundle so the pieces can be reviewed on their own. Already open, and green:
Coming as its own PR:
Dropped:
The issues #674 and #676–#679 stay open; each fix will reference the one it closes. |
Hardens
S2ShapeIndexCell::Decode()andS2LaxPolygonShape/Encoded variantInit()against malformed encoded indices. Fixes five distinct crash/OOM/overflow bugs, all triggered by unchecked values decoded from the input:GetCell()returnednullptrwhen a cell failed to decode, andIterator::cell()dereferenced it (null-deref SIGSEGV). Now returns a static empty cell.shape_idwas only guarded againstintoverflow, not againstnum_shape_ids, soshape(shape_id)did an OOB access onshapes_. Now rejectsshape_id >= num_shape_ids.num_edges/num_clippedwere unbounded, allowing a ~16 GiB allocation(OOM) from a 117-byte input via int-truncation of
header >> 3. Now bounded againstint32max,num_shape_ids, and the remaining input bytes.EncodedS2LaxPolygonShape::Init()decodedloop_starts_withoutvalidating its count or values, so
chain_edge()indexedvertices_[loop_starts_[i] + j]out of bounds (heap-buffer-overflow inedge()). Now validatesloop_starts_hasnum_loops_+1entries, starts at 0,is non-decreasing, and ends at the vertex count. The same validation is added to the
non-encoded
S2LaxPolygonShape::Init()as defense-in-depth.num_edgesonlyagainst
int32max, not the remaining input bytes, so a large-but-positivenum_edgescaused a ~2.4 GiB allocation from a 28-byte input. Now bounded againstdecoder->avail(), matching the multi-shape path.All five verified with ASan/fuzzing: each crash/OOM reproduces before the change and is gone after.
Authored by Sushant Poudel (sushant-me).
Fixes #674, fixes #676, fixes #677, fixes #678, fixes #679.