以太坊智能合约常见安全漏洞有哪些?重入攻击怎么防?
以太坊智能合约部署后不可修改,安全漏洞直接等于资金损失。最经典的例子是 2016 年 The DAO 事件——重入攻击一次就卷走 6000 万美元,直接导致以太坊硬分叉。2025 年的数据显示,重入攻击造成的损失仍高达 3570 万美元,说明这个老漏洞至今没被完全堵住。
下面按"最致命 → 最容易被忽略"的顺序,逐个讲清楚。
重入攻击(Reentrancy)
一句话:合约在更新状态之前就把 ETH 发出去了,攻击者利用 fallback 函数递归调用,反复提款。
漏洞代码:
solidityfunction withdraw(uint256 amount) public { require(balances[msg.sender] >= amount); (bool success, ) = msg.sender.call{value: amount}(""); require(success); balances[msg.sender] -= amount; // 状态更新在转账之后——致命 }
攻击合约的 receive() 函数里再次调用 withdraw(),此时 balances 还没扣减,检查照过,循环提款直到合约余额归零。
修复:检查-效果-交互模式——先扣余额,再转账:
solidityfunction withdraw(uint256 amount) public { require(balances[msg.sender] >= amount); balances[msg.sender] -= amount; // 先更新状态 (bool success, ) = msg.sender.call{value: amount}(""); require(success); }
或者直接上 OpenZeppelin 的 ReentrancyGuard,用互斥锁防止重入。
整数溢出/下溢
Solidity 0.8.0 之前,uint8 类型的 255 加 1 会变成 0。0.8.0 起内置了自动检查,溢出会 revert。如果你在维护老版本合约,用 SafeMath 库;新合约直接用 0.8.0+,不需要额外处理。
访问控制缺失
最容易被忽略的漏洞。函数没加权限修饰符,任何人都能调用:
solidityfunction mint(address to, uint256 amount) public { balanceOf[to] += amount; // 谁都能铸币 }
加上 onlyOwner 修饰符,或者用 OpenZeppelin 的 Ownable、AccessControl。
前置交易(Front-Running)
攻击者在 mempool 里看到你的交易,出更高 gas 抢先执行。比如你提交了一笔大额 DEX 交易,攻击者先买入再等你成交后卖出,吃差价。
防御手段:提交-揭示模式(commit-reveal),先把哈希提交上链,再揭示真实数据;或者用 Flashbots 等私有交易服务,绕过公开 mempool。
默认可见性
Solidity 中函数不写可见性修饰符默认是 public。一个本应内部调用的函数暴露出去,可能被攻击者直接调用绕过逻辑。所有函数都必须显式声明可见性,编译器 0.5.0+ 已经强制要求了。
追问
重入攻击除了转账场景,还有哪些变体?
跨合约重入——攻击者不是回调同一个函数,而是调用合约的其他函数,利用状态不一致。还有只读重入(Read-Only Reentrancy),view 函数在重入期间返回过时数据,误导其他协议的预言机或价格计算。2025 年已有多个 DeFi 协议因此被攻击。
OpenZeppelin 的 ReentrancyGuard 和手动写检查-效果-交互,该用哪个?
都加上。ReentrancyGuard 是兜底,检查-效果-交互是根本。Guard 防的是你漏掉的场景,但不能替代正确的代码逻辑。两者不冲突。
实际项目中,安全审计流程是怎样的?
先跑 Slither 做静态分析,再用 Foundry 写 Fuzz 测试覆盖边界情况,然后找专业审计公司(如 Trail of Bits、OpenZeppelin)做人工审计,最后在 Immunefi 上开漏洞赏金。上线后持续监控异常交易。
Solidity 0.8.0 之后还有整数安全问题吗?
大部分溢出被自动检查覆盖了,但 unchecked {} 块内仍然可以溢出——这是刻意设计的,用于 gas 优化。如果不小心把关键逻辑放进 unchecked 块,一样会出问题。另外,类型转换(如 uint256 转 uint128)不会自动检查溢出,需要用 SafeCast。
写段代码
solidityimport "@openzeppelin/contracts/security/ReentrancyGuard.sol"; contract Vault is ReentrancyGuard { mapping(address => uint256) public balances; function withdraw(uint256 amount) external nonReentrant { require(balances[msg.sender] >= amount, "insufficient"); balances[msg.sender] -= amount; (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "transfer failed"); } }