前段时间在 BlockSec 的安全技术群里发现一个有意思的求助。
一个名为 Josh
的用户咨询了某个关于智能合约的技术问题。
"这个合约的 deposit 函数好像有点奇怪" "Etherscan 上的 warning 是什么意思?" "好像可以通过存入 ETH 来绕过某些检查,不过算了,我本地 fork 测试会 revert"
本着助人为乐的精神肯定要打开看看。
合约地址在这里[1]。
合约名为 ETHEscrow
,逻辑比较简单,是一个存款合约。
fiduciary 用户
可以调用 deposit()
存入一定数量的 ETHfiduciary
可以调用 releaseToCounterparty()
将这笔锁定的 ETH 释放给 counterparty 用户
fiduciary
也可以选择调用 refundToTreasury()
将这笔钱转给 treasury
合约中已经有人进行过存款,里面有 100 ETH(约 30 万美金)。
Josh 提到的奇怪的函数就是这个 deposit()
存款函数:
function deposit(address payable _fiduciary, address payable _counterparty) external 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 amount) public {
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 signalee) external {
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
的异常到底是什么样的人能同时满足这 3 点?
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
【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。