Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
82 changes: 82 additions & 0 deletions src/checks/ERC777ReentrancyCheck.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
181 changes: 181 additions & 0 deletions test/ERC777Reentrancy.t.sol
Original file line number Diff line number Diff line change
@@ -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");
}
}