fix!: prevent OOM via unbounded Groth16 verifying key deserialization#6754
fix!: prevent OOM via unbounded Groth16 verifying key deserialization#6754
Conversation
Add an exact size check (396 bytes) on Groth16Vkey in ValidateBasic() and genesis Validate() before passing it to gnark's deserializer. Without this check, an attacker-controlled uint32 length prefix in the serialized VK causes gnark-crypto to allocate up to ~256 GiB from a ~292-byte input, crashing any node during CheckTx with no authentication required. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| // TestNewVerifyingKey_OOMVulnerability demonstrates that NewVerifyingKey is | ||
| // vulnerable to an OOM attack. A crafted payload with a large G1.K array | ||
| // length prefix causes gnark to allocate (sliceLen * 64) bytes before reading | ||
| // any element data. | ||
| // | ||
| // The BN254 verifying key binary format encodes 6 curve points (288 bytes | ||
| // total) followed by a uint32 length prefix for the G1.K slice. An attacker | ||
| // can set this to 0xFFFFFFFF, triggering a ~256 GiB allocation from a | ||
| // ~292-byte input. | ||
| // | ||
| // This test uses a moderate G1.K length (1M elements = ~64 MB) to safely | ||
| // demonstrate the code path without crashing the test runner. The allocation | ||
| // happens, then gnark fails with EOF because no element data follows. In | ||
| // production with 0xFFFFFFFF, the allocation would be ~256 GiB and crash the | ||
| // node. | ||
| func TestNewVerifyingKey_OOMVulnerability(t *testing.T) { |
There was a problem hiding this comment.
I'm open to removing this before merging b/c it provides value for reviewers pre-merge but won't provide much value post-merge.
Claude used this to verify the bug before implementing a fix.
There was a problem hiding this comment.
Fine with keeping it if you want. Could optionally always skip the test to avoid every make test-short allocating an additional 64MiB
The existing size check (Groth16VkeySize == 396) can be bypassed by crafting a payload that is exactly 396 bytes but has the internal G1.K length prefix set to 0xFFFFFFFF. This causes gnark to allocate ~256 GiB before returning an error. These tests reproduce the issue raised in PR review feedback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The outer size check alone is insufficient: an attacker can craft a payload that is exactly Groth16VkeySize (396 bytes) but has the internal G1.K length prefix set to 0xFFFFFFFF, causing gnark to allocate ~256 GiB before returning an error. Add ValidateGroth16Vkey() that checks both the total size and the big-endian uint32 G1.K length at bytes 288-291, ensuring it equals the expected value of 3 (nPublic + 1 for SP1's 2 public inputs). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
I will take a deeper look at this tomorrow. It makes sense to me, but I don't know if allocation of 256GB is possible. It looks like some reasonable size limits were added in Consensys/gnark-crypto#759 for many of the primitives including that which is used here, right? bn254 prime fields. cc. @jonas089 // to avoid allocating too large slice when the header is tampered, we limit
// the maximum allocation. We set the target to 4GB. This incurs a performance
// hit when reading very large slices, but protects against OOM.
targetSize := uint64(1 << 32) // 4GB
if bits.UintSize == 32 {
// reduce target size to 1GB on 32 bits architectures
targetSize = uint64(1 << 30) // 1GB
}
maxAllocateSliceLength := targetSize / uint64(Bytes)edit: I see the actual point of slice allocation now in |
damiannolan
left a comment
There was a problem hiding this comment.
Looks like the PR has already been auto-merged. LGTM regardless..
| // TestNewVerifyingKey_OOMVulnerability demonstrates that NewVerifyingKey is | ||
| // vulnerable to an OOM attack. A crafted payload with a large G1.K array | ||
| // length prefix causes gnark to allocate (sliceLen * 64) bytes before reading | ||
| // any element data. | ||
| // | ||
| // The BN254 verifying key binary format encodes 6 curve points (288 bytes | ||
| // total) followed by a uint32 length prefix for the G1.K slice. An attacker | ||
| // can set this to 0xFFFFFFFF, triggering a ~256 GiB allocation from a | ||
| // ~292-byte input. | ||
| // | ||
| // This test uses a moderate G1.K length (1M elements = ~64 MB) to safely | ||
| // demonstrate the code path without crashing the test runner. The allocation | ||
| // happens, then gnark fails with EOF because no element data follows. In | ||
| // production with 0xFFFFFFFF, the allocation would be ~256 GiB and crash the | ||
| // node. | ||
| func TestNewVerifyingKey_OOMVulnerability(t *testing.T) { |
There was a problem hiding this comment.
Fine with keeping it if you want. Could optionally always skip the test to avoid every make test-short allocating an additional 64MiB
… (backport #6754) (#6781) Fixes https://dashboard.hackenproof.com/manager/companies/celestia/celestia/reports/CELESTIA-214 ## Summary - Add `ValidateGroth16Vkey()` that performs two checks on `Groth16Vkey` in `ValidateBasic()` and genesis `Validate()` **before** passing it to gnark's deserializer: 1. **Exact size check**: `Groth16VkeySize` (396 bytes) — rejects payloads that are too short or too long 2. **G1.K length prefix check**: `Groth16VkeyG1KLength` (3) — reads the big-endian `uint32` at bytes 288–291 and verifies it equals the expected value (`nPublic + 1` for SP1's 2 public inputs) - Without both checks, a crafted payload can cause gnark-crypto to `make([]G1Affine, 0xFFFFFFFF)` (~256 GiB allocation), crashing any node during `CheckTx` with no authentication required - The size check alone is insufficient: an attacker can craft a 396-byte payload (passes size check) with an inflated G1.K length prefix that still triggers OOM - Add POC test demonstrating the underlying gnark vulnerability (64 MiB allocation from 292-byte input, skipped in `-short` mode) - Add regression tests in both `msgs_test.go` and `genesis_test.go` verifying malicious payloads are rejected before deserialization, including the 396-byte bypass variant ## Test plan - [x] `go test -v ./x/zkism/types/` — all tests pass including new OOM payload rejection tests - [x] `go test -v ./x/zkism/internal/groth16/` — POC test confirms 65 MiB allocation from 292-byte input (233,472× amplification) - [x] `go test -v -short ./x/zkism/internal/groth16/` — POC test correctly skipped in short mode - [x] `make build-standalone` — compiles cleanly 🤖 Generated with [Claude Code](https://claude.com/claude-code)<hr>This is an automatic backport of pull request #6754 done by [Mergify](https://mergify.com). <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/celestiaorg/celestia-app/pull/6781" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: Rootul Patel <rootulp@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Fixes https://dashboard.hackenproof.com/manager/companies/celestia/celestia/reports/CELESTIA-214
Summary
ValidateGroth16Vkey()that performs two checks onGroth16VkeyinValidateBasic()and genesisValidate()before passing it to gnark's deserializer:Groth16VkeySize(396 bytes) — rejects payloads that are too short or too longGroth16VkeyG1KLength(3) — reads the big-endianuint32at bytes 288–291 and verifies it equals the expected value (nPublic + 1for SP1's 2 public inputs)make([]G1Affine, 0xFFFFFFFF)(~256 GiB allocation), crashing any node duringCheckTxwith no authentication required-shortmode)msgs_test.goandgenesis_test.goverifying malicious payloads are rejected before deserialization, including the 396-byte bypass variantTest plan
go test -v ./x/zkism/types/— all tests pass including new OOM payload rejection testsgo test -v ./x/zkism/internal/groth16/— POC test confirms 65 MiB allocation from 292-byte input (233,472× amplification)go test -v -short ./x/zkism/internal/groth16/— POC test correctly skipped in short modemake build-standalone— compiles cleanly🤖 Generated with Claude Code