test/fixtures/DrainVariants.sol90 lines · 4165 bytes
| 1 | // SPDX-License-Identifier: MIT |
| 2 | pragma solidity 0.8.37; |
| 3 | |
| 4 | import {DemoBTC} from "../../src/DemoBTC.sol"; |
| 5 | |
| 6 | // Test-only attack fixtures for the CRE clearance workflow's fork E2E. Each inherits DemoBTC, so it keeps v1's |
| 7 | // storage layout and every v1 selector intact (mint stays owner-only, transfers conserve, the owner stays put, |
| 8 | // no dangerous opcodes): R1, R2, R3, R4 and R9 all pass, and only R3B can catch the drain. These are the seven |
| 9 | // variants a reviewer used to slip past an R3B that only matched the exact rescue(from, to, amount) shape; the |
| 10 | // hardened simulation must veto every one of them R3B. |
| 11 | // |
| 12 | // Deliberately malicious demo code. Never deploy outside a demo. |
| 13 | |
| 14 | /// @notice A: rescue with the amount word between the two addresses — rescue(from, amount, to). |
| 15 | contract VariantA_RescueReorder is DemoBTC { |
| 16 | function rescue(address from, uint256 amount, address to) external { |
| 17 | _transfer(from, to, amount); |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | /// @notice B: a sweep whose first word is the amount — sweep(amount, from, to). |
| 22 | contract VariantB_SweepReorder is DemoBTC { |
| 23 | function sweep(uint256 amount, address from, address to) external { |
| 24 | _transfer(from, to, amount); |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | /// @notice C: a four-argument rescueTokens(token, from, to, amount) that insists the token is this contract. The |
| 29 | /// probe that fills the remaining address slots with the proxy address passes the guard. |
| 30 | contract VariantC_RescueTokensGuard is DemoBTC { |
| 31 | error WrongToken(); |
| 32 | |
| 33 | function rescueTokens(address token, address from, address to, uint256 amount) external { |
| 34 | if (token != address(this)) revert WrongToken(); |
| 35 | _transfer(from, to, amount); |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | /// @notice D: an open rescue(from, to, amount) gated on the caller already holding tokens. A stranger with no |
| 40 | /// balance cannot reach it — unless the simulation funds the stranger first. |
| 41 | contract VariantD_RescueRequireTokens is DemoBTC { |
| 42 | error NoTokens(); |
| 43 | |
| 44 | function rescue(address from, address to, uint256 amount) external { |
| 45 | if (balanceOf(msg.sender) == 0) revert NoTokens(); |
| 46 | _transfer(from, to, amount); |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | /// @notice E: a rescue(from, to, amount) that moves balances with raw storage writes and emits NO Transfer event, |
| 51 | /// so a probe out and a later probe back net to zero with no logs. The post-probe balance read catches it. |
| 52 | contract VariantE_RescueNoLog is DemoBTC { |
| 53 | // ERC20Upgradeable ERC-7201 storage: _balances is the first field of the struct at this slot. |
| 54 | // keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.ERC20")) - 1)) & ~bytes32(uint256(0xff)) |
| 55 | bytes32 private constant ERC20_STORAGE = 0x52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00; |
| 56 | |
| 57 | function rescue(address from, address to, uint256 amount) external { |
| 58 | bytes32 fromSlot = keccak256(abi.encode(from, ERC20_STORAGE)); |
| 59 | bytes32 toSlot = keccak256(abi.encode(to, ERC20_STORAGE)); |
| 60 | assembly { |
| 61 | let fb := sload(fromSlot) |
| 62 | if lt(fb, amount) { revert(0, 0) } |
| 63 | sstore(fromSlot, sub(fb, amount)) |
| 64 | sstore(toSlot, add(sload(toSlot), amount)) |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /// @notice F: an open rescue(from, to, amount) gated on the caller holding ETH. A stranger with no ETH cannot reach |
| 70 | /// it — unless the simulation gives the stranger an ETH balance override. |
| 71 | contract VariantF_RescueRequireEth is DemoBTC { |
| 72 | error NoEth(); |
| 73 | |
| 74 | function rescue(address from, address to, uint256 amount) external { |
| 75 | if (msg.sender.balance == 0) revert NoEth(); |
| 76 | _transfer(from, to, amount); |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | /// @notice G: no new selector. transferFrom waives the allowance check whenever the caller already holds tokens, so |
| 81 | /// a token-holding stranger can pull any holder's balance. The R3B transferFrom runs from the funded stranger. |
| 82 | contract VariantG_TransferFromAllowanceSkip is DemoBTC { |
| 83 | function transferFrom(address from, address to, uint256 value) public override returns (bool) { |
| 84 | if (balanceOf(msg.sender) != 0) { |
| 85 | _transfer(from, to, value); |
| 86 | return true; |
| 87 | } |
| 88 | return super.transferFrom(from, to, value); |
| 89 | } |
| 90 | } |