Solidity基础回顾:智能合约、函数调用、fallback函数、send/transfer/call的区别

好,咱们正式开始第二讲。在深入重入攻击之前,我觉得有必要先把Solidity里几个最基础、也最容易踩坑的概念捋一遍。说白了,重入攻击能成功,很大程度上就是利用了这些基础机制的特性。我自己刚入行时,就因为在fallback函数里写了个转账逻辑,差点让测试网上的合约被薅秃——嗯,那次教训挺深刻的。

智能合约的本质:一个永不停机的状态机

智能合约是什么?我的理解是:它就是一个部署在区块链上的、由代码逻辑驱动的状态机。你想想看,它没有管理员,没有后台,一旦部署,所有人都能调用它的公开函数。每个函数调用都会改变合约内部的状态(比如余额、变量值),而这些改变会被全网节点共识确认。

我个人习惯把合约想象成一个“自动售货机”:你投币(发送交易),它根据你按的按钮(调用的函数)给你出货(执行逻辑并更新状态)。只不过这个售货机永远不会关机,而且代码是公开的。

核心要点:合约的状态(storage变量)是持久化的,每次函数调用都在这个状态基础上执行。重入攻击的本质,就是攻击者在一次交易中多次修改同一个状态,导致状态不一致。

函数调用的两种方式:内部调用与外部调用

Solidity里的函数调用分两种:内部调用和外部调用。这个区分特别重要,因为重入攻击只发生在外部调用中。

  • 内部调用:就是合约内部函数之间的跳转,比如A函数调用B函数。这种调用不会触发新的交易,也不会走fallback函数。说白了,就是同一个执行上下文里的跳转。
  • 外部调用:合约A调用合约B的函数。这种调用会触发一次新的消息调用(message call),目标合约的fallback函数可能会被触发。重入攻击就是利用这个机制——合约A调用合约B,合约B在fallback里又回调合约A的函数。
// 内部调用示例
contract A {
    function foo() public {
        bar(); // 内部调用,不会触发fallback
    }
    function bar() internal {
        // 一些逻辑
    }
}

// 外部调用示例
contract A {
    function callB(address b) public {
        B(b).doSomething(); // 外部调用,可能触发B的fallback
    }
}

我的经验:写合约时,我习惯在外部调用前后加上状态更新。比如先更新余额,再转账。这样即使被重入,状态已经变了,攻击者无法重复提取。这个模式叫“检查-生效-交互”(Checks-Effects-Interactions),后面我们会详细讲。

fallback函数:合约的“万能入口”

fallback函数是Solidity里一个特殊的存在。当有人向合约发送以太币,或者调用了合约里不存在的函数时,就会触发它。在旧版本Solidity里,它叫fallback,新版本里分成了fallbackreceive

为什么会这样设计?因为合约需要处理“意外”的调用。比如你给一个合约转了ETH,但合约没有对应的接收函数,那这笔转账就会失败。有了fallback,合约就能“接住”这些调用。

// 新版Solidity (0.6.x+)
contract Receiver {
    // 接收ETH时触发
    receive() external payable {
        // 只有转账时触发
    }
    
    // 调用不存在函数时触发
    fallback() external payable {
        // 可以处理任意调用
    }
}

注意:重入攻击最常用的入口就是fallback函数。攻击者部署一个合约,在fallback里写回调逻辑。当受害者合约向攻击者合约转账时,fallback被触发,攻击者就可以在fallback里再次调用受害者合约的提款函数。我曾经审计过一个项目,他们的fallback函数里居然调用了外部合约的提款接口——这简直是给攻击者开了后门。

send/transfer/call:三种转账方式的恩怨情仇

在Solidity里,向其他地址发送ETH有三种方式:sendtransfercall。它们有什么区别?我直接说结论:现在几乎只用call。为什么?往下看。

方法 Gas限制 返回值 是否转发所有Gas 推荐使用
transfer 2300 Gas 自动revert 不推荐(旧版)
send 2300 Gas 返回bool 不推荐
call 无限制(可指定) 返回(bool, bytes) 是(默认) 推荐

transfersend都只提供2300 Gas。这个Gas量只够执行一次日志记录或者一个简单的状态更新。为什么这么设计?初衷是为了防止重入攻击——2300 Gas不够攻击者在fallback里做复杂操作。但问题来了:如果接收方是一个多签钱包或者需要执行复杂逻辑的合约,2300 Gas根本不够用,转账就会失败。

我记得在2020年,很多DeFi项目还在用transfer。结果Gas价格一涨,很多用户的提现都失败了,因为2300 Gas连基本的存储操作都不够。从那以后,社区基本达成共识:call代替transfersend

// 旧方式(不推荐)
payable(receiver).transfer(amount);  // 失败时自动revert
bool success = payable(receiver).send(amount); // 失败时返回false

// 推荐方式
(bool success, ) = payable(receiver).call{value: amount}("");
require(success, "Transfer failed");

重要提醒:call虽然灵活,但也意味着攻击者的fallback函数可以执行任意逻辑。所以使用call时,必须遵循“检查-生效-交互”模式。先更新状态,再发起外部调用。否则,重入攻击就会找上门。

知识体系:一张图看懂

下面我用一张SVG图来总结这一章的核心逻辑。你可以把它当作一个思维导图,方便记忆。

Solidity基础回顾 智能合约 永不停机的状态机 状态持久化在链上 函数调用 内部调用 vs 外部调用 外部调用触发fallback fallback函数 receive() 接收ETH fallback() 处理未知调用 send / transfer / call send: 2300 Gas, 返回bool transfer: 2300 Gas, 自动revert call: 无Gas限制, 推荐使用 ⚠️ 核心原则:先更新状态,再发起外部调用 使用call时务必遵循 Checks-Effects-Interactions 模式

避坑指南:我踩过的那些坑

最后,分享几个我实际项目中遇到的教训:

  • 别在fallback里写复杂逻辑:我曾经在一个项目中,把提款逻辑写在了fallback函数里。结果用户转账时触发了fallbackfallback又去调用另一个合约,那个合约又回调回来——直接死循环了。嗯,那次测试网差点被玩坏。
  • 别迷信transfer的安全性:2300 Gas确实能防重入,但也会导致正常用户无法提现。尤其是当Gas价格高时,2300 Gas连一个SSTORE操作都不够。我建议直接用call,配合重入锁(Reentrancy Guard)来防护。
  • 外部调用一定要检查返回值:用call时,记得检查返回的bool值。如果不检查,转账失败了你都不知道。我见过一个合约,call返回了false,但代码继续执行,结果用户余额被扣了,钱却没到账。

一句话总结:理解智能合约的调用机制,是防御重入攻击的第一步。记住,外部调用是双刃剑——它让合约可以交互,但也给了攻击者重入的机会。下一章,我们会正式进入重入攻击的实战分析。