2016年,一个名为「TheDAO」的项目,在万众瞩目下募得了当时价值1.5亿美元的以太币,占了当时以太币总量的14%。然而,短短几周内,一名黑客利用一个致命的程序漏洞,将其中三分之一的资金席卷一空。这起事件不仅震惊了整个社区,更直接导致了以太坊的硬分叉,分裂成我们今天熟知的以太坊(ETH)和以
2016年,一个名为「The DAO」的项目,在万众瞩目下募得了当时价值1.5亿美元的以太币,占了当时以太币总量的14%。然而,短短几周内,一名黑客利用一个致命的程序漏洞,将其中三分之一的资金席卷一空。
这起事件不仅震惊了整个社区,更直接导致了以太坊的硬分叉,分裂成我们今天熟知的以太坊(ETH)和以太坊经典(ETC)。
而这一切的元凶,就是我们今天要深入探讨的,智能合约的头号公敌——「重入攻击」(Reentrancy Attack)。
要理解重入攻击,我们可以把它想象成一个设计有缺陷的 ATM 提款流程。
想象一下你去 ATM 提款。你插入卡片,输入金额1000元。ATM 开始数钱,但在钱还没吐出来、你的账户余额也还没被扣除的那个瞬间,你用黑客技术让 ATM「重新」执行一次「提款1000元」的指令。ATM 再次检查你的余额,发现钱还够,于是又数了一次钱...
这个过程不断「重入」,直到你把 ATM 的现金提领一空,而你的银行账户,自始至终只会被扣一次款。
在智能合约的世界里,这个「黑客技术」就是利用合约间的外部调用来达成的。
让我们来看一个有漏洞的资金存储合约 EtherStore
:
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
Solidity
<!---->
<!---->
<!---->
<!---->
<!---->
// 警告:这是一个有严重漏洞的合约!
contract EtherStore {
mapping(address => uint) public balances;
// ... 省略存款等其他函数 ...
function withdraw(uint _amount) public {
// 1. 检查余额是否足够
require(balances[msg.sender] >= _amount);
// 2. 转账给调用者 (问题所在!)
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
// 3. 更新账户余额
balances[msg.sender] -= _amount;
}
}
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
接着,黑客部署了他的攻击合约 Attack
:
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
Solidity
<!---->
<!---->
<!---->
<!---->
<!---->
import "./EtherStore.sol";
contract Attack {
EtherStore public etherStore;
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
// fallback 函数是关键,当合约收到以太币时会被触发
fallback() external payable {
// 只要 EtherStore 合约的余额还大于1 ETH,就继续调用它的 withdraw 函数
if (address(etherStore).balance >= 1 ether) {
etherStore.withdraw(1 ether);
}
}
function attack() external payable {
etherStore.deposit{value: 1 ether}(); // 先存入 1 ETH
etherStore.withdraw(1 ether); // 发起第一次提款
}
}
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
攻击流程如下:
Attack.attack()
,存入 1 ETH 到 EtherStore
,然后立刻要求提领这 1 ETH。EtherStore.withdraw()
开始执行。它检查黑客的余额(1 ETH),检查通过。EtherStore
执行 msg.sender.call{value: 1 ether}("")
,试图将 1 ETH 还给黑客的 Attack
合约。Attack
合约收到这笔以太币时,它的 fallback()
函数被触发了。fallback()
函数中,黑客**再一次(重入)**调用了 EtherStore.withdraw()
,要求再提领 1 ETH。EtherStore
的代码还没执行到第3步(更新余额),所以它账上的记录依然是「黑客还有 1 ETH」。因此,第二次的余额检查再次通过!EtherStore
又一次尝试发送 1 ETH 给黑客,这又触发了黑客的 fallback()
,形成一个无限循环,直到 EtherStore
合约的资金被掏空为止。幸运的是,防御重入攻击的方法相当明确,主要有两种策略。
这是最根本、也最应该被内化为开发习惯的模式。它的核心思想是调整代码的执行顺序。
一个安全的函数应该遵循以下流程:
让我们用这个模式来修复 EtherStore
:
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
Solidity
<!---->
<!---->
<!---->
<!---->
<!---->
// 安全的版本
contract EtherStore {
mapping(address => uint) public balances;
function withdraw(uint _amount) public {
// 1. 检查 (Checks)
require(balances[msg.sender] >= _amount);
// 2. 生效 (Effects) - 先更新内部状态!
balances[msg.sender] -= _amount;
// 3. 互动 (Interactions) - 最后才进行外部调用
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
}
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
在这个安全版本中,当黑客第一次调用 withdraw
时,合约会立刻将其余额设为0。因此,当黑客试图从 fallback()
函数重入时,第一步的 require
检查就会因为余额不足而失败,从而阻断了攻击。
另一种非常有效的方法是使用「互斥锁」(Mutex),在函数执行期间,不允许同一个函数被再次进入。
你可以自己写一个简单的锁,但业界的标准作法是使用经过审计和社区考验的库,例如 OpenZeppelin 的 ReentrancyGuard
。
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
Solidity
<!---->
<!---->
<!---->
<!---->
<!---->
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
// 使用 OpenZeppelin 的 ReentrancyGuard
contract EtherStore is ReentrancyGuard {
mapping(address => uint) public balances;
// 只需要加上 nonReentrant 修饰符
function withdraw(uint _amount) public nonReentrant {
require(balances[msg.sender] >= _amount);
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
balances[msg.sender] -= _amount;
}
}
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
<!---->
nonReentrant
这个修饰符会在函数开始时「上锁」,在函数结束时「解锁」。如果黑客在函数尚未结束时就试图重入,修饰符会发现「门是锁着的」,并立即让交易失败,这是一种非常简洁且强大的保护机制。
重入攻击是智能合约安全领域最经典的案例,The DAO 事件的惨痛教训,至今仍然是所有区块链开发者的警钟。
代码的顺序,决定了资产的生死。将「检查-生效-互动」模式刻在脑海里,并善用 ReentrancyGuard
这样的安全工具。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!