智能合约一旦部署就不可改变,漏洞意味着直接的经济损失。截至 2024 年,DeFi 协议因智能合约漏洞损失超过 $50 亿。这一节拆解四大经典攻击的机理与防御。
12.3.1 重入攻击(Reentrancy):TheDAO 的 3.6 亿美元遗产
攻击原理
solidity
// 有漏洞的版本
contract VulnerableVault {
mapping(address => uint256) public balances;
function withdraw() public {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
(bool ok, ) = msg.sender.call{value: amount}(""); // 外部调用 -> 攻击者回调
require(ok, "transfer failed");
balances[msg.sender] = 0; // 状态更新在转账之后!
}
}
// 攻击合约
contract Attacker {
VulnerableVault target;
function attack() external {
target.withdraw(); // 触发第一次,目标会在 fallback 前调用这里
}
fallback() external payable {
if (address(target).balance > 0) {
target.withdraw(); // 再次调用,balance 还没被扣减!
}
}
}数学建模
攻击者可以在每次重入中重复提取余额 ,直到合约被榨干:
其中 是重入次数,受限于:
- 调用栈深度(EVM 限制 1024)
- 合约余额
- 每次调用的 gas 可用量
防御方案
| 方法 | 实现 | 局限 |
|---|---|---|
| Checks-Effects-Interactions | 先更新状态,再外部调用 | 需要审查所有代码路径 |
| 重入锁(mutex) | bool locked + require(!locked) | 增加单线程阻塞,Gas 略增 |
| Pull 而非 Push | 用户 pull 资金,而非合约 push | 用户体验稍差 |
typescript
/**
* 重入攻击的数学模型:状态更新的不同顺序导致的差异
*/
function simulateReentrancy(
initialBalance: bigint,
hackerDeposits: bigint,
safe: boolean, // 如果 true,状态先更新
): { victimDrained: bigint; hackerWins: bigint } {
let contractBalance = initialBalance + hackerDeposits;
let hackerBalance = 0n; // 攻击者在目标合约中"看起来"的余额
// 攻击者先存入
// 然后触发 withdraw
if (safe) {
// CEI 模式:先扣减余额,再发钱
const claim = hackerDeposits;
hackerBalance = 0n; // 扣减!
contractBalance -= claim;
// 外部调用...
return { victimDrained: 0n, hackerWins: claim };
} else {
// 攻击路径:先外部调用,再扣减
let drained = 0n;
let iterations = 0;
while (contractBalance > 0 && iterations < 10) {
const amount = hackerDeposits; // 扣减前余额仍为 hackerDeposits
if (contractBalance >= amount) {
drained += amount;
contractBalance -= amount;
iterations++;
} else { break; }
// 注意:在目标中,只有在最后才设置 hackerBalance = 0
}
return { victimDrained: drained - hackerDeposits, hackerWins: drained };
}
}
// 模拟:攻击者存 1 ETH,合约有 10 ETH
const result = simulateReentrancy(10n, 1n, false);
console.log(`重入攻击: 攻击者投入 1 ETH,最终到手 ${result.hackerWins} ETH`);
console.log(`受害者损失: ${result.victimDrained} ETH`);
// 如果未加防护,攻击者可能榨干全部合约余额12.3.2 闪电贷攻击:无限资本的瞬间
核心机制
闪电贷允许用户无需任何抵押,在单个交易中借出任意金额,只要在同一个区块的调用结束时偿还 + 手续费。这意味着攻击者拥有瞬时无限资本。
价格操纵闪电贷
典型链条:
- 从 Aave/AAVE 闪电贷 10,000 ETH
- 在 DEX(如 Uniswap V2)用 10,000 ETH 买 Token X(推高价格)
- 用少量 ETH 作为抵押在借贷协议(如 Compound)以虚高价格借出其他资产
- 在 Uniswap 卖出 Token X(恢复价格)
- 偿还闪电贷
sequenceDiagram
participant A as 攻击者
participant L as 闪电贷池
participant D1 as Uniswap
participant M as 价格预言机依赖的合约
A->>L: 借 10K ETH (0 抵押)
A->>D1: 10K ETH → 买 TokenX<br/>(价格被推高 100x)
A->>M: 用少量 TokenX 抵押借出 500 ETH (按虚假价格)
A->>D1: 卖 TokenX (价格恢复)
A->>L: 归还 10K + 手续费
Note over M: 价格预言机未使用 TWAP<br/>被瞬间操纵
Note over A: 净赚: 500 - fee (0.09%)<br/>→ 约 499.1 ETH
防御
| 方案 | 原理 |
|---|---|
| TWAP / Chainlink | 时间加权平均价格,对单个区块的操纵不敏感 |
| 跨区块读取锁定 | 同一交易者不能在 N 个区块内连续做大额交易 |
| 价值边界检查 | 借贷/清算时,要求多个独立价格源对齐 |
12.3.3 抢跑攻击(MEV):暗池中的拍卖
交易内存池(Mempool)的透明性
在交易被打包进区块之前,它暂存于内存池,对全网可见(默认)。攻击者可以:
- Front-running:复制你的交易,付更高 gas,抢在你之前执行
- Sandwich attack:在你大额买之前先买,你买之后立刻卖,赚差价
- Back-running:在你交易后立刻利用其结果(如清算)
提取价值公式
对于 AMM 上的 DEX 交易:
其中 是被抢跑者注入的量。
防御
| 方案 | 效果 | 代价 |
|---|---|---|
| 隐私内存池(Flashbots) | 交易不广播到全网,直接发送给矿工/验证者 | 信任矿工不抢跑 |
| 时间锁 | 延迟执行,让价格变动冷却 | 延迟体验 |
| 批量随机化 | 将大单拆为随机小单 | 轻微额外滑点 |
| 提交-揭示(Commit-Reveal) | 先提交哈希,后揭示内容 | 两步交互,体验差 |
typescript
/**
* 抢跑攻击的数学模型:AMM 上的三明治攻击
* 恒定乘积做市商: x * y = k
*/
class ConstantProductAMM {
private x: bigint; // 资产 X 储备
private y: bigint; // 资产 Y 储备
private fee = 30n; // 0.3% = 30 basis points
private feeDen = 10000n;
constructor(reserveX: bigint, reserveY: bigint) {
this.x = reserveX; this.y = reserveY;
}
// 给定 xIn,计算 yOut(含手续费)
getAmountOut(xIn: bigint): bigint {
const xInFee = (xIn * this.fee) / this.feeDen; // 0.3% 手续费给 LP
const xInNet = xIn - xInFee;
const yOut = (this.y * xInNet) / (this.x + xInNet);
return yOut;
}
swap(xIn: bigint): bigint {
const yOut = this.getAmountOut(xIn);
this.x += xIn;
this.y -= yOut;
return yOut;
}
}
// 三明治攻击模拟
function simulateSandwich(
amm: ConstantProductAMM,
victimAmount: bigint,
attackerAmount: bigint,
): { attackerProfit: bigint; victimSlippage: number } {
// 攻击前状态
const x0 = 10000n; const y0 = 10000n; // 1:1 价格
const amm = new ConstantProductAMM(x0, y0);
// 正常情况(无攻击)
const normalOut = amm.getAmountOut(victimAmount);
// 重置 + 攻击
const amm2 = new ConstantProductAMM(x0, y0);
const attackerIn = attackerAmount;
const attackerOut1 = amm2.swap(attackerIn); // 攻击者先买 → 推高 Y 的价格
const victimOut = amm2.swap(victimAmount); // 受害者以更高价格买
const attackerOut2 = amm2.swap(attackerOut1); // 攻击者回卖,赚差价
const attackerProfit = attackerOut2 - attackerIn;
const slippageLoss = Number(normalOut - victimOut) / Number(normalOut);
return { attackerProfit, victimSlippage: slippageLoss };
}
const result = simulateSandwich(
new ConstantProductAMM(10000n, 10000n),
100n, // 受害者买 100 X
50n, // 攻击者用 50 X 做三明治
);
console.log(`攻击者利润: ${result.attackerProfit} Y(手续费后的净赚)`);
console.log(`受害者额外滑点损失: ${(result.victimSlippage * 100).toFixed(2)}%`);12.3.4 整数溢出(Solidity 0.7.x 之前)
solidity
// Solidity 0.7.x 之前
uint8 a = 255;
uint8 b = 1;
uint8 c = a + b; // c = 0! (溢出绕回)
// 0.8.0 之后自动 revert,但 assembly 中仍然需要手动检查12.3.5 知识地图
mindmap
root((智能合约漏洞))
重入攻击
CEI 模式防御
ReentrancyGuard
拉取优于推送
闪电贷
无限瞬时资本
价格预言机操纵
TWAP 防御
抢跑攻击
MEV 提取
三明治攻击
Flashbots / 提交-揭示
整数溢出
0.8+ 自动检查
SafeMath 库
权限控制
Ownable
角色制 AccessControl
> ← 上一节:12.2 事件 | 前往 → 12.4 审计与工具 |*
评论
0评论加载中…