Skip to content

core/types: (*Header).GetValidatorBytes panics with slice-bounds-out-of-range on short Extra (pre-Cancun branch) #2221

@kajaaz

Description

@kajaaz

(*Header).GetValidatorBytes panics with slice-bounds-out-of-range on short Extra (pre-Cancun branch)

Summary

(*Header).GetValidatorBytes(cfg *params.ChainConfig) in core/types/block.go panics with runtime error: slice bounds out of range whenever it is called on a *Header whose Extra is shorter than ExtraVanityLength + ExtraSealLength (< 97 bytes) AND cfg.IsCancun(h.Number) returns false.

The post-Cancun branch of the very same function already guards against this exact condition. The pre-Cancun branch does not, and the comment on consensus/bor/bor.go:670 openly acknowledges the function relies on upstream length validation:

// len(header.Extra) >= extraVanity+extraSeal has already been validated in
// validateHeaderExtraField, so this won't result in a panic

Because the function is exported, any out-of-tree consumer (indexers, archive-node frontends, light clients, block explorers, MEV tooling, custom RPC layers) that imports github.com/0xPolygon/bor/core/types and calls GetValidatorBytes directly, without first running validateHeaderExtraField or (c *Bor).VerifyHeader, will crash on attacker-controllable input.

In-tree (consensus engine) call sites are guarded; this is an API hardening / defense-in-depth issue, not a remote crash of a synced bor node.

System information

This is a source-code bug in the core/types package, not a runtime issue. The fields below from the issue template are therefore N/A in the usual sense:

  • Bor client version: reproduced on develop HEAD as of 2026-05-13 (core/types/block.go lines 503-523).
  • Heimdall client version: N/A. Bug is in bor's core/types, no Heimdall interaction involved.
  • OS & Version: Linux x86_64 (Go 1.24); platform-independent (pure Go runtime panic on a slice op).
  • Environment: N/A. Bug fires before any chain interaction.
  • Type of node: N/A. Bug fires in library code, reachable from any package importing core/types.

Overview of the problem

core/types/block.go:503-523 (verbatim):

// GetValidatorBytes extracts validator bytes from the header's Extra field.
// If you need multiple fields from BlockExtraData, prefer DecodeBlockExtraData
// to avoid redundant RLP decodes.
func (h *Header) GetValidatorBytes(chainConfig *params.ChainConfig) []byte {
    if !chainConfig.IsCancun(h.Number) {
        return h.Extra[ExtraVanityLength : len(h.Extra)-ExtraSealLength]   // <-- no length check
    }

    if len(h.Extra) < ExtraVanityLength+ExtraSealLength {                  // <-- post-Cancun guard
        log.Error("length of extra less is than vanity and seal")
        return nil
    }

    var blockExtraData BlockExtraData
    if err := rlp.DecodeBytes(h.Extra[ExtraVanityLength:len(h.Extra)-ExtraSealLength], &blockExtraData); err != nil {
        log.Debug("error while decoding block extra data", "err", err)
        return nil
    }

    return blockExtraData.ValidatorBytes
}

With ExtraVanityLength = 32 and ExtraSealLength = 65, the pre-Cancun slice operation h.Extra[32 : len(h.Extra)-65] panics for any len(h.Extra) < 97:

len(h.Extra) Slice op Panic
1 Extra[32 : -64] runtime error: slice bounds out of range [:-64]
65 Extra[32 : 0] runtime error: slice bounds out of range [32:0]
96 Extra[32 : 31] runtime error: slice bounds out of range [32:31]
97 Extra[32 : 32] OK (empty slice)

The same body is reused (with the inner guard already present) in GetBaseFeeParams, DecodeBlockExtraData, and (*Block).GetTxDependency, so it is clear the maintainers know the length guard is required; it is just missing from one of the four sites.

Reproduction steps

Drop this test into core/types/block_test.go (inside the existing package types, after any existing function), then run go test -v -run TestGetValidatorBytesShortExtraVerbose ./core/types/. It must be placed inside the core/types package so it has access to the Header struct directly. It uses only packages already imported in block_test.go (math/big, testing, github.com/ethereum/go-ethereum/params) so no import changes are needed.

func TestGetValidatorBytesShortExtraVerbose(t *testing.T) {
	// No CancunBlock set means IsCancun always returns false,
	// so the vulnerable pre-Cancun branch is taken for every block number.
	cfg := &params.ChainConfig{ChainID: big.NewInt(137)}
	for _, n := range []int{0, 1, 32, 64, 65, 80, 96} {
		n := n
		panicked := false
		func() {
			defer func() {
				if r := recover(); r != nil {
					panicked = true
					t.Logf("len(Extra)=%2d  --> PANIC: %v", n, r)
				}
			}()
			h := &Header{Number: big.NewInt(1), Extra: make([]byte, n)}
			_ = h.GetValidatorBytes(cfg)
		}()
		if !panicked {
			t.Errorf("len(Extra)=%d: expected panic, got none (bug is already fixed?)", n)
		}
	}
}

Note on test semantics: recover() catches each panic so the test runner does not abort. The test passes when the bug is present (all 7 cases panic) and fails after the fix is applied (no panics, so !panicked triggers t.Errorf). The t.Logf line makes the individual panics visible in -v output.

Verified output on unpatched develop (run against github.com/ethereum/go-ethereum/core/types):

=== RUN   TestGetValidatorBytesShortExtraVerbose
    block_test.go:944: len(Extra)= 0  --> PANIC: runtime error: slice bounds out of range [:-65]
    block_test.go:944: len(Extra)= 1  --> PANIC: runtime error: slice bounds out of range [:-64]
    block_test.go:944: len(Extra)=32  --> PANIC: runtime error: slice bounds out of range [:-33]
    block_test.go:944: len(Extra)=64  --> PANIC: runtime error: slice bounds out of range [:-1]
    block_test.go:944: len(Extra)=65  --> PANIC: runtime error: slice bounds out of range [32:0]
    block_test.go:944: len(Extra)=80  --> PANIC: runtime error: slice bounds out of range [32:15]
    block_test.go:944: len(Extra)=96  --> PANIC: runtime error: slice bounds out of range [32:31]
--- PASS: TestGetValidatorBytesShortExtraVerbose (0.00s)
PASS
ok  	github.com/ethereum/go-ethereum/core/types	1.862s

Expected output after the fix from the linked PR is applied:

=== RUN   TestGetValidatorBytesShortExtraVerbose
    block_test.go:944: len(Extra)=0: expected panic, got none (bug is already fixed?)
    ...
--- FAIL: TestGetValidatorBytesShortExtraVerbose

Logs / Traces / Output / Error Messages

Native panic stack trace from a minimal harness inlining the upstream function verbatim (./harness "00"):

panic: runtime error: slice bounds out of range [:-64]

goroutine 1 [running]:
main.getValidatorBytesPreCancun({0xc0000a20b0, 0x1, 0x1})
	core/types/block.go:508 +0xa5
main.exerciseGetValidatorBytesPreCancun({0xc0000a20b0, 0x1, 0x1})
	main.go:156 +0x25
main.main()
	main.go:181 +0x2b9

How this bug was found with Zorya

This bug was discovered with Zorya, a concolic-execution engine for Go binaries.

A minimal harness was built around the inlined-verbatim copy of (*Header).GetValidatorBytes and valset.ParseValidators, then analysed in function mode targeting exerciseGetValidatorBytesPreCancun for 300 s:

zorya /path/to/32_bor_parlia_valset/harness \
  --lang go --compiler gc \
  --thread-scheduling main-only \
  --mode function 0x53d8a0 \
  --arg "0000000000000000000000000000000000000000000000000000000000000000aa0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"

The seed (a 99-byte hex blob: 32-byte vanity + 2-byte body + 65-byte seal) is a valid Extra so the concrete run takes the safe path. Zorya then negates the path constraints and explores nearby branches. The CBRANCH oracle flagged instruction 0x53d346 after 65.7 s with the following SAT witness:

[*] SATISFIABLE STATE FOUND
Elapsed since start: 65.739s
Instruction Address: 0x53d346
Panic Address: 0x53d39c
Opcode: CBRANCH
Detection method: Exploring the current path with a symbolic check on the pointer

The program can panic if its inputs are the following:
  * The input 'extra.cap' must be 1
  * The input 'extra.len' must be 1
  * The input 'extra[0]' must be 0

The Z3-suggested extra.len = 1 was confirmed to natively reproduce the panic (see the table above). Zorya thus identified the missing length guard from binary alone, without any source-level annotation, and gave a minimal triggering input within ~1 minute.

To understand why extra.len = 1 is a valid witness: the vulnerable slice operation is h.Extra[32 : len(h.Extra)-65]. For this to succeed, Go's runtime requires len(h.Extra)-65 >= 32, which means len(h.Extra) >= 97. When len = 1, the high bound becomes 1-65 = -64, which is negative, violating the invariant and triggering the panic. Z3 was given the symbolic constraint len(h.Extra) < 97 (any value in that range makes the slice fail) and simply returned 1 as the smallest satisfying integer. The bug covers the entire range len(Extra) ∈ [0, 97) -- extra.len = 1 is just the concrete witness Z3 happened to produce first, and passing one zero byte ("00" in hex) to the harness confirms it instantly.

The same campaign also exercised the IsCancun-true branch (exerciseGetValidatorBytesPostCancun) and the GetTxDependency body (exerciseTxDependency); both returned no SAT state, confirming that their existing length guards correctly protect the slice operations.

Impact

  • In-tree path: Safe. All four call sites in consensus/bor/bor.go (verifyHeader calls GetValidatorBytes / GetBaseFeeParams; verifyCascadingFields calls GetValidatorBytes and parent.GetValidatorBytes) run after validateHeaderExtraField(header.Extra), which rejects len(Extra) < ExtraVanityLength + ExtraSealLength.

  • Out-of-tree consumers: Vulnerable. Any code that imports github.com/0xPolygon/bor/core/types and calls GetValidatorBytes on user-supplied / network-sourced headers without first invoking validateHeaderExtraField will panic on a short Extra. Concrete examples of consumer code patterns that may trip this:

    • Block-explorer / indexer code paths that decode an RLP-encoded header and then immediately read validator data.
    • Light-client implementations that consume headers from peers prior to running the full consensus engine.
    • Off-chain analytics, MEV tooling, or snapshot exporters that enumerate validator sets per-block.
    • Test fixtures or fuzzing harnesses that bypass verifyHeader.
  • Production attacker reachability: low on a synced node (Cancun is long-active on mainnet, so the broken pre-Cancun branch is not hit on current blocks). Historical-sync paths walking pre-Cancun blocks plus any out-of-tree pre-validation hook expand the attack surface.

This is a defense-in-depth finding. Severity suggestion: Low.

Suggested fix

Mirror the post-Cancun guard into the pre-Cancun branch and document the postcondition on the godoc. Four-line patch:

 // GetValidatorBytes extracts validator bytes from the header's Extra field.
-// If you need multiple fields from BlockExtraData, prefer DecodeBlockExtraData
-// to avoid redundant RLP decodes.
+// Returns nil if len(h.Extra) is shorter than ExtraVanityLength + ExtraSealLength.
+// If you need multiple fields from BlockExtraData, prefer DecodeBlockExtraData
+// to avoid redundant RLP decodes.
 func (h *Header) GetValidatorBytes(chainConfig *params.ChainConfig) []byte {
+    if len(h.Extra) < ExtraVanityLength+ExtraSealLength {
+        return nil
+    }
     if !chainConfig.IsCancun(h.Number) {
         return h.Extra[ExtraVanityLength : len(h.Extra)-ExtraSealLength]
     }
-
-    if len(h.Extra) < ExtraVanityLength+ExtraSealLength {
-        log.Error("length of extra less is than vanity and seal")
-        return nil
-    }

     var blockExtraData BlockExtraData
     if err := rlp.DecodeBytes(h.Extra[ExtraVanityLength:len(h.Extra)-ExtraSealLength], &blockExtraData); err != nil {
         log.Debug("error while decoding block extra data", "err", err)
         return nil
     }

     return blockExtraData.ValidatorBytes
 }

Also recommend redundantly hoisting the same guard to the top of GetBaseFeeParams, DecodeBlockExtraData, and (*Block).GetTxDependency for consistency (they already perform the check inside their respective RLP-decode branches, but the early return makes the precondition uniform across the four functions).

The "len(header.Extra) >= extraVanity+extraSeal has already been validated" comment in consensus/bor/bor.go can then be removed; the invariant becomes intrinsic to the API, not an implicit caller obligation.

Additional Information

Not applicable in the usual start.sh / admin.peers / heimdall-config sense. This is a library API bug, not a runtime issue. Triggering it does not require a running bor node, a Heimdall sidecar, or any peer interaction; it is purely a precondition violation in core/types.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions