慢霧: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、專案方可以在專案初期或未完成多次嚴謹安全審計之前新增合約暫停功能與可升級模型,避免在發生黑天鵝事件時無法有效的保證剩餘資金安全。

免責聲明:

  1. 本文版權歸原作者所有,僅代表作者本人觀點,不代表鏈報觀點或立場。
  2. 如發現文章、圖片等侵權行爲,侵權責任將由作者本人承擔。
  3. 鏈報僅提供相關項目信息,不構成任何投資建議

推荐阅读

;