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,新版本里分成了fallback和receive。
为什么会这样设计?因为合约需要处理“意外”的调用。比如你给一个合约转了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有三种方式:send、transfer和call。它们有什么区别?我直接说结论:现在几乎只用call。为什么?往下看。
| 方法 | Gas限制 | 返回值 | 是否转发所有Gas | 推荐使用 |
|---|---|---|---|---|
transfer |
2300 Gas | 自动revert | 否 | 不推荐(旧版) |
send |
2300 Gas | 返回bool | 否 | 不推荐 |
call |
无限制(可指定) | 返回(bool, bytes) | 是(默认) | 推荐 |
transfer和send都只提供2300 Gas。这个Gas量只够执行一次日志记录或者一个简单的状态更新。为什么这么设计?初衷是为了防止重入攻击——2300 Gas不够攻击者在fallback里做复杂操作。但问题来了:如果接收方是一个多签钱包或者需要执行复杂逻辑的合约,2300 Gas根本不够用,转账就会失败。
我记得在2020年,很多DeFi项目还在用transfer。结果Gas价格一涨,很多用户的提现都失败了,因为2300 Gas连基本的存储操作都不够。从那以后,社区基本达成共识:用call代替transfer和send。
// 旧方式(不推荐)
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图来总结这一章的核心逻辑。你可以把它当作一个思维导图,方便记忆。
避坑指南:我踩过的那些坑
最后,分享几个我实际项目中遇到的教训:
- 别在fallback里写复杂逻辑:我曾经在一个项目中,把提款逻辑写在了
fallback函数里。结果用户转账时触发了fallback,fallback又去调用另一个合约,那个合约又回调回来——直接死循环了。嗯,那次测试网差点被玩坏。 - 别迷信transfer的安全性:2300 Gas确实能防重入,但也会导致正常用户无法提现。尤其是当Gas价格高时,2300 Gas连一个
SSTORE操作都不够。我建议直接用call,配合重入锁(Reentrancy Guard)来防护。 - 外部调用一定要检查返回值:用
call时,记得检查返回的bool值。如果不检查,转账失败了你都不知道。我见过一个合约,call返回了false,但代码继续执行,结果用户余额被扣了,钱却没到账。
一句话总结:理解智能合约的调用机制,是防御重入攻击的第一步。记住,外部调用是双刃剑——它让合约可以交互,但也给了攻击者重入的机会。下一章,我们会正式进入重入攻击的实战分析。