// SPDX-License-Identifier: MIT pragma solidity 0.8.26; /** A plain ERC-20 with settable decimals, for the harness only. */ contract MockToken { string public name = "Mock"; string public symbol = "MOCK"; uint8 public immutable decimals; uint256 public totalSupply; mapping(address => uint256) public balanceOf; mapping(address => mapping(address => uint256)) public allowance; event Transfer(address indexed from, address indexed to, uint256 v); event Approval(address indexed o, address indexed s, uint256 v); constructor(uint8 d) { decimals = d; } function mint(address to, uint256 v) public virtual { totalSupply += v; balanceOf[to] += v; emit Transfer(address(0), to, v); } function approve(address s, uint256 v) external returns (bool) { allowance[msg.sender][s] = v; emit Approval(msg.sender, s, v); return true; } function transfer(address to, uint256 v) external returns (bool) { return _move(msg.sender, to, v); } function transferFrom(address f, address to, uint256 v) external returns (bool) { if (msg.sender != f) { uint256 a = allowance[f][msg.sender]; if (a != type(uint256).max) { require(a >= v, "allowance"); allowance[f][msg.sender] = a - v; } } return _move(f, to, v); } function _move(address f, address t, uint256 v) internal virtual returns (bool) { require(balanceOf[f] >= v, "balance"); unchecked { balanceOf[f] -= v; balanceOf[t] += v; } emit Transfer(f, t, v); return true; } } /** Takes a cut on every transfer. Exists so "credit what arrived" is tested against a token that actually does it, not asserted. */ contract FeeToken is MockToken { uint256 public immutable feeBps; constructor(uint8 d, uint256 bps) MockToken(d) { feeBps = bps; } function _move(address f, address t, uint256 v) internal override returns (bool) { require(balanceOf[f] >= v, "balance"); uint256 fee = (v * feeBps) / 10_000; unchecked { balanceOf[f] -= v; balanceOf[t] += v - fee; balanceOf[address(0xdead)] += fee; } emit Transfer(f, t, v - fee); return true; } } interface IVault { function deposit(uint256, address) external returns (uint256); function redeem(uint256, address, address) external returns (uint256); function settle() external returns (uint256); } /** * A token with a transfer hook, which is what makes the balance-either-side * measurement in `_pull` exploitable without a mutex. * * IT MUST ACTUALLY FIRE. The first version of this attack in a sibling never * reached the vault at all: the malicious token had not approved the vault, so * the reentrant deposit died on `allowance` and the sabotage was reported as * MISSED against a contract with its mutex removed. `armed` is set only after * this contract has both a balance and an approval, and `fired` records that * the inner call was made, so a property can assert the attack HAPPENED. */ contract HookToken is MockToken { address public vault; bool public armed; bool public fired; bool public innerReverted; uint256 public hookAmount; constructor(uint8 d) MockToken(d) {} function arm(address v, uint256 amount) external { vault = v; hookAmount = amount; this.approve(v, type(uint256).max); allowance[address(this)][v] = type(uint256).max; require(balanceOf[address(this)] >= amount, "fund me first"); armed = true; } function _move(address f, address t, uint256 v) internal override returns (bool) { require(balanceOf[f] >= v, "balance"); unchecked { balanceOf[f] -= v; balanceOf[t] += v; } emit Transfer(f, t, v); /* Re-enter while the outer deposit sits between its two balance reads. */ if (armed && t == vault && !fired) { fired = true; try IVault(vault).deposit(hookAmount, address(this)) {} catch { innerReverted = true; } } return true; } } /** The classic ERC-4626 first-depositor inflation attack, run for real. */ contract Inflator { function attack(address vault, address token, uint256 victimAssets) external returns (uint256 victimShares, uint256 victimBack, uint256 attackerBack) { MockToken t = MockToken(token); t.approve(vault, type(uint256).max); /* 1 wei in, then a large donation straight to the vault. */ uint256 s = IVault(vault).deposit(1, address(this)); t.transfer(vault, victimAssets * 10); /* The victim deposits. */ victimShares = _victimDeposit(vault, token, victimAssets); victimBack = victimShares == 0 ? 0 : IVault(vault).redeem(victimShares, address(this), address(this)); attackerBack = IVault(vault).redeem(s, address(this), address(this)); } function _victimDeposit(address vault, address token, uint256 a) internal returns (uint256) { MockToken(token).approve(vault, type(uint256).max); try IVault(vault).deposit(a, address(this)) returns (uint256 s) { return s; } catch { return 0; } } } /** A stranger, so a guard is tested by an armed caller rather than by something else also saying no. */ contract Stranger { function callReport(address vault, int256 delta) external returns (bool ok) { (ok, ) = vault.call(abi.encodeWithSignature("report(int256)", delta)); } function callRedeem(address vault, uint256 shares, address owner) external returns (bool ok) { (ok, ) = vault.call(abi.encodeWithSignature("redeem(uint256,address,address)", shares, address(this), owner)); } }