Solidity 重入攻击全景学习文档
基于 RareSkills 文章整理,面向有 Solidity 基础的中级开发者 参考来源:https://rareskills.io/post/where-to-find-solidity-reentrancy-attacks
一、什么是重入攻击(Reentrancy)?
重入攻击是指:合约在执行过程中将控制权交给外部合约,外部合约趁状态尚未更新,**重新进入(re-enter)**原合约的函数,利用不一致的状态获利。
一句话: 你还没改完账本,别人就趁机再来取一次钱。
二、重入攻击发生的根本条件
重入攻击需要同时满足以下条件:
条件 1: 合约向外部地址/合约发送 ETH 或调用外部合约
(控制权转移给外部代码)
条件 2: 状态更新发生在外部调用之后
(先调用,后记账)
条件 3: 攻击者可以在回调中重新调用原合约
(利用未更新的状态再次执行逻辑)
三、在哪里找到重入攻击入口?(Where to Find)
3.1 发送 ETH 的三种方式
// 方式 1: transfer(2300 gas 限制,基本安全但已不推荐)
payable(to).transfer(amount);
// 方式 2: send(2300 gas 限制,基本安全但已不推荐)
bool sent = payable(to).send(amount);
// 方式 3: call(无 gas 限制,最危险!)⚠️
(bool sent, ) = to.call{value: amount}("");
.call{value: ...}("") 是最常见的重入攻击入口,因为它转发所有可用 Gas 给接收方,接收方可以在 receive() 或 fallback() 中执行任意复杂逻辑。
3.2 Token 标准中的回调 Hook
这是 RareSkills 文章的核心重点:很多开发者忽视了 token 回调也是重入入口!
| Token 标准 | 回调函数 | 触发方法 | 重入风险 |
|---|---|---|---|
| ERC-721 | onERC721Received() | safeTransferFrom() | ⚠️ 高 |
| ERC-1155 | onERC1155Received() | safeTransferFrom() | ⚠️ 高 |
| ERC-1155 | onERC1155BatchReceived() | safeBatchTransferFrom() | ⚠️ 高 |
| ERC-777 | tokensReceived() | send() / transfer() | ⚠️ 极高 |
| ERC-1363 | onTransferReceived() | transferAndCall() | ⚠️ 高 |
| ERC-20 | 无回调 | transfer() / transferFrom() | ✅ 低(无回调) |
3.3 ERC-20 为什么相对安全?
// 标准 ERC-20 transfer 不调用任何接收方代码
function transfer(address to, uint256 amount) public returns (bool) {
_balances[msg.sender] -= amount;
_balances[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
// ← 没有外部调用!接收方不知道自己收到了 token
}
但注意: ERC-777 的 transfer() 看起来和 ERC-20 一样,实际会触发 tokensReceived() hook!
3.4 ERC-721/1155 的 safeTransferFrom 流程
function safeTransferFrom(address from, address to, uint256 tokenId) public {
// Step 1: 权限检查
require(isApprovedOrOwner(msg.sender, tokenId));
// Step 2: 转移 token
_transfer(from, to, tokenId);
// Step 3: ⚠️ 如果 to 是合约,调用其 onERC721Received
if (to.code.length > 0) {
require(
IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, "") ==
IERC721Receiver.onERC721Received.selector
);
}
// ← 如果有其他状态还未更新,此处可被攻击者利用
}
3.5 完整攻击入口清单
ETH 转账:
├── address.call{value: ...}("") ← 最常见
├── address.transfer() ← 2300 gas 限制,基本安全
└── address.send() ← 同上
Token 回调:
├── ERC-721 safeTransferFrom → onERC721Received()
├── ERC-721 safeMint → onERC721Received()
├── ERC-1155 safeTransferFrom → onERC1155Received()
├── ERC-1155 safeBatchTransferFrom → onERC1155BatchReceived()
├── ERC-777 send/transfer → tokensReceived()
├── ERC-1363 transferAndCall → onTransferReceived()
└── ERC-1363 approveAndCall → onApprovalReceived()
低级调用:
├── address.call(data) ← 通用调用
└── address.delegatecall(data) ← 代理调用
四、重入攻击的四种类型
4.1 单函数重入(Single-Function Reentrancy)
最经典的形式 —— The DAO Hack(2016年)
// ❌ 有漏洞的合约
contract VulnerableVault {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0);
// ⚠️ 先发钱(控制权转移)
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent);
// 后记账(攻击者在上面的 call 中重入 withdraw,此时余额还没清零)
balances[msg.sender] = 0;
}
}
攻击者合约:
contract Attacker {
VulnerableVault vault;
receive() external payable {
// 在收到 ETH 时重新进入 withdraw
if (address(vault).balance >= 1 ether) {
vault.withdraw(); // 重入!balances 还没被设为 0
}
}
function attack() external payable {
vault.deposit{value: 1 ether}();
vault.withdraw(); // 触发第一次
}
}
4.2 跨函数重入(Cross-Function Reentrancy)
攻击者在回调中不是重入同一个函数,而是调用同一合约的另一个函数。
contract VulnerableToken {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
// ⚠️ 先发 ETH
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent);
// 后更新余额
balances[msg.sender] -= amount;
}
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
攻击: 在 withdraw 的 callback 中调用 transfer,因为 balances[attacker] 还没被扣减,可以把余额”转走”一份再提取一份 → 双倍获利。
4.3 跨合约重入(Cross-Contract Reentrancy)
涉及多个合约之间的状态不一致。
// 合约 A: 负责管理 shares
contract VaultA {
mapping(address => uint256) public shares;
TokenB public token;
function withdraw(uint256 shareAmount) external {
uint256 tokenAmount = shareAmount * token.totalAssets() / totalShares();
shares[msg.sender] -= shareAmount;
// ⚠️ 调用外部合约
token.transfer(msg.sender, tokenAmount);
}
}
// 合约 B: 是一个 ERC-777 token
// 当 transfer 时,tokensReceived() 被触发
// 攻击者在 tokensReceived() 中调用 VaultA 的其他函数
// 此时 VaultA 的 shares 可能已更新,但 token.totalAssets() 还未反映新状态
4.4 只读重入(Read-Only Reentrancy)
最隐蔽的形式 —— 不修改被攻击合约的状态,但利用其 view 函数返回的陈旧值。
contract LiquidityPool {
uint256 public totalDeposits;
function withdraw(uint256 amount) external {
totalDeposits -= amount;
// ⚠️ 此时 totalDeposits 已更新,但 ETH 还没发出
// 如果另一个协议在这期间读取 getPrice()...
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent);
}
// view 函数,其他协议用来获取价格
function getPrice() external view returns (uint256) {
return address(this).balance * 1e18 / totalDeposits;
// ⚠️ balance 还包含即将发出的 ETH,但 totalDeposits 已扣减
// → 价格被虚高!
}
}
攻击流程:
- 攻击者调用
withdraw totalDeposits被扣减- ETH 发送,控制权进入攻击者的
receive() - 攻击者在
receive()中调用另一个协议(如借贷协议) - 该协议调用
pool.getPrice()获取价格 → 得到虚高价格 - 攻击者利用虚高价格获取超额借款
五、防御措施
5.1 Checks-Effects-Interactions 模式(CEI)
// ✅ 安全版本
function withdraw() external {
uint256 amount = balances[msg.sender];
// Checks:检查条件
require(amount > 0, "No balance");
// Effects:更新状态(先记账!)
balances[msg.sender] = 0;
// Interactions:最后才进行外部调用
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Transfer failed");
}
5.2 ReentrancyGuard(互斥锁)
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeVault is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0);
balances[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent);
}
}
原理:
// OpenZeppelin ReentrancyGuard 简化版
uint256 private _status;
uint256 private constant NOT_ENTERED = 1;
uint256 private constant ENTERED = 2;
modifier nonReentrant() {
require(_status != ENTERED, "ReentrancyGuard: reentrant call");
_status = ENTERED;
_;
_status = NOT_ENTERED;
}
5.3 Pull 模式(Pull over Push)
// ✅ 不主动发 ETH,让用户自己来取
contract PullPayment {
mapping(address => uint256) public pendingWithdrawals;
function allowWithdrawal(address user, uint256 amount) internal {
pendingWithdrawals[user] += amount;
}
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0);
pendingWithdrawals[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}
}
5.4 针对只读重入的防御
// 方法 1: 给 view 函数也加锁
modifier nonReentrantView() {
require(_status != ENTERED, "Reentrancy: view during mutation");
_;
}
function getPrice() external view nonReentrantView returns (uint256) {
return address(this).balance * 1e18 / totalDeposits;
}
// 方法 2: 在外部调用前确保所有状态一致
function withdraw(uint256 amount) external nonReentrant {
// ✅ 先更新所有相关状态
totalDeposits -= amount;
// balance 的变化和 totalDeposits 的变化保持一致
// 此时即使有人读 getPrice(),返回值也是一致的
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent);
}
六、各类型重入的防御总结
| 重入类型 | CEI 能防 | ReentrancyGuard 能防 | 特殊措施 |
|---|---|---|---|
| 单函数重入 | ✅ | ✅ | - |
| 跨函数重入 | ⚠️ 需要所有函数都遵循 | ✅ | 所有共享状态的函数都加 nonReentrant |
| 跨合约重入 | ⚠️ 难以保证 | ⚠️ 只保护单个合约 | 全局锁或协议级设计 |
| 只读重入 | ❌ 不够 | ⚠️ 需额外措施 | view 函数加锁 / 状态一致性优先 |
七、真实世界攻击案例
7.1 The DAO Hack(2016)
- 类型: 单函数重入
- 损失: 约 $60M(导致以太坊硬分叉)
- 原因:
withdraw先发 ETH 后更新余额 - 教训: 催生了 Checks-Effects-Interactions 模式
7.2 ERC-777 相关攻击(多起)
- 类型: 跨函数/跨合约重入
- 原因: ERC-777 的
tokensReceivedhook 在每次 transfer 时触发 - 受害者: Uniswap V1(2020)、imBTC 等
- 教训: ERC-777 与未做防护的协议组合极度危险
7.3 只读重入攻击(2024-2026)
- 类型: Read-only reentrancy
- 原因: view 函数在外部调用窗口期返回不一致状态
- 受害者: 多个 DeFi 借贷协议
- 教训: view 函数也需要重入保护
八、代码审计检查清单
□ 是否有 .call{value: ...}("") ?
□ 是否使用了 ERC-721/1155 的 safeTransferFrom ?
□ 是否使用了 ERC-777 token ?
□ 是否使用了 ERC-1363 的 transferAndCall ?
□ 外部调用前状态是否已完全更新?(CEI)
□ 关键函数是否加了 nonReentrant ?
□ 跨函数是否共享可被利用的状态?
□ view 函数在外部调用窗口期是否返回一致值?
□ 是否有跨合约的状态依赖?
九、关键概念回顾
- 控制权转移 = 重入风险 — 任何外部调用都可能被利用
- ERC-20 相对安全 — 无回调机制,不转移控制权
- safeTransfer 类方法有回调 — ERC-721/1155/777/1363 都是攻击面
- 四种重入类型 — 单函数、跨函数、跨合约、只读
- CEI 模式是基础 — 先改状态,后调外部
- ReentrancyGuard 是保险 — 即使忘了 CEI 也能防住
- 只读重入最隐蔽 — view 函数也可能被利用
- 不要假设 transfer/send 安全 — 虽然 2300 gas 限制基本安全,但建议统一用 CEI + Guard
文档整理日期:2026-05-31 参考来源:RareSkills - Where to Find Solidity Reentrancy Attacks