以太坊智能合约钓鱼:成本 100 ETH 的骗局
2025-02-17 13:03
Diary of Owen
2025-02-17 13:03
订阅此专栏
收藏此文章

前段时间在 BlockSec 的安全技术群里发现一个有意思的求助。

一个名为 Josh 的用户咨询了某个关于智能合约的技术问题。

"这个合约的 deposit 函数好像有点奇怪" "Etherscan 上的 warning 是什么意思?" "好像可以通过存入 ETH 来绕过某些检查,不过算了,我本地 fork 测试会 revert"

本着助人为乐的精神肯定要打开看看。

合约地址在这里[1]

合约名为 ETHEscrow,逻辑比较简单,是一个存款合约。

  • fiduciary 用户 可以调用 deposit() 存入一定数量的 ETH
  • 到期后(7 天) fiduciary 可以调用 releaseToCounterparty() 将这笔锁定的 ETH 释放给 counterparty 用户
  • 到期后 fiduciary 也可以选择调用 refundToTreasury() 将这笔钱转给 treasury
  • 锁定期间 ETH 在合约中任何人都无法取走。

合约中已经有人进行过存款,里面有 100 ETH(约 30 万美金)。

Josh 提到的奇怪的函数就是这个 deposit() 存款函数:

function deposit(address payable _fiduciary, address payable _counterpartyexternal payable {
    require(dvpAmount>0"DVP Amount not initialized");
    require(address(this).balance - msg.value != dvpAmount, "Already Deposited");
    require(msg.value == dvpAmount, "Bad Amount");
    require(_fiduciary != address(0), "Invalid fiduciary");
    require(_counterparty != address(0), "Invalid counterparty");

    fiduciary = _fiduciary;
    counterparty = _counterparty;
    escrowExpiry = block.timestamp + 7 days;
    emit EscrowDeposited(_fiduciary, _counterparty, msg.value, escrowExpiry);
}

其中的这行检查确实有点奇怪

require(address(this).balance - msg.value != dvpAmount, "Already Deposited");

如果要检查已经 Deposited,可能会如下比较正常。

require(address(this).balance != dvpAmount, "Already Deposited");

如果有用户再次调用了 deposit,那么检查条件 address(this).balance - msg.value 的结果会是 0(因为 address(this).balance 和 msg.value 都是 dvpAmount),与 dvpAmount 不相等,从而通过检查。

这样该名用户可以 2 次存款。并且重要的是,此次调用会覆盖原本的 fiduciary 和 counterparty 设定的值。并且将锁定期重置为 7 天。

7 天到期后进行 2 次存款的用户则可以通过他设定的 fiduciary 调用 releaseToCounterparty() 将合约中 2 次存款的总额 200 ETH 一起取出!

不过在 7 天间,如果有新的用户来进行第 3 次存款,是否会继续滚雪球,层层加码让这个游戏无限进行下去呢(像著名的 Fomo3D 一样)?

答案是否。回到这个检查,

require(address(this).balance - msg.value != dvpAmount, "Already Deposited");

在 2 次存款后,address(this).balance 成为 2 * dvpAmount,此时再次进行存款(合约要求每次存款必须是 dvpAmount),address(this).balance - msg.value 刚好变成 dvpAmount,使检查无法通过。

至此,这个奇怪的检查已经不仅仅是笔误了,简直堪称神来一笔。这个错误的检查将原本正常的合约转化成了一个完美的漏洞合约,给第 2 次存款的人形成了一个安全的获利机会,投入 100 个 ETH,7 天后就可以提取 200 ETH。

可惜我没有 100 个 ETH,只能看着干着急。这时 Etherscan 页面上的 warning 吸引了我的注意。

之所以有这个警告是因为合约中使用了 library Address,主体合约中所有 ETH 转账,都是通过 Address.sendValue 完成的。其代码如下,可以看到 sendValue() 标记为了 public

library Address {
    // ...
    function sendValue(address payable recipient, uint256 amountpublic {
        require(address(this).balance >= amount, "Address: insufficient balance");

        (bool success, ) = recipient.call{value: amount}("");
        require(success, "Address: unable to send value, recipient may have reverted");
    }
}

这种情况下 library 的代码不会被编译进主体合约中,而是会形成一个单独的合约,主体合约会以 delegatecall 的形式去调用 library。由于以太坊单个合约体积有限制,对于复杂的合约常用此种方式来绕过单个合约大小的限制,并且也利于项目的模块化。

这里的 Address 合约部署在了这里[2]

这个合约未开源,因此 Etherscan 才会有那个特殊的提示。 不过 Address 是 OpenZeppelin[3] 中非常常见的库,同时主体合约 ETHEscrow 中已经展示了 library Address 的源码。

不过,反正手上并没有 100 ETH,就先深入看一下吧。

打开反编译工具[4],有意思的东西出现了,sendValue 的代码比想像中要复杂一些,相比主体合约中展示的源码,增加了新的逻辑:

当调用者是 0x807b6115a1925e9d7810963b31977709b81fd44a 时,将合约内的 ETH 余额转给他!这是明显的后门代码。

那么主体合约中有哪些函数是任何人都可以调用,并且内部使用了 Address.sendValue 的呢?

最终可以找到这个。

contract ETHEscrow {
    // ...
    
    /**
     * @notice Updated From last release: not only counterparty
     * @notice Sends a zero-value transaction to an arbitrary address.
     * @dev    Used as an on-chain signal that deposit has been made (and/or recognized).
     *         This triggers the recipient's fallback or receive function, if any.
     */

    function signal(address payable signaleeexternal {
        require(counterparty != address(0), "Deposit not made yet");
        Address.sendValue(signalee, 0);// 0 value transfer used as signal
        emit SignalSent(signalee, msg.sender);
    }

从源码上看,无论谁调用这个函数,都只能产生 0 ETH 的转账。然而实际 0x807b.. 调用这个 signal 方法,就可以完成从合约内提款的操作,并且不受锁定期的限制。

如果真的存在某个人进行了 2 次存款,那么就会形成螳螂捕蝉,黄雀在后的局面,在 7 天锁定期内被 0x807b.. 截胡!

至此这个合约骗局的的核心就揭示完了。值得庆幸的是这场骗局没有任何小动物受到伤害,攻击者在部署合约后的第 3 天,就把 100 个 ETH 提走,没有等待鱼儿上钩。

不过直到最后,我依然没有明白这个骗局的针对目标群体是哪些人:

  • 需要懂得一些智能合约的技术,至少可以读懂合约代码,找出使用 deposit() 函数攻击获利的方式。
  • 但也不能太懂,不能看出 Address.sendValue 的异常
  • 能拿出 100 个 ETH

到底是什么样的人能同时满足这 3 点?


参考资料

[1] 

ETHEscrow 合约代码: https://etherscan.io/address/0xf5e1186146330855deb1bc9b99ba4e492641918f#code

[2] 

Address 合约: https://etherscan.io/address/0xd51f1454d47d9db09e9eb196a0ed892ab60639a7#code

[3] 

OpenZeppelin Address.sol: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/Address.sol

[4] 

Dedaub 反编译工具: https://app.dedaub.com/ethereum/address/0xd51f1454d47d9db09e9eb196a0ed892ab60639a7/decompiled





【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。

在 App 打开
空投
rwa
稳定币
wct
hyperliquid
uniswap
initia
fo
以太坊
om
crv
香港