From 729a79924c98dcaa4f817257a0174496c37021a7 Mon Sep 17 00:00:00 2001 From: MyTH-zyxeon
Date: Wed, 20 May 2026 14:00:10 +0900 Subject: [PATCH] feat: add ERC777 reentrancy check template --- README.md | 2 + src/checks/ERC777ReentrancyCheck.sol | 82 ++++++++++++ test/ERC777Reentrancy.t.sol | 181 +++++++++++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 src/checks/ERC777ReentrancyCheck.sol create mode 100644 test/ERC777Reentrancy.t.sol diff --git a/README.md b/README.md index 885331f..acbbdac 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` | Token callback reentrancy through ERC-777 `tokensReceived` hooks | | `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 tests │ ├── 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..8bef289 --- /dev/null +++ b/src/checks/ERC777ReentrancyCheck.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "../ChecklistBase.sol"; + +interface IERC777Balance { + function balanceOf(address account) external view returns (uint256); +} + +/// @title ERC777ReentrancyCheck — detect tokensReceived hook reentrancy +/// @notice Tests whether an ERC-777 token callback can re-enter a token withdrawal +/// before the audited contract updates internal accounting. +/// @author kcolbchain +abstract contract ERC777ReentrancyCheck is ChecklistBase { + /// @dev Override to return the ERC-777 token used by the target contract. + function getERC777Token() internal view virtual returns (address); + + /// @dev Override to return calldata for the target's token withdrawal function. + function getERC777WithdrawCalldata() internal view virtual returns (bytes memory); + + /// @dev Override to return the token amount to deposit for the attacker. + function getERC777DepositAmount() internal view virtual returns (uint256) { + return 100 ether; + } + + /// @dev Override to mint, transfer, or otherwise fund `depositor`. + function fundERC777Depositor(address depositor, uint256 amount) internal virtual; + + /// @dev Override to deposit `amount` from `depositor` into the target. + function performERC777Deposit(address depositor, uint256 amount) internal virtual; + + function test_erc777_tokens_received_reentrancy() public virtual { + address token = getERC777Token(); + uint256 depositAmount = getERC777DepositAmount(); + bytes memory withdrawCalldata = getERC777WithdrawCalldata(); + ERC777ReentrantReceiver attacker = new ERC777ReentrantReceiver(targetContract, token, withdrawCalldata); + + fundERC777Depositor(address(attacker), depositAmount); + performERC777Deposit(address(attacker), depositAmount); + + uint256 attackerBalanceBefore = IERC777Balance(token).balanceOf(address(attacker)); + + attacker.attack(); + + uint256 attackerBalanceAfter = IERC777Balance(token).balanceOf(address(attacker)); + uint256 extracted = attackerBalanceAfter - attackerBalanceBefore; + + if (extracted > depositAmount) { + emit log("VULNERABILITY: ERC-777 tokensReceived reentrancy extracted more than deposited"); + emit log_named_uint("Deposited", depositAmount); + emit log_named_uint("Extracted", extracted); + } + assertLe(extracted, depositAmount, "ERC-777 tokensReceived reentrancy extracted more than deposited"); + } +} + +/// @dev Helper recipient that re-enters the target when ERC-777 tokens are received. +contract ERC777ReentrantReceiver { + address public immutable target; + address public immutable token; + bytes public withdrawCalldata; + uint256 public attackCount; + + constructor(address _target, address _token, bytes memory _withdrawCalldata) { + target = _target; + token = _token; + withdrawCalldata = _withdrawCalldata; + } + + function tokensReceived(address, address, address, uint256, bytes calldata, bytes calldata) external { + if (msg.sender == token && attackCount < 2) { + attackCount++; + (bool success,) = target.call(withdrawCalldata); + success; + } + } + + function attack() external { + (bool success,) = target.call(withdrawCalldata); + success; + } +} diff --git a/test/ERC777Reentrancy.t.sol b/test/ERC777Reentrancy.t.sol new file mode 100644 index 0000000..8a6749e --- /dev/null +++ b/test/ERC777Reentrancy.t.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/checks/ERC777ReentrancyCheck.sol"; + +interface IERC777Recipient { + function tokensReceived( + address operator, + address from, + address to, + uint256 amount, + bytes calldata userData, + bytes calldata operatorData + ) external; +} + +contract MockERC777Token { + mapping(address => uint256) public balanceOf; + + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + } + + function send(address to, uint256 amount, bytes calldata data) external { + _send(msg.sender, msg.sender, to, amount, data, ""); + } + + function _send( + address operator, + address from, + address to, + uint256 amount, + bytes memory userData, + bytes memory operatorData + ) internal { + require(balanceOf[from] >= amount, "insufficient balance"); + + balanceOf[from] -= amount; + balanceOf[to] += amount; + + if (to.code.length > 0) { + try IERC777Recipient(to).tokensReceived(operator, from, to, amount, userData, operatorData) {} catch {} + } + } +} + +contract VulnerableERC777Vault is IERC777Recipient { + MockERC777Token public immutable token; + mapping(address => uint256) public balances; + + constructor(MockERC777Token _token) { + token = _token; + } + + function tokensReceived(address, address from, address, uint256 amount, bytes calldata, bytes calldata) external { + require(msg.sender == address(token), "unknown token"); + balances[from] += amount; + } + + function withdraw() external { + uint256 amount = balances[msg.sender]; + require(amount > 0, "no balance"); + + token.send(msg.sender, amount, ""); + + balances[msg.sender] = 0; + } +} + +contract SafeERC777Vault is IERC777Recipient { + MockERC777Token public immutable token; + mapping(address => uint256) public balances; + + constructor(MockERC777Token _token) { + token = _token; + } + + function tokensReceived(address, address from, address, uint256 amount, bytes calldata, bytes calldata) external { + require(msg.sender == address(token), "unknown token"); + balances[from] += amount; + } + + function withdraw() external { + uint256 amount = balances[msg.sender]; + require(amount > 0, "no balance"); + + balances[msg.sender] = 0; + + token.send(msg.sender, amount, ""); + } +} + +contract VulnerableERC777Audit is ERC777ReentrancyCheck { + MockERC777Token token; + VulnerableERC777Vault vault; + + function setUp() public { + token = new MockERC777Token(); + vault = new VulnerableERC777Vault(token); + targetContract = address(vault); + + address victim = makeAddr("victim"); + token.mint(victim, 300 ether); + vm.prank(victim); + token.send(address(vault), 300 ether, ""); + } + + function getERC777Token() internal view override returns (address) { + return address(token); + } + + function getERC777WithdrawCalldata() internal pure override returns (bytes memory) { + return abi.encodeWithSignature("withdraw()"); + } + + function fundERC777Depositor(address depositor, uint256 amount) internal override { + token.mint(depositor, amount); + } + + function performERC777Deposit(address depositor, uint256 amount) internal override { + vm.prank(depositor); + token.send(address(vault), amount, ""); + } + + function test_erc777_tokens_received_reentrancy() public override {} + + function runERC777ReentrancyCheck() external { + super.test_erc777_tokens_received_reentrancy(); + } +} + +contract SafeERC777Audit is ERC777ReentrancyCheck { + MockERC777Token token; + SafeERC777Vault vault; + + function setUp() public { + token = new MockERC777Token(); + vault = new SafeERC777Vault(token); + targetContract = address(vault); + + address victim = makeAddr("victim"); + token.mint(victim, 300 ether); + vm.prank(victim); + token.send(address(vault), 300 ether, ""); + } + + function getERC777Token() internal view override returns (address) { + return address(token); + } + + function getERC777WithdrawCalldata() internal pure override returns (bytes memory) { + return abi.encodeWithSignature("withdraw()"); + } + + function fundERC777Depositor(address depositor, uint256 amount) internal override { + token.mint(depositor, amount); + } + + function performERC777Deposit(address depositor, uint256 amount) internal override { + vm.prank(depositor); + token.send(address(vault), amount, ""); + } +} + +contract ERC777ReentrancyTemplateTest is Test { + function test_check_passes_for_safe_erc777_vault() public { + SafeERC777Audit audit = new SafeERC777Audit(); + audit.setUp(); + audit.test_erc777_tokens_received_reentrancy(); + } + + function test_check_flags_vulnerable_erc777_vault() public { + VulnerableERC777Audit audit = new VulnerableERC777Audit(); + audit.setUp(); + + (bool success,) = address(audit).call(abi.encodeWithSignature("runERC777ReentrancyCheck()")); + + assertFalse(success, "ERC777 reentrancy check should fail on vulnerable accounting"); + } +}