ERC-777 was designed to improve ERC-20. It added operator permissions, richer transfer semantics, and hooks: tokensReceived and tokensToSend callbacks that fire when tokens are received or sent. It looked like a clean upgrade.
Instead, ERC-777 introduced one of the most dangerous reentrancy attack surfaces in DeFi. The $25 million Uniswap and Lendf.Me exploit in April 2020 used an ERC-777 hook to reenter a lending protocol that believed it was handling a plain ERC-20 token. The contract was audited. The reentrancy guard existed. The attack still worked — because no one anticipated that the token would call back into the protocol mid-transfer.
Six vulnerability patterns follow, each with vulnerable Solidity, an explanation, a fix, and detection guidance.
1. tokensReceived Hook Reentrancy
The most direct ERC-777 attack mirrors classic reentrancy. When tokens are sent, ERC-777 calls tokensReceived on the recipient's registered hook before the transfer completes. If the contract updates balances after the transfer, the hook fires with stale state.
// VULNERABLE: balance credited after hook fires
contract VulnerableVault {
mapping(address => uint256) public balances;
IERC777 public token;
function deposit(uint256 amount) external {
// token.send triggers tokensReceived on this contract
token.operatorSend(msg.sender, address(this), amount, "", "");
// Balance updated AFTER the external call — hook already fired
balances[msg.sender] += amount;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient");
balances[msg.sender] -= amount;
token.send(msg.sender, amount, "");
}
// Called by ERC-777 mid-transfer — before deposit() credits the balance
function tokensReceived(
address operator,
address from,
address to,
uint256 amount,
bytes calldata userData,
bytes calldata operatorData
) external {
// Hook calls withdraw() — prior balance is valid, second deposit completes after
}
}
The attack: attacker deposits once legitimately, then deposits again. When tokensReceived fires mid-transfer on the second deposit, the hook calls withdraw(). The vault pays out the first deposit's balance. The second deposit then completes, crediting the balance again — double the tokens for the cost of one.
// FIXED: Checks-Effects-Interactions — credit balance before external call
contract SecureVault {
mapping(address => uint256) public balances;
IERC777 public token;
mapping(address => bool) private _inDeposit;
function deposit(uint256 amount) external {
require(!_inDeposit[msg.sender], "reentrant deposit");
_inDeposit[msg.sender] = true;
// Effects first
balances[msg.sender] += amount;
// Interaction after state update
token.operatorSend(msg.sender, address(this), amount, "", "");
_inDeposit[msg.sender] = false;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient");
balances[msg.sender] -= amount;
token.send(msg.sender, amount, "");
}
}
Detection tips: Flag any function that calls token.send(), token.operatorSend(), or IERC777.transfer() and then updates balances[msg.sender] afterward. Slither's reentrancy detector catches some cases; ContractScan's call graph analysis traces the hook callback path explicitly.
2. tokensToSend Hook for Sender Manipulation
ERC-777 also calls tokensToSend on the sender's registered hook before tokens leave their account. An attacker who controls the sender can register a hook that reverts selectively, griefs specific transfers, or exploits the execution context to drain state.
// VULNERABLE: tokensToSend hook fires mid-withdrawal logic
contract VulnerableLending {
mapping(address => uint256) public debt;
mapping(address => uint256) public collateral;
IERC777 public lendingToken;
function repay(uint256 amount) external {
require(debt[msg.sender] >= amount, "excess repayment");
// tokensToSend fires on msg.sender's hook HERE
// before debt is cleared
lendingToken.transferFrom(msg.sender, address(this), amount);
// Debt cleared after the external call
debt[msg.sender] -= amount;
}
function withdrawCollateral(uint256 amount) external {
require(debt[msg.sender] == 0, "outstanding debt");
collateral[msg.sender] -= amount;
// Transfer collateral to msg.sender
}
}
An attacker registers a tokensToSend hook that calls withdrawCollateral() during the repay() transfer. At that moment debt[msg.sender] is still non-zero, but withdrawCollateral() only checks the collateral mapping — so the attacker pulls collateral while debt remains outstanding.
// FIXED: clear debt before the external transfer
contract SecureLending {
mapping(address => uint256) public debt;
mapping(address => uint256) public collateral;
IERC777 public lendingToken;
uint256 private _lock;
modifier nonReentrant() {
require(_lock == 0, "reentrant");
_lock = 1;
_;
_lock = 0;
}
function repay(uint256 amount) external nonReentrant {
require(debt[msg.sender] >= amount, "excess repayment");
// Effects before interaction
debt[msg.sender] -= amount;
// tokensToSend fires here — debt is already cleared
lendingToken.transferFrom(msg.sender, address(this), amount);
}
function withdrawCollateral(uint256 amount) external nonReentrant {
require(debt[msg.sender] == 0, "outstanding debt");
collateral[msg.sender] -= amount;
}
}
Detection tips: Any transferFrom call on an ERC-777-compatible token where the caller controls the sender address is a tokensToSend hook entry point. Check whether state changes happen before or after that call. Grep for transferFrom calls followed by state writes on the same mapping key.
3. ERC-20 Compatibility Gap
ERC-777 is backward-compatible with ERC-20 at the interface level. A contract that defends against ERC-20 reentrancy may be fully vulnerable when the same token address implements ERC-777, because transfer() and transferFrom() still fire hooks.
// VULNERABLE: designed for ERC-20, silently used with ERC-777
contract ERC20SafeVault {
using SafeERC20 for IERC20;
mapping(address => uint256) public shares;
IERC20 public token; // Could be ERC-777 at this address
function deposit(uint256 amount) external {
// SafeERC20.safeTransferFrom — but if token is ERC-777,
// this fires tokensToSend on sender and tokensReceived on this contract
token.safeTransferFrom(msg.sender, address(this), amount);
uint256 newShares = _calculateShares(amount);
shares[msg.sender] += newShares;
}
function withdraw(uint256 shareAmount) external {
require(shares[msg.sender] >= shareAmount, "not enough shares");
uint256 tokenAmount = _calculateTokens(shareAmount);
shares[msg.sender] -= shareAmount;
token.safeTransfer(msg.sender, tokenAmount);
}
}
The vault uses SafeERC20 and follows CEI on withdraw() — but deposit() updates shares after the transfer. If the token is ERC-777, tokensReceived fires before shares[msg.sender] += newShares. An attacker's hook calls withdraw() mid-deposit against shares that are still valid from a prior session.
// FIXED: detect ERC-777 and block hook calls; or update state first
contract DefensiveVault {
using SafeERC20 for IERC20;
mapping(address => uint256) public shares;
IERC20 public token;
uint256 private _lock;
modifier nonReentrant() {
require(_lock == 0, "reentrant");
_lock = 1;
_;
_lock = 0;
}
function deposit(uint256 amount) external nonReentrant {
uint256 newShares = _calculateShares(amount);
// Effects first, always — regardless of token standard
shares[msg.sender] += newShares;
token.safeTransferFrom(msg.sender, address(this), amount);
}
function withdraw(uint256 shareAmount) external nonReentrant {
require(shares[msg.sender] >= shareAmount, "not enough shares");
uint256 tokenAmount = _calculateTokens(shareAmount);
shares[msg.sender] -= shareAmount;
token.safeTransfer(msg.sender, tokenAmount);
}
// Reject unexpected hook calls
function tokensReceived(
address, address, address, uint256, bytes calldata, bytes calldata
) external {
require(_lock == 1, "unexpected hook");
}
}
Detection tips: Search your codebase for IERC20 parameters that are passed externally by users. Any token address a user supplies could be ERC-777. ContractScan flags token interfaces without ERC-777 hook defenses when the token address is not hardcoded.
4. Hook Registration Bypass via ERC1820
ERC-777 hooks are registered through the ERC1820 registry. Some protocols assume: "if I didn't register a hook, no hook fires." That is wrong for senders — and dangerous for anyone relying on this assumption for security decisions.
// VULNERABLE: assumes hook won't fire because this contract didn't register one
contract NaivePool {
IERC777 public token;
mapping(address => uint256) public balances;
function addLiquidity(uint256 amount) external {
// Developer assumes: we didn't register tokensReceived,
// so no hook fires, so this is safe like a plain ERC-20 transfer.
token.send(address(this), amount, "");
balances[msg.sender] += amount;
}
}
The reasoning is half-right: this contract won't receive a tokensReceived callback because it didn't register one. But the sender's tokensToSend hook fires unconditionally — any sender with a registered hook can execute arbitrary code mid-transfer. The assumption creates a false sense of safety.
// FIXED: never assume hook absence; always apply CEI and nonReentrant
contract SecurePool {
IERC777 public token;
mapping(address => uint256) public balances;
uint256 private _lock;
IERC1820Registry private constant _ERC1820 =
IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);
modifier nonReentrant() {
require(_lock == 0, "reentrant");
_lock = 1;
_;
_lock = 0;
}
function addLiquidity(uint256 amount) external nonReentrant {
// Check if sender has a tokensToSend hook registered
address senderHook = _ERC1820.getInterfaceImplementer(
msg.sender, keccak256("ERC777TokensSender")
);
// Either reject hook-enabled senders, or proceed with full CEI
// Here we proceed safely with effects-first ordering
balances[msg.sender] += amount;
token.send(address(this), amount, "");
}
}
Detection tips: Search for IERC777 usage without corresponding ERC1820 registry checks. Evaluate whether the codebase explicitly validates or rejects addresses with registered hooks when security-critical transfers occur. ContractScan flags ERC-777 transfers that lack sender hook verification.
5. Cross-Function Reentrancy via Hooks
A hook does not have to reenter the same function it was called from. Cross-function reentrancy through ERC-777 hooks is more dangerous because nonReentrant on a single function does not protect sibling functions. The hook can call withdraw(), borrow(), or any other function that shares state with the interrupted operation.
// VULNERABLE: nonReentrant on deposit, but withdraw is unguarded
contract CrossFunctionVuln {
mapping(address => uint256) public deposited;
mapping(address => uint256) public rewards;
IERC777 public token;
uint256 private _lock;
modifier nonReentrant() {
require(_lock == 0, "reentrant");
_lock = 1;
_;
_lock = 0;
}
// Protected — but only this function
function deposit(uint256 amount) external nonReentrant {
token.operatorSend(msg.sender, address(this), amount, "", "");
deposited[msg.sender] += amount;
rewards[msg.sender] = deposited[msg.sender] / 10;
}
// NOT protected — hook can enter claimRewards() while deposit() runs
function claimRewards() external {
uint256 reward = rewards[msg.sender];
require(reward > 0, "no rewards");
rewards[msg.sender] = 0;
token.send(msg.sender, reward, "");
}
}
During deposit(), tokensReceived fires before state is updated. rewards[msg.sender] still reflects the previous deposit. The attacker's hook calls claimRewards(), which has no guard, pays out the old reward, and zeroes it. When deposit() resumes it sets rewards again — double-collecting the reward.
// FIXED: shared lock across all functions that touch related state
contract SecureCrossFunction {
mapping(address => uint256) public deposited;
mapping(address => uint256) public rewards;
IERC777 public token;
uint256 private _lock;
modifier nonReentrant() {
require(_lock == 0, "reentrant");
_lock = 1;
_;
_lock = 0;
}
function deposit(uint256 amount) external nonReentrant {
// Effects before interaction
deposited[msg.sender] += amount;
rewards[msg.sender] = deposited[msg.sender] / 10;
// Interaction last
token.operatorSend(msg.sender, address(this), amount, "", "");
}
// Guarded with the same lock — hook cannot enter during deposit()
function claimRewards() external nonReentrant {
uint256 reward = rewards[msg.sender];
require(reward > 0, "no rewards");
rewards[msg.sender] = 0;
token.send(msg.sender, reward, "");
}
}
Detection tips: Map all functions that share state variables. If any of those functions lacks a nonReentrant guard and any sibling function makes an ERC-777 call, the pair is vulnerable. ContractScan's cross-function reentrancy analysis builds a state-dependency graph and flags unguarded siblings.
6. Operator Hook Griefing
ERC-777 allows authorized operators to send tokens on behalf of a holder. When an operator triggers a send, both hooks fire. A malicious operator — or a protocol that grants operator rights broadly — can weaponize hooks to DoS a protocol or force attacker-controlled logic inside a legitimate transfer.
// VULNERABLE: operator-triggered transfer DoS through hook revert
contract OperatorVulnerable {
IERC777 public token;
mapping(address => bool) public operators;
function grantOperator(address op) external {
// Users grant operator rights to this contract or others
token.authorizeOperator(op);
operators[op] = true;
}
// Protocol calls this to move tokens on behalf of users
function protocolTransfer(
address from,
address to,
uint256 amount
) external {
require(operators[msg.sender], "not operator");
// If `from` has registered a malicious tokensToSend hook,
// the hook can revert here, blocking ALL transfers from that user.
// This can brick liquidations, repayments, or forced settlements.
token.operatorSend(from, to, amount, "", "");
}
}
A borrower facing liquidation registers a tokensToSend hook that reverts when the caller is the liquidation contract. Every liquidation attempt reverts. The borrower avoids liquidation indefinitely, accumulating bad debt.
// FIXED: validate hook behavior; use try/catch or remove hook dependency
contract SecureOperator {
IERC777 public token;
IERC1820Registry private constant _ERC1820 =
IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);
function protocolTransfer(
address from,
address to,
uint256 amount
) external {
// Check for registered tokensToSend hook before attempting transfer
address senderHook = _ERC1820.getInterfaceImplementer(
from, keccak256("ERC777TokensSender")
);
if (senderHook != address(0)) {
// Reject hook-enabled senders in critical paths like liquidation
revert("sender hook registered: transfer blocked");
}
token.operatorSend(from, to, amount, "", "");
}
}
Detection tips: Look for operatorSend calls in liquidation, settlement, or forced-transfer code paths. If the from address is user-controlled and unvalidated against the ERC1820 registry, the protocol is griefable. ContractScan flags operator send calls in liquidation logic without hook registration checks.
Audit Your ERC-777 Exposure
ERC-777 tokens are still live in production — imBTC, the token behind the 2020 exploit, was ERC-777. Any protocol that accepts arbitrary token addresses carries this risk silently.
The six patterns above share one root cause: external calls to ERC-777 tokens execute arbitrary code. Treating them like passive ERC-20 transfers is the mistake every exploit in this category exploits.
Scan your contracts at contractscan.io. ContractScan traces hook callback paths, validates ERC1820 registry checks, and flags cross-function reentrancy across shared state — including cases where nonReentrant exists on only one function in a vulnerable pair.
Related Posts
- Token Approval Security: Infinite Allowances and How to Avoid Them
- ERC-20 Missing Return Values and the safeTransfer Pattern
This post is for educational purposes only. It does not constitute financial, legal, or investment advice. Always conduct a professional audit before deploying smart contracts to production.
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.