The ProofOfBurn circuit verifies Merkle-Patricia Trie structure by checking that the last layer is a valid account leaf with an empty code hash (EOA), and that each layer's keccak hash appears as a substring in its parent layer. However, the circuit does not validate that intermediate layers are NOT account leaf nodes themselves.
In Ethereum's state trie, there are two types of account leaves:
- EOA (Externally Owned Account):
RLP([nonce, balance, EMPTY_STORAGE_HASH, EMPTY_CODE_HASH])
- Contract Account:
RLP([nonce, balance, storage_root, code_hash]) where code_hash and storage_root are 32-byte hashes
The vulnerability arises because:
-
The circuit only validates that the last layer (layers[numLayers - 1]) is a properly formed EOA leaf:
signal (leaf[maxLeafLen], leafLen) <== RlpMerklePatriciaTrieLeaf(32, amountBytes)(
addressHashNibbles, numLeafAddressNibbles, balance
);
for(var i = 0; i < maxLeafLen; i++) {
leaf[i] === lastLayer[i];
}
https://github.com/worm-privacy/proof-of-burn/blob/0e5237480fbac83aa4291a9e06618b4318da3585/circuits/proof_of_burn.circom#L192-L199
-
Intermediate layers (layers[0] through layers[numLayers - 2]) are only verified via substring checks:
substringCheckers[i - 1] <== SubstringCheck(maxNodeBlocks * 136, 31)(
subInput <== reducedLayerKeccaks[i],
mainLen <== layerLens[i - 1],
mainInput <== layers[i - 1]
);
https://github.com/worm-privacy/proof-of-burn/blob/0e5237480fbac83aa4291a9e06618b4318da3585/circuits/proof_of_burn.circom#L171-L175
-
No check exists to ensure intermediate layers are branch/extension nodes rather than account leaves.
This means an attacker can use a CONTRACT ACCOUNT leaf as an intermediate layer. Since contract account leaves contain 32-byte fields (storage_root and code_hash), the attacker can craft scenarios where these hash fields match the keccak hash of a fake child layer.
Attack scenario (bytecode case):
The attacker exploits the fact that the circuit doesn't validate that layers are actual MPT nodes. The attacker can provide raw bytecode as a "layer" even though bytecode is not part of the state trie structure.
Step 1: Craft the attack from bottom-up
ATTACKER_LEAF (fake burn address leaf claiming arbitrary balance):
RLP([fake_address_hash_nibbles, RLP([0, 999 ETH, EMPTY_STORAGE_HASH, EMPTY_CODE_HASH])])
// where fake_address_hash_nibbles = nibbles of keccak256(fake_burn_address)
Step 2: Create bytecode containing the hash of ATTACKER_LEAF
bytecode = 0x60<32_bytes_of_keccak(ATTACKER_LEAF)>...
// Example: PUSH32 instruction followed by the hash, plus any padding
Step 3: Deploy contract with crafted bytecode
// Deploy contract at some address, which creates:
contract_leaf = RLP([
contract_address_hash_nibbles,
RLP([nonce, balance, storage_root, code_hash])
])
where:
- contract_address_hash_nibbles = nibbles of keccak256(contract_address)
- code_hash = keccak256(bytecode)
Step 4: Craft fake proof path with 4 layers
layers[0] = branch_node (legitimate, contains keccak(contract_leaf))
layers[1] = contract_leaf (CONTRACT ACCOUNT leaf - pretending to be intermediate node!)
layers[2] = bytecode (NOT an MPT node! Just raw bytecode bytes containing keccak(ATTACKER_LEAF))
layers[3] = ATTACKER_LEAF (fake burn address leaf)
Step 5: Circuit verification (all checks PASS incorrectly)
Check 1 (i=1): Is keccak(contract_leaf)[0:31] substring of branch_node?
YES - contract_leaf is a real account in the state trie
Check 2 (i=2): Is keccak(bytecode)[0:31] substring of contract_leaf?
YES - The contract_leaf contains code_hash = keccak256(bytecode)
Check 3 (i=3): Is keccak(ATTACKER_LEAF)[0:31] substring of bytecode?
YES - The bytecode physically contains keccak256(ATTACKER_LEAF) as embedded data
Check 4: Is ATTACKER_LEAF a valid EOA leaf?
YES - Properly formatted RLP
Result: Circuit accepts ATTACKER_LEAF as existing in state root!
The key insight:
The circuit doesn't validate that layers represent actual MPT nodes. The attacker exploits this by:
- Using a contract account leaf at Layer N (contains code_hash field)
- Providing raw bytecode as Layer N+1 (NOT a real MPT node!)
- The bytecode contains keccak(ATTACKER_LEAF) as embedded data
- Using the fake ATTACKER_LEAF as Layer N+2
Visual diagram (bytecode variant):
Legitimate path (what circuit expects):
State Root -> Branch -> Branch -> EOA Leaf
(layers[0]) (layers[1]) (layers[2])
^
All intermediate layers should be branch/extension nodes
Malicious path (bytecode attack):
State Root -> Branch -> Contract Leaf -> Bytecode -> Fake EOA Leaf
(layers[0]) (layers[1]) (layers[2]) (layers[3])
^ ^
| |
CONTRACT ACCOUNT leaf NOT an MPT node!
(circuit accepts this Just raw bytecode with
as intermediate layer!) keccak(layers[3]) embedded
Contains code_hash
Key vulnerabilities:
1. No validation that layers[1] is a branch/extension node (not a leaf)
2. No validation that layers[2] is an actual state MPT node (not arbitrary data)
Alternative attack (storage_root variant):
The same attack works with storage_root instead of code_hash. The attacker can:
- Deploy a contract and use SSTORE to write the complete ATTACKER_LEAF structure as a storage value:
sstore(slot, ATTACKER_LEAF) // ATTACKER_LEAF = RLP([fake_address_hash_nibbles, RLP([0, 999 ETH, ...])])
// where fake_address_hash_nibbles = nibbles of keccak256(fake_burn_address)
- The storage trie will have a leaf:
RLP([storage_key_hash_nibbles, RLP(ATTACKER_LEAF)])
// where storage_key_hash_nibbles = nibbles of keccak256(storage_slot)
- Provide the storage trie path (storage branch/extension nodes -> storage leaf with ATTACKER_LEAF)
- The contract's storage_root equals the root of this storage trie, creating a valid link
- The substring check finds
ATTACKER_LEAF (the raw bytes) within the storage leaf's RLP encoding
Visual diagram (storage_root variant):
Malicious path (storage_root attack):
State Root -> Branch -> Contract Leaf -> Storage Branch -> Storage Leaf
(layers[0]) (layers[1]) (layers[2]) (layers[3])
^ ^
| |
CONTRACT ACCOUNT leaf Storage leaf = Fake EOA Leaf
(circuit accepts this RLP([key, RLP(ATTACKER_LEAF)])
as intermediate layer!) The value IS the ATTACKER_LEAF
Contains storage_root
Attack setup:
1. Deploy contract and execute: sstore(slot_x, ATTACKER_LEAF)
2. Storage trie now has leaf containing ATTACKER_LEAF as the stored value
3. Craft fake proof path with 4 layers:
layers[0] = branch_node (legitimate, contains keccak(contract_leaf))
layers[1] = contract_leaf (CONTRACT ACCOUNT leaf - pretending to be intermediate node!)
layers[2] = storage_branch (storage trie branch node)
layers[3] = storage_leaf = RLP([key, RLP(ATTACKER_LEAF)]) (This IS the fake EOA leaf!)
4. Circuit verification:
- Substring check finds ATTACKER_LEAF (raw bytes) within storage_leaf's RLP encoding
- Final layer (storage_leaf) is validated as the ATTACKER_LEAF claiming 999 ETH
Key insight: SSTORE lets attacker write arbitrary values, so they can store
the entire ATTACKER_LEAF structure as a storage value. The storage leaf containing
this value becomes the final validated layer.