一、血的教训:历史上的重大合约漏洞
1.1 The DAO事件(2016)
2016年6月,The DAO合约被攻击,损失360万个ETH(当时价值约6000万美元)。攻击利用的就是重入漏洞——攻击者通过递归调用withdraw函数,在余额更新前反复提取资金。
这次事件直接导致了以太坊的硬分叉,诞生了以太坊经典(ETC)。它给整个行业的教训是: Checks-Effects-Interactions模式必须成为每个Solidity开发者的肌肉记忆。
1.2 Poly Network(2021)
Poly Network是跨链协议,在2021年8月被攻击者利用跨链验证漏洞盗走6.11亿美元。这是DeFi历史上最大的一次黑客攻击。
攻击者发现Poly Network的跨链消息验证存在漏洞,可以伪造”来自另一条链”的消息,从而在目标链上解锁任意资产。漏洞的本质是:信任了不应该信任的数据源。
1.3 为什么要了解这些案例
学习历史漏洞不是为了记住故事,而是为了理解问题的本质。很多漏洞在原理上很简单,但在大型系统中却容易被忽视。作为开发者,我们需要培养识别这些问题的意识。

二、重入攻击:最经典的合约漏洞
2.1 攻击原理
重入攻击发生在合约调用外部地址时——如果被调用的合约是恶意的,它可以在回调中再次调用原合约的函数,利用未更新的状态进行多次操作。
solidity
// 存在重入漏洞的合约
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// 有漏洞的提款函数
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
// 问题在这里:先发送ETH,再更新余额
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// 余额更新太晚,攻击者可以在此之前再次调用
balances[msg.sender] -= amount;
}
}
攻击者可以部署一个恶意合约来循环调用withdraw:
solidity
// 攻击合约
contract Attacker {
VulnerableBank public bank;
uint256 public constant AMOUNT = 1 ether;
constructor(address _bank) {
bank = VulnerableBank(_bank);
}
function attack() external payable {
require(msg.value >= AMOUNT);
bank.deposit{value: AMOUNT}();
bank.withdraw(AMOUNT);
}
// 接收ETH的回调函数
receive() external payable {
if (address(bank).balance >= AMOUNT) {
bank.withdraw(AMOUNT);
}
}
}
2.2 防御策略
方法一:Checks-Effects-Interactions模式
solidity
function withdraw(uint256 amount) external {
// 1. 检查条件
require(balances[msg.sender] >= amount, "Insufficient balance");
// 2. 更新状态(在外部调用之前)
balances[msg.sender] -= amount;
// 3. 交互(最后执行)
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
方法二:使用ReentrancyGuard
solidity
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
}
方法三:使用Push而非Pull模式
不直接转账,而是让用户来”取”:
solidity
mapping(address => uint256) public pendingWithdrawals;
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingWithdrawals[msg.sender] = 0; // 先清零
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
三、整数溢出与下溢
3.1 Solidity 0.8之前的漏洞
在Solidity 0.8之前,整数运算不会自动检查溢出。uint256最大是2^256-1,加1会变成0,减1会变成一个巨大的数。
solidity
// Solidity 0.7.x - 存在溢出漏洞
function unsafeAdd(uint256 a, uint256 b) public pure returns (uint256) {
return a + b; // 可能无声地溢出
}
function unsafeSub(uint256 a, uint256 b) public pure returns (uint256) {
return a - b; // b > a时会下溢
}
3.2 Solidity 0.8+的自动检查
Solidity 0.8之后,数学运算默认会检查溢出,超出范围时自动revert。这解决了最大的安全风险之一。
但有时候我们确实需要忽略溢出检查(性能原因),可以使用unchecked:
solidity
function safeAdd(uint256 a, uint256 b) public pure returns (uint256) {
unchecked {
return a + b; // 如果溢出,结果会截断
}
}
使用unchecked时要确保你知道自己在做什么,并且有注释说明为什么可以安全地忽略检查。
3.3 精度问题导致的漏洞
DeFi合约中,精度问题经常导致资金损失。典型问题是计算结果向下取整导致的灰尘余额累积:
solidity
// 有精度损失的兑换函数
function exchange(uint256 amountIn, uint256 amountOutMin) external {
uint256 rate = getExchangeRate(); // 假设是 3.5 (代币A/代币B)
uint256 amountOut = amountIn / rate; // 3 / 3.5 = 0,整数除法
require(amountOut >= amountOutMin, "Slippage exceeded");
// 用户损失了本应得到的0.14个代币A
}
// 更好的做法:先乘后除,或使用更高精度
function betterExchange(uint256 amountIn, uint256 amountOutMin) external {
uint256 rate = getExchangeRate(); // 存储为 3500000000000000000 (3.5 * 10^18)
uint256 amountOut = (amountIn * 10**18) / rate; // 3 * 10^18 / 3.5 = 857142857142857142
require(amountOut >= amountOutMin, "Slippage exceeded");
}
四、访问控制漏洞
4.1 缺失的权限检查
这是第二大常见的漏洞类型。很多合约函数应该只有特定角色可以调用,但忘记加了modifier:
solidity
// 有漏洞的合约
contract Token {
mapping(address => uint256) public balances;
// 任何人都可以调用这个函数铸造代币!
function mint(address to, uint256 amount) external {
balances[to] += amount;
}
}
// 正确实现
contract SecureToken {
mapping(address => uint256) public balances;
address public owner;
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
constructor() {
owner = msg.sender;
}
function mint(address to, uint256 amount) external onlyOwner {
balances[to] += amount;
}
}
4.2 Ownable的陷阱
使用OpenZeppelin的Ownable虽然方便,但有几个常见问题:
solidity
// 问题1:owner可以是合约,但合约可能没有receive函数
// 如果owner是空合约地址,代币会锁死在那里
// 问题2:owner可以是零地址
// zero-address ownership会导致不可治理
// 问题3:单点故障
// 如果owner私钥丢失,整个合约都无法升级
// 正确做法:使用多签或DAO治理
import "@openzeppelin/contracts/access/AccessControl.sol";
contract SecureToken is AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
_mint(to, amount);
}
}
五、预言机操纵
5.1闪电贷攻击原理
DeFi协议经常依赖链上价格预言机,但这些价格可以被操纵。闪电贷让攻击者可以在单笔交易中借用巨额资金,操纵价格后执行套利。
典型攻击模式:
- 借出大量资产
- 在Uniswap上大量买入某个代币,推高价格
- 用虚高的价格在其他协议进行抵押借贷
- 归还闪电贷
- 利润到手
solidity
// 存在预言机操纵风险的代码
function getValue(address token) public view returns (uint256) {
// 直接使用Uniswap池子的即时价格
(uint256 reserve0, uint256 reserve1, ) = IUniswapV2Pair(pair).getReserves();
return reserve1 * 1e18 / reserve0; // 一个池子的价格太容易被操纵
}
// 更安全的做法:使用时间加权平均价格
function getTWAP(address token, uint256 window) public view returns (uint256) {
// Chainlink或Uniswap V3的TWAP
return IAggregatorV3(priceFeed).latestRoundData();
}
5.2 防御措施
- 使用Chainlink、Band Protocol等去中心化预言机
- 实施TWAP而非即时价格
- 设置价格波动阈值,超出范围则暂停操作
- 使用多个数据源取中位数或平均值
六、系统化审计流程
6.1 自动化扫描
在写代码时就应该运行自动化工具:
bash
# Slither静态分析
slither . --contract "MyContract"
# 主要检查项:
# - 重入漏洞
# - 未检查的返回值
# - 整数溢出
# - 可变状态可见性
# - tx.origin使用
bash
# Mythril符号执行
mythril analysis contract.sol
6.2 手动代码审查
自动化工具只能发现已知的漏洞模式。手动审查需要:
- 逐行阅读:理解每个函数的行为
- 追踪数据流:参数从哪里来,经过哪些变换,最终存储在哪里
- 边界条件测试:0、最大值、负数等
- 权限链路分析:每个函数是否正确检查了权限
- 跨合约交互:调用外部合约时是否存在信任假设
6.3 审计检查清单
我整理的审计清单:
访问控制
- 每个external函数都有正确的权限检查吗?
- owner角色的权限是否过于集中?
- 是否存在绕过权限检查的路径?
金融逻辑
- 取款/存款的余额更新是否在外部调用之前?
- 汇率计算是否存在精度损失?
- 是否有可能出现负数或溢出的计算?
跨合约交互
- 调用外部合约后的返回值是否都检查了?
- 是否存在对外部数据的信任假设?
- 回调函数是否会引入新的攻击面?
初始化
- 构造函数是否正确设置了所有者?
- 代理合约是否正确初始化了?
- 是否存在初始化漏洞?
七、使用Foundry进行安全测试
7.1 模糊测试发现边界问题
solidity
// 模糊测试ERC20转账
function testTransferFuzz(address to, uint256 amount) public {
// 只测试有效的地址和金额
vm.assume(to != address(0) && to != address(this));
amount = bound(amount, 1, balanceOf(address(this)));
uint256 senderBefore = balanceOf(address(this));
uint256 recipientBefore = balanceOf(to);
token.transfer(to, amount);
assertEq(balanceOf(address(this)), senderBefore - amount);
assertEq(balanceOf(to), recipientBefore + amount);
}
7.2 不变式测试
定义合约必须始终满足的条件:
solidity
contract InvariantTest is Test {
MyContract public target;
function setUp() public {
target = new MyContract();
// 设置处理程序,fuzzer会调用它
bytes4[] memory selectors = new bytes4[](1);
selectors[0] = MyContract.deposit.selector;
target.setFuzzSelector(FuzzSelector({
target: address(target),
selectors: selectors
}));
}
// 不变式1:合约余额应该等于所有用户余额之和
function invariantBalanceAccounting() public view {
assertEq(address(target).balance, target.totalDeposits());
}
// 不变式2:不应该出现负余额
function invariantNoNegativeBalance() public view {
// 遍历所有可能的状态...
}
}
八、安全开发的最佳实践
8.1 代码规范
- 使用最新稳定版本的Solidity(0.8.26+)
- 总是使用OpenZeppelin经过审计的合约库
- 避免直接使用assembly,除非绝对必要
- 函数保持简短,一个函数只做一件事
- 变量命名清晰,让代码自解释
8.2 测试覆盖
- 单元测试覆盖每个函数的所有分支
- 集成测试覆盖合约间的交互
- 模糊测试覆盖边界输入
- 不变式测试确保系统级安全属性
8.3 上线前的最后一步
- 在测试网完整运行多次
- 找一个专业审计公司做正式审计
- 设置紧急暂停机制(如果合约支持升级)
- 准备好应急预案——即使审计通过也可能有问题
结语
安全不是可以”完成”的任务,而是持续的过程。我的建议:把安全当作开发的一部分,理解漏洞原理,保持谦逊,建立应急响应机制。
最好的安全策略:假设代码迟早会被攻击,让攻击成本高于收益。
相关阅读:

发表回复