Grego AI | EIP-8024 Reentrancy

Ethereum

Geth EIP-8024

EIP-8024 Reentrancy

A critical Geth EIP-8024 finding where a one-byte program-counter error could skip the opcode after DUPN, SWAPN, or EXCHANGE.

Failure path

With EIP-8024 enabled, the DUPN, SWAPN, and EXCHANGE handlers advanced the program counter by two before the interpreter advanced it again. Bytecode using them skipped the following opcode, allowing exact layouts to bypass a state update and reach an external call first.

Impact and conditions

Low likelihood. EIP-8024 was experimental, feature-gated, and not enabled on Ethereum mainnet.

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:

  1. 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
  2. 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

  1. 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.
  2. Attacker deploys MaliciousReceiver with receive() { if (victim.hasFunds()) victim.withdraw(A); } and an attack(A) method that invokes victim.withdraw(A).
  3. Attacker funds or ensures victim.balances[attacker] >= A (e.g., via deposit).
  4. Attacker calls MaliciousReceiver.attack(A) to trigger victim.withdraw(A).
  5. 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).
  6. CALL transfers A to MaliciousReceiver before the skipped SSTORE updates guard/balances.
  7. receive() reenters victim.withdraw(A) while state is unfinalized; steps 5–6 repeat until funds are drained.
  8. 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