慢雾:Opyn 合约被黑详细分析

背景
2020 年 8 月 5 日,Opyn 合约遭遇黑客攻击。慢雾安全团队在收到情报后对本次攻击事件进行了全面的分析,下面为大家就这次攻击事件展开具体的技术分析。
攻击细节
逻辑分析
看其中一笔攻击交易:

https://etherscan.io/tx/0xa858463f30a08c6f3410ed456e59277fbe62ff14225754d2bb0b4f6a75fdc8ad

通过查看内联交易可以看到攻击者仅使用 272ETH 最终得到 467ETH
使用 OKO 合约浏览器对具体的攻击细节进行分析
https://oko.palkeo.com/0xa858463f30a08c6f3410ed456e59277fbe62ff14225754d2bb0b4f6a75fdc8ad/

关键点在于 oToken 合约的 exercise 函数,从上图中可以看出在 exercise 函数中通过调用两次 transfer 将 USDC 发送给攻击者合约,接下来我们切入 exercise 函数进行具体的分析
function exercise(
        uint256 oTokensToExercise,
        address payable[] memory vaultsToExerciseFrom
) public payable {
        for (uint256 i = 0; i < vaultsToExerciseFrom.length; i++) {
            address payable vaultOwner = vaultsToExerciseFrom[i];
            require(
                hasVault(vaultOwner),
                “Cannot exercise from a vault that doesn’t exist”
            );
            Vault storage vault = vaults[vaultOwner];
            if (oTokensToExercise == 0) {
                return;
            } else if (vault.oTokensIssued >= oTokensToExercise) {
                _exercise(oTokensToExercise, vaultOwner);
                return;
            } else {
                oTokensToExercise = oTokensToExercise.sub(vault.oTokensIssued);
                _exercise(vault.oTokensIssued, vaultOwner);
            }
        }
        require(
            oTokensToExercise == 0,
            “Specified vaults have insufficient collateral”
        );
    }
可以看到 exercise 函数允许传入多个 vaultsToExerciseFrom,然后通过 for 循环调用 _exercise 函数对各个 vaultsToExerciseFrom 进行处理,现在我们切入 _exercise 函数进行具体的分析
function _exercise(
        uint256 oTokensToExercise,
        address payable vaultToExerciseFrom
) internal {
        // 1. before exercise window: revert
        require(
            isExerciseWindow(),
            “Can’t exercise outside of the exercise window”
        );
        require(hasVault(vaultToExerciseFrom), “Vault does not exist”);
        Vault storage vault = vaults[vaultToExerciseFrom];
        require(oTokensToExercise > 0, “Can’t exercise 0 oTokens”);
        // Check correct amount of oTokens passed in)
        require(
            oTokensToExercise <= vault.oTokensIssued,
            “Can’t exercise more oTokens than the owner has”
        );
        // Ensure person calling has enough oTokens
        require(
            balanceOf(msg.sender) >= oTokensToExercise,
            “Not enough oTokens”
        );
        // 1. Check sufficient underlying
        // 1.1 update underlying balances
        uint256 amtUnderlyingToPay = underlyingRequiredToExercise(
            oTokensToExercise
        );
        vault.underlying = vault.underlying.add(amtUnderlyingToPay);
        // 2. Calculate Collateral to pay
        // 2.1 Payout enough collateral to get (strikePrice * oTokens) amount of collateral
        uint256 amtCollateralToPay = calculateCollateralToPay(
            oTokensToExercise,
            Number(1, 0)
        );
        // 2.2 Take a small fee on every exercise
        uint256 amtFee = calculateCollateralToPay(
            oTokensToExercise,
            transactionFee
        );
        totalFee = totalFee.add(amtFee);
        uint256 totalCollateralToPay = amtCollateralToPay.add(amtFee);
        require(
            totalCollateralToPay <= vault.collateral,
            “Vault underwater, can’t exercise”
        );
        // 3. Update collateral + oToken balances
        vault.collateral = vault.collateral.sub(totalCollateralToPay);
        vault.oTokensIssued = vault.oTokensIssued.sub(oTokensToExercise);
        // 4. Transfer in underlying, burn oTokens + pay out collateral
        // 4.1 Transfer in underlying
        if (isETH(underlying)) {
            require(msg.value == amtUnderlyingToPay, “Incorrect msg.value”);
        } else {
            require(
                underlying.transferFrom(
                    msg.sender,
                    address(this),
                    amtUnderlyingToPay
                ),
                “Could not transfer in tokens”
            );
        }
        // 4.2 burn oTokens
        _burn(msg.sender, oTokensToExercise);
        // 4.3 Pay out collateral
        transferCollateral(msg.sender, amtCollateralToPay);
        emit Exercise(
            amtUnderlyingToPay,
            amtCollateralToPay,
            msg.sender,
            vaultToExerciseFrom
        );
    }
1、在代码第 6 行首先检查了现在是否在保险期限内,这自然是肯定的
2、在代码第 11 行则对 vaultToExerciseFrom 是否创建了 vault 进行检查,注意这里只是检查了是否有创建 vault
3、在代码第 14、16、21 行对传入的 oTokensToExercise 值进行了检查,在上图 OKO 浏览器中我们可以看到攻击者传入了 0x1443fd000,这显然是可以通过检查的
4、接下来在代码第 28 行计算需要消耗的 ETH 数量
5、在代码第 35、41 行计算需要支付的数量与手续费
6、接下来在代码第 59 行对 underlying 是否是 ETH 地址进行判断,而 underlying 在上面代码第 31 行进行了赋值,由于 isETH 为 true, 因此将会进入 if 逻辑而不会走 else 逻辑,在 if 逻辑中 amtUnderlyingToPay 与 msg.value 都是用户可控的
7、随后对 oTokensToExercise 进行了燃烧,并调用 transferCollateral 函数将 USDC 转给 exercise 函数的调用者
以上关键的地方在于步骤 2 与步骤 6,因此我们只需要确保传入的 vaultToExerciseFrom 都创建了 vault,且使 amtUnderlyingToPay 与 msg.value 相等即可,而这些相关参数都是我们可以控制的,所以攻击思路就显而易见了。
思路验证
让我们通过攻击者的操作来验证此过程是否如我们所想:
1、首先在保险期限内是肯定的

2、攻击者传入的 vaultToExerciseFrom 分别为:
0xe7870231992ab4b1a01814fa0a599115fe94203f、0x076c95c6cd2eb823acc6347fdf5b3dd9b83511e4

经验证,这两个地址都创建了 vault
3、攻击者调用 exercise 传入 oTokensToExercise 为 0x1443fd000 (5440000000),msg.value 为 272ETH,vaultsToExerciseFrom 分别为以上两个地址

4、此时由于此前攻击者创建的 oToken 为 0xa21fe800 (2720000000),及 vault.oTokensIssued 为 2720000000 小于 5440000000,所以将走 exercise 函数中的 else 逻辑,此时 oTokensToExercise 为 0xa21fe800 (2720000000),则以上代码第 60 行 msg.value == amtUnderlyingToPay 是肯定成立的

5、由于 vaultsToExerciseFrom 传入两个地址,所以 for 循环将执行两次 _exercise 函数,因此将 transfer 两次把 USDC 转给攻击者合约

完整的攻击流程如下
1、攻击者使用合约先调用 Opyn 合约的 createERC20CollateralOption 函数创建 oToken
2、攻击合约调用 exercise 函数,传入已创建 vault 的地址
3、通过 exercise 函数中 for 循环逻辑执行调用两次 _exercise 函数
4、exercise 函数调用 transferCollateral 函数将 USDC 转给函数调用者(由于 for 循环调用两次 _exercise 函数,transferCollateral 函数也将执行两次)
5、攻击合约调用 removeUnderlying 函数将此前传入的 ETH 转出
6、最终攻击者拿回了此前投入的 ETH 以及额外的 USDC
攻击合约地址
0xe7870231992Ab4b1A01814FA0A599115FE94203f
Opyn 合约地址
0x951D51bAeFb72319d9FBE941E1615938d89ABfe2
攻击交易(其一)
0xa858463f30a08c6f3410ed456e59277fbe62ff14225754d2bb0b4f6a75fdc8ad
 
修复建议
此次攻击主要是利用了 _exercise 函数中对 vaultToExerciseFrom 是否创建 vault 的检查缺陷。此检查未校验 vaultToExerciseFrom 是否是调用者自己,而只是简单的检查是否创建了 vault,导致攻击者可以任意传入已创建 vault 的地址来通过检查。
建议如下:
1、在处理用户可控的参数时应做好权限判断,限制 vaultToExerciseFrom 需为调用者本人。
2、项目方可以在项目初期或未完成多次严谨安全审计之前添加合约暂停功能与可升级模型,避免在发生黑天鹅事件时无法有效的保证剩余资金安全。