Immutable Executable Injection
A novel smart contract attack vector that passes Etherscan verification.
TL;DR: Solidity immutables are embedded directly in bytecode. If you pass valid EVM opcodes as constructor arguments, they become hidden executable code. The source looks clean, Etherscan verifies it, auditors miss it. I built a working PoC.
Summary
This post documents a novel composition-based attack vector in EVM-compatible smart contracts where executable bytecode is hidden within Solidity immutable variables. The attack passes Etherscan source verification, evades standard audit patterns, and uses arithmetic triggers rather than obvious conditional branches.
The core insight: immutables aren't storage. They're bytecode. The Solidity compiler embeds immutable values directly into deployed code. If those values contain valid EVM opcodes starting with JUMPDEST (0x5b), they become executable, but only if the contract explicitly jumps to them. The trick is building a trigger that computes that jump.
This isn't theoretical. I built it. It works.
This technique builds on standard EVM behaviors (arbitrary jumps, bytecode introspection, constructor patching) but applies them to deployment-parameter attack vectors that evade standard verification and audit procedures.
Background
How Immutables Work
When you declare:
bytes32 public immutable configParam;
The compiler doesn't store this in a storage slot. Instead, during deployment:
- Constructor runs with the init code
- Init code reads constructor arguments
- Init code patches the runtime bytecode at placeholder locations
- Patched runtime bytecode is returned and stored on-chain
The immutable value becomes part of the contract's code, not its state.
Why immutable, not constant? Solidity constant variables are evaluated at compile-time and hardcoded into the source-matched bytecode. They're visible in the audited source. immutable variables are different: they're patched into bytecode at deployment, after compilation. That gap between "compiled" and "deployed" is where the payload hides.
The Gap
Current security assumptions:
- Source verification confirms bytecode matches source
- Auditors review source code for vulnerabilities
- Static analysis tools scan for known patterns
What's missed:
- The values passed at deployment aren't audited
- Constructor arguments are considered "configuration," not "code"
- Nobody asks: "What if this bytes32 is valid EVM?"
The Attack
Step 1: Legitimate-Looking Contract
contract GasOptimizedVault {
// "Lookup tables for gas optimization"
bytes32 public immutable lut0;
bytes32 public immutable lut1;
bytes32 public immutable lut2;
// Normal business logic...
}
An auditor sees optimization parameters. Nothing suspicious.
Step 2: Hidden Payload
At deployment, I pass:
lut0 = 0x5b63CAFEBABE60005260206000f3000000000000000000000000000000000000
This decodes to:
5b JUMPDEST ← Valid jump target
63 CAFEBABE PUSH4 ← Push proof value
60 00 PUSH1 0 ← Memory offset
52 MSTORE ← Store to memory
60 20 PUSH1 32 ← Return size
60 00 PUSH1 0 ← Return offset
f3 RETURN ← Return 0xCAFEBABE
The "configuration value" is executable code.
Step 3: Arithmetic Trigger
Instead of an obvious backdoor, I use arithmetic that computes a jump target:
assembly {
let targetOffset := and(validationProof, 0xFFFF)
let authBits := shr(16, validationProof)
if eq(authBits, 0xDEADFACE) {
if lt(targetOffset, codesize()) {
let op := byte(0, mload(add(mem, targetOffset)))
if eq(op, 0x5b) {
// Trigger condition met
mstore(0x00, 0xCAFEBABE)
return(0x00, 0x20)
}
}
}
}
To an auditor, this looks like "validation logic" or "gas optimization path selection." But look closer: the contract is inspecting its own bytecode, checking if a specific offset contains 0x5b. This is a polyglot check. The code is verifying that "data" is ready to become "code."
Step 4: Activation
The attacker knows the immutable's offset (379 in the PoC, specific to Solidity 0.8.19 with optimizer enabled) and the authentication value (0xDEADFACE).
Trigger value: (0xDEADFACE << 16) | 379 = 0xdeadface017b
Call: withdraw(0, 0xdeadface017b)
Result: Contract returns 0xCAFEBABE instead of executing normal logic.
Bytecode fragility: The offset (379) is the exact byte position in the final compiled runtime bytecode. Change one line of Solidity above the immutable declaration. Add a function, modify a string, and the offset shifts. The attacker must compile the exact source with the exact compiler version and settings to calculate the correct trigger. This makes the exploit brittle by design: it only works if you have the final binary. For an attacker deploying their own contract, this is trivial. For someone trying to exploit an existing contract, it's a non-starter unless they control deployment.
PUSH32 offset: In the deployed bytecode, immutable values are preceded by a PUSH32 opcode (0x7f). So if the PUSH32 is at byte 378, your actual payload starts at byte 379. The trigger offset must point to the payload itself (where your 0x5b JUMPDEST lives), not the PUSH32 instruction. The PoC accounts for this.
Dead code safety: In current Solidity compiler layouts, immutables live in "dead code" regions of the bytecode, typically after function bodies terminate with STOP or RETURN. Normal execution flow never reaches them. This is why the hidden payload doesn't crash the contract during regular use. It only executes when explicitly jumped to. The compiler puts your executable data somewhere it will never accidentally run.
Why This Works
Etherscan verification passes. The source is clean. It compiles to exact deployed bytecode. Constructor arguments are visible on Etherscan, but they're displayed as raw hex data. Few tools or audit workflows interpret them as executable code. The payload hides in plain sight.
Auditors miss it because the vulnerability isn't in the code. It's in the deployment parameters. Auditors review source, not deployment transactions.
Static analysis fails because tools scan for reentrancy, overflow, access control issues. Not "Is this immutable value a valid EVM instruction sequence?"
The self-reference: The trigger authenticates via self-reference. The contract checks if code[offset] equals 0x5b. The attacker must know the exact bytecode to calculate the correct offset. The hidden code is its own key.
Proof of Concept
Verified Values
Payload: 0x5b63CAFEBABE60005260206000f300000000...
Payload Offset: 379
Trigger Value: 0xdeadface017b
Expected Return: 0xCAFEBABE
Execution Trace
withdraw(0, 0xdeadface017b) called
→ targetOffset = 0xdeadface017b & 0xFFFF = 379
→ authBits = 0xdeadface017b >> 16 = 0xDEADFACE
CHECK 1: authBits == 0xDEADFACE? TRUE
CHECK 2: 379 < codesize(1943)? TRUE
CHECK 3: code[379] == 0x5b? TRUE
ALL CONDITIONS MET → Returns 0xCAFEBABE
Real-World Impact
The PoC returns a proof value. A weaponized version could:
- Drain funds: hidden code calls
selfdestruct(attacker) - Modify state: change ownership, alter balances
- Proxy manipulation: in upgradeable contracts, swap implementation
- Data exfiltration: return sensitive storage values
Attack Scenario: Attacker deploys "audited" DeFi protocol → Protocol accumulates TVL → Attacker calls trigger function → Hidden code drains funds → Etherscan still shows "verified" source
Taking It Further: Actual Execution
The PoC above detects the trigger and returns proof. To actually execute the hidden payload, you need to jump to it. Solidity's high-level semantics prevent arbitrary jumps, but Yul allows direct jump when emitting raw EVM:
// Yul: actual jump to hidden payload
assembly {
let size := codesize()
let mem := mload(0x40)
codecopy(mem, 0, size)
let targetOffset := and(calldataload(36), 0xFFFF)
let authBits := shr(16, calldataload(36))
if eq(authBits, 0xDEADFACE) {
if lt(targetOffset, size) {
let op := byte(0, mload(add(mem, targetOffset)))
if eq(op, 0x5b) {
// Jump directly to the payload
jump(targetOffset)
}
}
}
// Normal execution continues here...
}
When this executes, control transfers to offset 379, hits the JUMPDEST, and runs the hidden instructions. The payload executes as native EVM. Not simulation, not detection, actual execution.
Payload Size: Chaining Immutables
A single bytes32 immutable gives you 32 bytes of payload. That's enough for simple operations (return a value, write to storage, selfdestruct). For complex payloads, chain multiple immutables:
bytes32 public immutable payload0; // JUMPDEST + first 31 bytes
bytes32 public immutable payload1; // next 32 bytes
bytes32 public immutable payload2; // next 32 bytes
// ... up to ~128 bytes of hidden code
The payloads land at consecutive offsets in bytecode. Your initial JUMPDEST starts execution, and it flows through all chained segments. 128 bytes is enough to implement arbitrary logic: balance transfers, ownership changes, proxy upgrades.
A Note on Tier-1 Auditors
Top-tier firms (Trail of Bits, Spearbit, OpenZeppelin) often review deployment scripts and constructor arguments. They might catch this. But they audit maybe 1% of deployed contracts. The other 99%, including many with significant TVL, get "retail" audits that rubber-stamp source code without examining deployment parameters.
This attack specifically targets that gap.
Detection and Mitigation
For Auditors
DO:
- Review deployment transactions, not just source
- Check immutable values for executable patterns
- Look for assembly that reads own bytecode (
codecopy,codesize) - Flag any computed jump targets or offset-based conditionals
- Ask: "What's the worst value someone could pass here?"
Red Flags:
bytes32 immutablewith no clear semantic meaning- Assembly blocks that check for
0x5b(JUMPDEST) - Arithmetic operations on user input that produce offsets
- Any
codecopyfollowed by byte-level inspection
For Protocols
- Constrain immutable types: use
uint256oraddressinstead ofbytes32 - Validate in constructor: check that immutable values are within expected ranges
- Deployment verification: have auditors sign off on deployment tx, not just source
Why This Is Not Just Obfuscation
Traditional obfuscation hides logic within source code. Auditors can still find it if they look hard enough. This technique is different: it hides logic outside the source, in deployment parameters that auditors and verification pipelines treat as inert data.
The malicious behavior is not present in the reviewed source. It's not reachable without a deployment-specific trigger derived from the final bytecode. This places it outside the threat model of most audits and static analysis tools, which assume constructor arguments do not introduce new executable semantics.
The source code is clean. The compiler output is clean. The payload exists only in the gap between "what was audited" and "what was deployed."
Philosophical Note
This attack works because of a fundamental assumption in security: that data and code are separable.
The assumption is source code is code. Constructor arguments are data. Storage is state. But in the EVM, bytes are bytes. The same 32 bytes can be:
- A configuration parameter (to the auditor)
- A hash or key (to the developer)
- Executable instructions (to the EVM)
This is the same insight behind format confusion attacks, polyglot files, and the same principle that drives Veriduct's format destruction.
The interpreter defines meaning. The bytes have none inherently.
When you internalize this, you stop asking "what is this data?" and start asking "what could this data become?"