Sharing another interesting finding that was discovered by Grego AI.
If you would like to engage our security services for your protocol contact @0xriptide or @0xitsgreg to discuss.
We had two objectives with this experiment:
- Contribute to the security of Ethereum by preemptively running our tool against a promising EIP (EIP-8024) proposed for Glamsterdam and discover a vulnerability deemed valid by the EIP team
- Gauge our recent tool developments against a Go codebase
What is EIP-8024?
EIP-8024 aims to introduce backward-compatible stack operations like SWAPN/DUPN/EXCHANGE. These instructions allow compilers to access deep stack items below the depth of 16 currently supported by the SWAP and DUP instructions.
The EIP is gated behind a configuration flag and not enabled yet on mainnet, but like other potential EIPs it is part of Geth’s codebase for experimentation, custom/private chains, devnets, and potential future hard forks.
Team has acknowledged and proposed a fix here: https://github.com/ethereum/go-ethereum/pull/33361
Bug report follows …
PC mis-advance in EIP‑8024 DUPN/SWAPN/EXCHANGE enables effects-before-state reentrancy
1. Summary
EIP‑8024 handlers opDupN, opSwapN, and opExchange consume one immediate byte but advance pc by two; the interpreter then unconditionally increments pc again. This deterministically skips one byte after e6/e7/e8, misdecoding the next opcode and shifting control flow. Contracts assuming correct semantics can execute an external CALL before a planned SSTORE, opening a classic reentrancy window.
2. Finding Description
The interpreter unconditionally increments pc after every handler; e6/e7/e8 handlers already add 2 for a 1‑byte immediate, causing a net +3 instead of +2 (opcode+immediate). This breaks decoding and can reorder effects and state.
// interpreter.go (always increments pc)
...
res, err = operation.execute(&pc, evm, callContext)
if err != nil {
break
}
pc++ // unconditional
...
// instructions.go (EIP‑8024 handlers consume 1 byte at code[*pc+1], but do *pc += 2)
func opDupN(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) {
code := scope.Contract.Code
i := *pc + 1
if i >= uint64(len(code)) { return nil, &ErrInvalidOpCode{opcode: INVALID} }
x := code[i]
...
scope.Stack.push(scope.Stack.Back(n - 1))
*pc += 2 // BUG: consumed 1 immediate, but adds 2; interpreter adds +1 more
return nil, nil
}
func opSwapN(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) {
code := scope.Contract.Code
i := *pc + 1
if i >= uint64(len(code)) { return nil, &ErrInvalidOpCode{opcode: INVALID} }
x := code[i]
...
scope.Stack.data[indexTop], scope.Stack.data[indexN] = scope.Stack.data[indexN], scope.Stack.data[indexTop]
*pc += 2 // BUG: same pattern
return nil, nil
}
func opExchange(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) {
code := scope.Contract.Code
i := *pc + 1
if i >= uint64(len(code)) { return nil, &ErrInvalidOpCode{opcode: INVALID} }
x := code[i]
...
scope.Stack.data[indexN], scope.Stack.data[indexM] = scope.Stack.data[indexM], scope.Stack.data[indexN]
*pc += 2 // BUG: same pattern
return nil, nil
}
// eips.go (EIP‑8024 activation)
func enable8024(jt *JumpTable) {
jt[DUPN] = &operation{ execute: opDupN, constantGas: GasFastestStep, minStack: minStack(1, 0), maxStack: maxStack(0, 1) }
jt[SWAPN] = &operation{ execute: opSwapN, constantGas: GasFastestStep, minStack: minStack(2, 0), maxStack: maxStack(0, 0) }
jt[EXCHANGE] = &operation{ execute: opExchange, constantGas: GasFastestStep, minStack: minStack(2, 0), maxStack: maxStack(0, 0) }
}
For contrast, PUSH opcodes advance by the immediate length only and rely on interpreter pc++ for the opcode byte (total +1+N), which is the correct invariant:
// instructions.go (correct convention)
func opPush1(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) {
...
*pc += 1 // consumed 1 immediate; interpreter adds +1 for opcode
return nil, nil
}
The unit test loop for e6/e7/e8 does not apply interpreter pc++, masking the bug:
// instructions_test.go (excerpt)
switch op {
case 0xe6: _, err = opDupN(&pc, evm, scope) // no pc++ here
case 0xe7: _, err = opSwapN(&pc, evm, scope)
case 0xe8: _, err = opExchange(&pc, evm, scope)
...
}
Misalignment can reorder a planned SSTORE after an external CALL:
// instructions.go (effects vs. state)
func opCall(...){ ... ret, returnGas, err := evm.Call(...); ... }
func opSstore(...){ evm.StateDB.SetState(...); ... }
3. Attack Steps
- Preconditions: Chain enables EIP‑8024; victim bytecode contains at offset L: 0xe6 <imm> 0x55 0xf1 (or 0xe7/0xe8), where 0x55 is the intended finalizing SSTORE and 0xf1 is CALL.
- Attacker deploys MaliciousReceiver with receive() { if (victim.hasFunds()) victim.withdraw(A); } and an attack(A) method that invokes victim.withdraw(A).
- Attacker funds or ensures victim.balances[attacker] >= A (e.g., via deposit).
- Attacker calls MaliciousReceiver.attack(A) to trigger victim.withdraw(A).
- At pc=L, interpreter executes 0xe6 (reads one immediate), handler does pc += 2, interpreter does pc++, landing at L+3 (CALL), skipping L+2 (SSTORE).
- CALL transfers A to MaliciousReceiver before the skipped SSTORE updates guard/balances.
- receive() reenters victim.withdraw(A) while state is unfinalized; steps 5–6 repeat until funds are drained.
- Outer call returns; any later SSTORE cannot recover lost funds.
4. Likelihood (low)
Exploitation requires EIP‑8024 explicitly enabled (not default), victim bytecode that actually uses e6/e7/e8 with 1‑byte immediates, and a layout where the skipped byte is a critical SSTORE (or similar) whose reordering enables reentrancy. Any unprivileged caller can trigger the path once these conditions exist. Such contracts are currently uncommon; finding or crafting the exact byte alignment is contract‑specific and nontrivial, but feasible where 8024 is adopted.
5. Impact (critical)
Enables external CALL to execute before intended SSTORE finalizers, violating state‑before‑effects and allowing reentrancy to drain ETH/tokens up to the contract’s entire on‑chain balances on affected paths. Also risks control‑flow corruption (skipped byte alters subsequent decoding), leading to invariant violations and data corruption. Impacted parties are users/protocol treasuries whose contracts adopt EIP‑8024 and rely on correct opcode sequencing. Losses are irreversible and bounded only by per‑contract balances/limits.
6. Mitigation
In instructions.go, change *pc += 2 to *pc += 1 in opDupN, opSwapN, and opExchange so handlers advance by the consumed immediate only and rely on interpreter pc++ (total +2). Update TestEIP8024_Execution to mirror interpreter semantics (apply pc++ after e6/e7/e8 or run via EVM.Run) and add unit assertions that e6/e7/e8 advance pc by exactly two bytes total. Optionally gate EIP‑8024 until patched and add debug invariants asserting expected pc deltas per opcode.
PoC
Add to go-ethereum/core/vm/runtime/runtime_test.go:
// TestEIP8024_PCMisAdvancement demonstrates the PC mis-advancement bug in EIP-8024 opcodes.
// The bug: opDupN, opSwapN, opExchange do *pc += 2 but should do *pc += 1.
// Since the interpreter always adds pc++ after each opcode, total advancement is 3 instead of 2.
// This causes one byte to be skipped after each EIP-8024 opcode.
func TestEIP8024_PCMisAdvancement(t *testing.T) {
// Test bytecode: PUSH1 0x01, 16x PUSH0, DUPN 0x00, ADD, STOP
// DUPN 0x00 decodes to n=17 (per decode_single: 0+17=17), duplicating the 17th stack item.
// After DUPN at PC=18, correct behavior: PC = 18 + 2 = 20 (execute ADD at 20)
// Buggy behavior: handler does pc+=2, interpreter does pc++, so PC = 18 + 3 = 21 (skip ADD!)
code := []byte{
byte(vm.PUSH1), 0x01, // PC 0-1: push 1
byte(vm.PUSH0), // PC 2: push 0
byte(vm.PUSH0), // PC 3: push 0
byte(vm.PUSH0), // PC 4: push 0
byte(vm.PUSH0), // PC 5: push 0
byte(vm.PUSH0), // PC 6: push 0
byte(vm.PUSH0), // PC 7: push 0
byte(vm.PUSH0), // PC 8: push 0
byte(vm.PUSH0), // PC 9: push 0
byte(vm.PUSH0), // PC 10: push 0
byte(vm.PUSH0), // PC 11: push 0
byte(vm.PUSH0), // PC 12: push 0
byte(vm.PUSH0), // PC 13: push 0
byte(vm.PUSH0), // PC 14: push 0
byte(vm.PUSH0), // PC 15: push 0
byte(vm.PUSH0), // PC 16: push 0
byte(vm.PUSH0), // PC 17: push 0 (17 items on stack now)
byte(vm.DUPN), 0x00, // PC 18-19: DUPN with immediate 0x00 (n=17, dup 17th item = 1)
byte(vm.ADD), // PC 20: ADD (should execute, but with bug it gets skipped)
byte(vm.STOP), // PC 21: STOP
}
// Track all PC values and opcodes executed
type opTrace struct {
pc uint64
opcode vm.OpCode
}
var traces []opTrace
statedb, _ := state.New(types.EmptyRootHash, state.NewDatabaseForTesting())
_, _, err := Execute(code, nil, &Config{
State: statedb,
EVMConfig: vm.Config{
ExtraEips: []int{8024}, // Enable EIP-8024
Tracer: &tracing.Hooks{
OnOpcode: func(pc uint64, op byte, gas, cost uint64, scope tracing.OpContext, rData []byte, depth int, err error) {
traces = append(traces, opTrace{pc: pc, opcode: vm.OpCode(op)})
},
},
},
})
if err != nil {
t.Fatalf("execution failed: %v", err)
}
// Log all traces for debugging
t.Log("=== Execution Trace ===")
for i, trace := range traces {
t.Logf("Step %d: PC=%d Opcode=%s", i, trace.pc, trace.opcode.String())
}
// Find the DUPN execution
var dupnIndex int = -1
var dupnPC uint64
for i, trace := range traces {
if trace.opcode == vm.DUPN {
dupnIndex = i
dupnPC = trace.pc
break
}
}
if dupnIndex == -1 {
t.Fatal("DUPN was not executed")
}
// Check what opcode comes after DUPN
if dupnIndex+1 >= len(traces) {
t.Fatal("No opcode executed after DUPN")
}
nextTrace := traces[dupnIndex+1]
expectedNextPC := dupnPC + 2 // DUPN + 1-byte immediate = 2 bytes
actualNextPC := nextTrace.pc
t.Logf("DUPN was at PC=%d", dupnPC)
t.Logf("Next opcode: PC=%d, Opcode=%s", actualNextPC, nextTrace.opcode.String())
t.Logf("Expected next PC: %d (DUPN at %d + 2 bytes)", expectedNextPC, dupnPC)
// THE BUG: next PC is dupnPC+3 instead of dupnPC+2
if actualNextPC == dupnPC+3 {
t.Logf("BUG CONFIRMED: PC advanced by 3 instead of 2, skipping byte at PC=%d", dupnPC+2)
t.Logf("The byte at PC=%d (0x%02x = %s) was SKIPPED!",
dupnPC+2, code[dupnPC+2], vm.OpCode(code[dupnPC+2]).String())
}
// Verify the bug: after DUPN, PC should be dupnPC+2 but is actually dupnPC+3
if actualNextPC != expectedNextPC {
t.Errorf("PC MIS-ADVANCEMENT BUG DETECTED!")
t.Errorf("After DUPN at PC=%d, expected next PC=%d, but got PC=%d", dupnPC, expectedNextPC, actualNextPC)
t.Errorf("This means %d byte(s) were skipped!", actualNextPC-expectedNextPC)
// Show what opcode was skipped
if actualNextPC > expectedNextPC && expectedNextPC < uint64(len(code)) {
skippedByte := code[expectedNextPC]
t.Errorf("SKIPPED OPCODE at PC=%d: 0x%02x (%s)", expectedNextPC, skippedByte, vm.OpCode(skippedByte).String())
}
}
}
// TestEIP8024_SWAPN_PCMisAdvancement demonstrates the PC bug in SWAPN opcode.
func TestEIP8024_SWAPN_PCMisAdvancement(t *testing.T) {
// SWAPN 0x00 decodes to n=17, swaps top with (n+1)th = 18th item, needs 18 items on stack
code := []byte{
byte(vm.PUSH1), 0x01, // PC 0-1: push 1
byte(vm.PUSH1), 0x02, // PC 2-3: push 2
byte(vm.PUSH0), // PC 4-19: 16x PUSH0
byte(vm.PUSH0),
byte(vm.PUSH0),
byte(vm.PUSH0),
byte(vm.PUSH0),
byte(vm.PUSH0),
byte(vm.PUSH0),
byte(vm.PUSH0),
byte(vm.PUSH0),
byte(vm.PUSH0),
byte(vm.PUSH0),
byte(vm.PUSH0),
byte(vm.PUSH0),
byte(vm.PUSH0),
byte(vm.PUSH0),
byte(vm.PUSH0), // Now 18 items on stack
byte(vm.SWAPN), 0x00, // PC 20-21: SWAPN (swap top with 18th)
byte(vm.ADD), // PC 22: ADD (should execute, but bug skips it)
byte(vm.STOP), // PC 23: STOP
}
type opTrace struct {
pc uint64
opcode vm.OpCode
}
var traces []opTrace
statedb, _ := state.New(types.EmptyRootHash, state.NewDatabaseForTesting())
_, _, err := Execute(code, nil, &Config{
State: statedb,
EVMConfig: vm.Config{
ExtraEips: []int{8024},
Tracer: &tracing.Hooks{
OnOpcode: func(pc uint64, op byte, gas, cost uint64, scope tracing.OpContext, rData []byte, depth int, err error) {
traces = append(traces, opTrace{pc: pc, opcode: vm.OpCode(op)})
},
},
},
})
if err != nil {
t.Fatalf("execution failed: %v", err)
}
// Find SWAPN and check next PC
var swapnPC uint64
var nextPC uint64
for i, trace := range traces {
if trace.opcode == vm.SWAPN && i+1 < len(traces) {
swapnPC = trace.pc
nextPC = traces[i+1].pc
break
}
}
expectedNextPC := swapnPC + 2
if nextPC != expectedNextPC {
t.Errorf("SWAPN PC BUG: at PC=%d, next should be PC=%d but got PC=%d (skipped %d bytes)",
swapnPC, expectedNextPC, nextPC, nextPC-expectedNextPC)
}
}
// TestEIP8024_EXCHANGE_PCMisAdvancement demonstrates the PC bug in EXCHANGE opcode.
func TestEIP8024_EXCHANGE_PCMisAdvancement(t *testing.T) {
// EXCHANGE 0x01 decodes to (n=1, m=2), swaps 2nd and 3rd items, needs 3 items
code := []byte{
byte(vm.PUSH1), 0x00, // PC 0-1
byte(vm.PUSH1), 0x01, // PC 2-3
byte(vm.PUSH1), 0x02, // PC 4-5
byte(vm.EXCHANGE), 0x01, // PC 6-7: EXCHANGE
byte(vm.ADD), // PC 8: ADD (should execute, but bug skips it)
byte(vm.STOP), // PC 9: STOP
}
type opTrace struct {
pc uint64
opcode vm.OpCode
}
var traces []opTrace
statedb, _ := state.New(types.EmptyRootHash, state.NewDatabaseForTesting())
_, _, err := Execute(code, nil, &Config{
State: statedb,
EVMConfig: vm.Config{
ExtraEips: []int{8024},
Tracer: &tracing.Hooks{
OnOpcode: func(pc uint64, op byte, gas, cost uint64, scope tracing.OpContext, rData []byte, depth int, err error) {
traces = append(traces, opTrace{pc: pc, opcode: vm.OpCode(op)})
},
},
},
})
if err != nil {
t.Fatalf("execution failed: %v", err)
}
// Find EXCHANGE and check next PC
var exchPC uint64
var nextPC uint64
for i, trace := range traces {
if trace.opcode == vm.EXCHANGE && i+1 < len(traces) {
exchPC = trace.pc
nextPC = traces[i+1].pc
break
}
}
expectedNextPC := exchPC + 2
if nextPC != expectedNextPC {
t.Errorf("EXCHANGE PC BUG: at PC=%d, next should be PC=%d but got PC=%d (skipped %d bytes)",
exchPC, expectedNextPC, nextPC, nextPC-expectedNextPC)
}
}
Logs:
=== RUN TestEIP8024_PCMisAdvancement
runtime_test.go:1005: === Execution Trace ===
runtime_test.go:1007: Step 0: PC=0 Opcode=PUSH1
runtime_test.go:1007: Step 1: PC=2 Opcode=PUSH0
runtime_test.go:1007: Step 2: PC=3 Opcode=PUSH0
runtime_test.go:1007: Step 3: PC=4 Opcode=PUSH0
runtime_test.go:1007: Step 4: PC=5 Opcode=PUSH0
runtime_test.go:1007: Step 5: PC=6 Opcode=PUSH0
runtime_test.go:1007: Step 6: PC=7 Opcode=PUSH0
runtime_test.go:1007: Step 7: PC=8 Opcode=PUSH0
runtime_test.go:1007: Step 8: PC=9 Opcode=PUSH0
runtime_test.go:1007: Step 9: PC=10 Opcode=PUSH0
runtime_test.go:1007: Step 10: PC=11 Opcode=PUSH0
runtime_test.go:1007: Step 11: PC=12 Opcode=PUSH0
runtime_test.go:1007: Step 12: PC=13 Opcode=PUSH0
runtime_test.go:1007: Step 13: PC=14 Opcode=PUSH0
runtime_test.go:1007: Step 14: PC=15 Opcode=PUSH0
runtime_test.go:1007: Step 15: PC=16 Opcode=PUSH0
runtime_test.go:1007: Step 16: PC=17 Opcode=PUSH0
runtime_test.go:1007: Step 17: PC=18 Opcode=DUPN
runtime_test.go:1007: Step 18: PC=21 Opcode=STOP
runtime_test.go:1034: DUPN was at PC=18
runtime_test.go:1035: Next opcode: PC=21, Opcode=STOP
runtime_test.go:1036: Expected next PC: 20 (DUPN at 18 + 2 bytes)
runtime_test.go:1040: BUG CONFIRMED: PC advanced by 3 instead of 2, skipping byte at PC=20
runtime_test.go:1041: The byte at PC=20 (0x01 = ADD) was SKIPPED!
runtime_test.go:1047: PC MIS-ADVANCEMENT BUG DETECTED!
runtime_test.go:1048: After DUPN at PC=18, expected next PC=20, but got PC=21
runtime_test.go:1049: This means 1 byte(s) were skipped!
runtime_test.go:1054: SKIPPED OPCODE at PC=20: 0x01 (ADD)
--- FAIL: TestEIP8024_PCMisAdvancement (0.00s)
=== RUN TestEIP8024_SWAPN_PCMisAdvancement
runtime_test.go:1121: SWAPN PC BUG: at PC=20, next should be PC=22 but got PC=23 (skipped 1 bytes)
--- FAIL: TestEIP8024_SWAPN_PCMisAdvancement (0.00s)
=== RUN TestEIP8024_EXCHANGE_PCMisAdvancement
runtime_test.go:1173: EXCHANGE PC BUG: at PC=6, next should be PC=8 but got PC=9 (skipped 1 bytes)
--- FAIL: TestEIP8024_EXCHANGE_PCMisAdvancement (0.00s)
FAIL
FAIL github.com/ethereum/go-ethereum/core/vm/runtime 1.093s
FAIL