Skip to content
All posts
miscrevised August 12, 20267 min read

Smart contract challenges: reentrancy, storage, and the EVM

Blockchain CTF without the jargon. Reading a contract you only have bytecode for, the storage slot that is public whether or not the variable is, reentrancy and delegatecall, and why on-chain randomness is never random.

A smart-contract challenge gives you an address on a test network and a goal - drain the balance, become the owner, set isSolved to true. The contract is a program with a public API, permanent storage, and no privacy whatsoever. That last property surprises people and is where several challenges live.

You need very little blockchain knowledge to solve these. You need to know that calls are transactions, that state changes persist, that everything on chain is readable, and that a contract calling another contract hands control to code it does not own.

Getting oriented

# Foundry's cast is the fastest way to talk to a chain.
cast code   $TARGET --rpc-url $RPC       # the deployed bytecode
cast storage $TARGET 0 --rpc-url $RPC    # storage slot 0
cast balance $TARGET --rpc-url $RPC

# Call a view function, or send a transaction.
cast call $TARGET "isSolved()(bool)" --rpc-url $RPC
cast send $TARGET "solve(uint256)" 42 --private-key $PK --rpc-url $RPC

If you were given the Solidity source, read it. If you were given only an address, you have bytecode - and EVM bytecode is far more tractable than native code, because the ABI dispatch at the top of every contract enumerates its own function selectors.

PUSH4 0x2e64cec1     ; the first 4 bytes of keccak256("retrieve()")
EQ
PUSH2 0x004f
JUMPI                ; jump to that function's body if the selector matches
Every public function appears in this dispatch table. Collect the selectors, look them up in a signature database, and you have reconstructed the ABI without any source.

Nothing on chain is private

private in Solidity is a *visibility* modifier for other contracts. It has nothing to do with confidentiality. Storage is a flat array of 32-byte slots and anyone can read any slot of any contract at any block.

cast storage $TARGET 0 --rpc-url $RPC   # first declared state variable
cast storage $TARGET 1 --rpc-url $RPC   # second, and so on
  • Slots are assigned in declaration order. Variables smaller than 32 bytes are packed together into one slot, so a bool and an address share slot 0 - read the whole slot and split it.
  • Mappings are at keccak256(key . slot), and dynamic arrays store their length in the slot and their data from keccak256(slot) onward. Both are computable.
  • A password stored in a private variable is a one-command read, and it is a whole genre of beginner challenge.
  • Transaction history is also public. If the value was ever passed as a calldata argument, it is in the transaction that set it, forever. cast tx retrieves it.

Reentrancy

The classic. When a contract sends ether to an address, if that address is a contract, its receive or fallback function runs - *before* the sender's own function finishes. If the sender updates its bookkeeping after the transfer, the callback sees stale state.

function withdraw() public {
    uint amount = balances[msg.sender];
    (bool ok, ) = msg.sender.call{value: amount}("");   // <- control transfers here
    require(ok);
    balances[msg.sender] = 0;                           // <- too late
}

The attacking contract's fallback calls withdraw() again. balances[msg.sender] is still the original amount, so it pays out again, recursively, until the contract is empty or the gas runs out.

// The attacker, in Solidity terms:
//   receive() external payable {
//       if (address(target).balance >= 1 ether) target.withdraw();
//   }
// Deploy it, deposit once, call withdraw once, and the recursion does the rest.
  • The pattern to look for is any state change that happens after an external call. The fix - checks, effects, interactions - names the correct order, and its absence is the bug.
  • Cross-function reentrancy is the subtler variant: the callback calls a *different* function that reads the same stale state. A withdraw guarded by a mutex and a transfer that is not, both reading balances, is exploitable.
  • Read-only reentrancy attacks a third contract that queries the victim's state mid-transaction and gets an inconsistent view.
  • `transfer` and `send` forward only 2300 gas, historically enough to prevent this. Modern code uses call and must guard explicitly, which is why the bug came back.

Conceptually this is a race condition with a single thread: the check and the act are separated, and the attacker gets to run in the gap. The gap here is not scheduling, it is a function call.

delegatecall and storage collision

delegatecall executes another contract's code *in the caller's storage context*. It is how upgradeable proxies work, and it is dangerous for exactly that reason: the library's code writes to slot numbers, and those slots belong to the caller.

So if a library writes to its slot 0 thinking it is uint public count, and the caller has address public owner at slot 0, calling through delegatecall overwrites the owner. Matching the two contracts' storage layouts is the entire vulnerability class.

  • Look for `delegatecall` with a target you can influence. If the library address is settable, you supply your own library and execute arbitrary code in the victim's context.
  • Look for a layout mismatch between proxy and implementation. A proxy that stores its implementation address in slot 0 and an implementation that uses slot 0 for something else is a takeover.
  • `selfdestruct` in a delegated library destroys the *caller*, which has been the ending of several real incidents.
  • `msg.sender` and `msg.value` are preserved across a delegatecall, so authorisation checks inside the library see the original caller - which is sometimes the bug and sometimes the point.

The rest of the catalogue

ClassThe tell
tx.origin used for authorisationPhishable: get the owner to call your contract, which calls the target. tx.origin is still the owner.
Unchecked return value of call/sendA failed transfer that is treated as success.
Integer overflowSolidity < 0.8 does not check. unchecked { } blocks opt out in 0.8+. Same reasoning as integer bugs.
Price from a single DEX poolFlash-loan the pool, move the price, act on the new price, repay - all in one transaction.
Access control on a function that lacks itRead every function's modifiers. An initialize() with no guard can be called by anyone.
Signature replayA signed message with no nonce or no chain id is reusable, on this chain and on others.
Gas griefing / DoS by revertA loop paying out to a list, where one recipient reverts and blocks everyone.

Note how many of these are the design-reading exercise rather than anything blockchain-specific: who is trusted, what is checked, and in what order. The EVM contributes the peculiar rules - control transfers on payment, storage is public, state is permanent - and the bug classes follow from those.

Working a challenge

  1. Get the goal precisely. Usually a isSolved() view function - read it and read what sets it.
  2. Get the ABI: from the source if given, from the dispatch table if not.
  3. Dump the first several storage slots. A surprising number of challenges end here.
  4. Read every function for its access control, and for state changes after external calls.
  5. Look for delegatecall, selfdestruct, tx.origin and any use of block values as randomness.
  6. Write the exploit as a contract when it needs to react mid-transaction - reentrancy and flash loans both do. A script cannot be called back into.
  7. Test on a local fork before spending the real transaction. anvil --fork-url gives you the same state to rehearse against.

That last point saves the most time. These challenges usually give you a single instance and resetting is slow, so rehearsing the whole exploit against a local fork of the same chain state turns a nervous one-shot into something you have already seen work.