说明 本漏洞的原理如果深入分析之后,发现其实是一个简单的问题。因为底层的兑换采用的是wBTC的数量,因为合约中没有人借贷wBTC,导致攻击者就可以操控兑换率,从而改变代币的价格,进而借贷出大量的其他的代币从而套利。
如果要实施这个攻击,需要攻击者对于各种DeFi协议非常的了解,对compound整体的机制要深刻理解。
基本信息
Attacker: 0x155da45d374a286d383839b1ef27567a15e67528
Attack Contract:
0x978D0CE23869EC666BFDE9868a8514F3D2754982
Vulnerable Contract:
0x7100CBCa885905F922a19006cF7fD5d0E1bBb26c
Chain:Optimism
Attack Tx
0x6e9ebcdebbabda04fa9f2e3bc21ea8b2e4fb4bf4f4670cb8483e2f0b2604f451
Lost:7M
漏洞函数 getAccountSnapshot 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 /** * @notice Get a snapshot of the account's balances, and the cached exchange rate * @dev This is used by comptroller to more efficiently perform liquidity checks. * @param account Address of the account to snapshot * @return (possible error, token balance, borrow balance, exchange rate mantissa) */ function getAccountSnapshot(address account) external view returns (uint, uint, uint, uint) { // cToken 余额信息 uint cTokenBalance = accountTokens[account]; uint borrowBalance; uint exchangeRateMantissa; MathError mErr; // 借款余额 (mErr, borrowBalance) = borrowBalanceStoredInternal(account); if (mErr != MathError.NO_ERROR) { return (uint(Error.MATH_ERROR), 0, 0, 0); } // 当前存款利率 (mErr, exchangeRateMantissa) = exchangeRateStoredInternal(); if (mErr != MathError.NO_ERROR) { return (uint(Error.MATH_ERROR), 0, 0, 0); } return (uint(Error.NO_ERROR), cTokenBalance, borrowBalance, exchangeRateMantissa); }
这是一个用于获取账户余额信息和当前存款利率的视图函数。 具体来说,此函数将返回传入地址账户的当前 cToken 余额信息、借款余额和当前存款利率(作为 exchangeRateMantissa 变量)。
这个函数可能被 Comptroller 或其他智能合约调用,以了解系统中现有的资产和债务,以便更好地管理系统资金。
通过exchangeRateStoredInternal()获得当前的兑换利率。
exchangeRateStoredInternal 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 /** * @notice Calculates the exchange rate from the underlying to the CToken * @dev This function does not accrue interest before calculating the exchange rate * @return (error code, calculated exchange rate scaled by 1e18) */ function exchangeRateStoredInternal() internal view returns (MathError, uint) { uint _totalSupply = totalSupply; if (_totalSupply == 0) { /* * If there are no tokens minted: * exchangeRate = initialExchangeRate */ return (MathError.NO_ERROR, initialExchangeRateMantissa); } else { /* * Otherwise: * exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply */ uint totalCash = getCashPrior(); uint cashPlusBorrowsMinusReserves; Exp memory exchangeRate; MathError mathErr; (mathErr, cashPlusBorrowsMinusReserves) = addThenSubUInt(totalCash, totalBorrows, totalReserves); if (mathErr != MathError.NO_ERROR) { return (mathErr, 0); } (mathErr, exchangeRate) = getExp(cashPlusBorrowsMinusReserves, _totalSupply); if (mathErr != MathError.NO_ERROR) { return (mathErr, 0); } return (MathError.NO_ERROR, exchangeRate.mantissa); } }
这是一个用于计算底层资产(Underlying Asset)到 cToken 的兑换汇率的内部函数。通过这个函数,可以在没有发生利息累计的情况下计算兑换汇率,以备将来使用。
getCashPrior 1 2 3 4 5 6 7 8 9 /** * @notice Gets balance of this contract in terms of the underlying * @dev This excludes the value of the current message, if any * @return The quantity of underlying tokens owned by this contract */ function getCashPrior() internal view returns (uint) { EIP20Interface token = EIP20Interface(underlying); return token.balanceOf(address(this)); }
这是一个用于获取智能合约在底层资产中的余额的内部函数,其中底层资产是用于购买和赎回 cToken 的资产。
getExp 1 2 3 4 5 6 7 8 9 10 11 12 13 function getExp(uint num, uint denom) pure internal returns (MathError, Exp memory) { (MathError err0, uint scaledNumerator) = mulUInt(num, expScale); if (err0 != MathError.NO_ERROR) { return (err0, Exp({mantissa: 0})); } (MathError err1, uint rational) = divUInt(scaledNumerator, denom); if (err1 != MathError.NO_ERROR) { return (err1, Exp({mantissa: 0})); } return (MathError.NO_ERROR, Exp({mantissa: rational})); }
攻击分析 由于phalcon不支持Optimism,所以本次攻击借助openchain和tenderly分析。
flashLoanSimple
攻击者首先调用aave的flashloan函数借出500wbtc。
redeem 攻击者之前向hWBTC存储了0.30064194 wbtc。
hWBTC tx
分两次存储。
0xf479b1f397080ac01d042311ac5b060ceccef491867c1796d12ad16a8f12a47e
0x771a16e02a8273fddf9d9d63ae64ff49330d44d31575af3dff0018b04da39fcc
攻击者调用redeem赎回自己保存在hWBTC中的wbtc。
Attack Contract-1 Attack Contract对应的合约地址是:0xD340F2208edBBBeCA4fe4893D76EaAa5711E702b
攻击者首先将全部的WBTC转入到攻击合约1中。
mint
通过Attack Contract-1 创建了一个新的子合约Attack Cotrac-1。Attack Contrac-1用4个WBTC获得了20000000000wei hwbtc。
redeem Attack Contract-1又调用redeem()方法,通过19999999998wei hwbtc赎回质押的所有的WBTC。
攻击者创建的合约上有500wbtc和2wei hwbtc(因为通过mint得到20000000000wei,redeem用掉19999999998wei)。
transfer 此时Attack Contract-1将所有的WBTC全部转入到hWBTC pool中,根据我们前面的分析,此时兑换率(Exchange Rate就会发生改变)
按照Compound的计算公式,exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply,当totalCash变大时,exchangeRate也会变大,意味着hwBTC可以兑换出来更多的wbtc。
通过实际的调试分析,当将所有的WBTC转入到hWBTC pool中前后的exchangeRate差别。
1 2 3 4 5 6 7 (,,, uint256 exchangeRate_1) = hWBTC.getAccountSnapshot(address(this)); console.log("exchangeRate before manipulation:", exchangeRate_1); uint256 donationAmount = WBTC.balanceOf(address(this)); WBTC.transfer(address(hWBTC), donationAmount); // "donation" exchangeRate manipulation uint256 WBTCAmountInhWBTC = WBTC.balanceOf(address(hWBTC)); (,,, uint256 exchangeRate_2) = hWBTC.getAccountSnapshot(address(this)); console.log("exchangeRate after manipulation:", exchangeRate_2);
计算得到的结果如下,可以发现exchangeRate陡增。
1 2 3 Second step, Donate a large amount of WBTC to the hWBTC pool to increase the exchangeRate(the number of WBTC represented by each share) exchangeRate before manipulation: 500000000000000000 exchangeRate after manipulation: 25015031908500000000000000000
exchangeRate变大了,就表示hwBTC可以兑换出来更多的wbtc。
borrow 因为hwBTC价值升高,所以攻击者就将hwBTC进行抵押,获得大量的1021数量的ETH。
redeemUnderlying 之前攻击者将50030063816 wei数量的WBTC全部转入到hWBTC pool中,现在调用redeemUnderlying打算赎回50030063815 wei数量的WBTC。
可以看到攻击者只消耗了1wei的hWBTC就赎回了50030063816 wei数量的WBTC。
仅仅只用了1wei的hWBTC就赎回了50030063815 wei数量的WBTC,明显不合理。深入分析(为了方便分析,隐去部分和分析逻辑无关的代码):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 function redeemUnderlying(uint redeemAmount) external returns (uint) { return redeemUnderlyingInternal(redeemAmount); } function redeemUnderlyingInternal(uint redeemAmount) internal nonReentrant returns (uint) { uint error = accrueInterest(); if (error != uint(Error.NO_ERROR)) { // accrueInterest emits logs on errors, but we still want to log the fact that an attempted redeem failed return fail(Error(error), FailureInfo.REDEEM_ACCRUE_INTEREST_FAILED); } // redeemFresh emits redeem-specific logs on errors, so we don't need to return redeemFresh(msg.sender, 0, redeemAmount); } /** redeemer: attack contract redeemTokensIn: 0 redeemAmountIn: 50030063815 */ function redeemFresh(address payable redeemer, uint redeemTokensIn, uint redeemAmountIn) internal returns (uint) { require(redeemTokensIn == 0 || redeemAmountIn == 0, "one of redeemTokensIn or redeemAmountIn must be zero"); // exchangeRateMantissa: 25015031908500000000000000000 (vars.mathErr, vars.exchangeRateMantissa) = exchangeRateStoredInternal(); // redeemAmountIn: 50030063815 (vars.mathErr, vars.redeemTokens) = divScalarByExpTruncate(redeemAmountIn, Exp({mantissa: vars.exchangeRateMantissa})); // 计算得到 vars.redeemTokens: 1 if (vars.mathErr != MathError.NO_ERROR) { return failOpaque(Error.MATH_ERROR, FailureInfo.REDEEM_EXCHANGE_AMOUNT_CALCULATION_FAILED, uint(vars.mathErr)); } vars.redeemAmount = redeemAmountIn; // Fail if redeem not allowed uint allowed = comptroller.redeemAllowed(address(this), redeemer, vars.redeemTokens); doTransferOut(redeemer, vars.redeemAmount); return uint(Error.NO_ERROR); }
因为前期攻击者向转入到大量的WBTC,使得兑换比例变高,使得exchangeRateMantissa变为了25015031908500000000000000000。
其中计算需要的hwBTC的计算方法是:
1 (vars.mathErr, vars.redeemTokens) = divScalarByExpTruncate(redeemAmountIn, Exp({mantissa: vars.exchangeRateMantissa}));
代入到攻击计算,如果按照实际的进度计算,得到
但是如果在solidity中按照实际的计算,得到的结果是:
1 2 3 uint numerator = 50030063815000000000000000000; uint denominator = 25015031908500000000000000000; console.log(numerator /denominator ); // 1
所以在赎回的时候本需要2wei hwbtc,经过计算只需要1wei。
此时,在Attack-Contract-1已经成功赎回了50030063815 wei数量的WBTC,然后将所有的WBTC全部转入到Receiver合约(0x978D0CE23869EC666BFDE9868a8514F3D2754982)中。
至此,整个Attack-Contract-1在初始化阶段的所有的代码全部执行完毕了。
liquidateBorrow 攻击者首先通过liquidateCalculateSeizeTokens()计算,将当前所有的hwBTC全部清算。
由于当前在Attack-Contract-1只有1wei的hWBTC在池子中,通过liquidateBorrow执行清算,将Attack-Contract-1质押的1wei的hWBTC在池子全部提出来。
Receiver获得了1wei的hWBTC之后,通过redeem()的方式将这个hWBTC取回,那么此时在hWBTC pool中的hWBTC数量就是0.
以上就是一个完整的攻击流程。攻击者通过这种方式,套利了1021数量的ETH。
Hundred SNX 前面攻击者利用漏洞,通过借贷的方式获利了1021 ETH,攻击者利用同样的手法,借贷Hundred SNX
攻击者获得的Hundred SNX数量是20000006040813679379832 wei:
1 20000006141346503346858 - 100532823967026 = 20000006040813679379832
换算得到是20000006040813679379832/10**18 = 20000.006040813678
hUSDC 攻击手法同上。
攻击者获得的USDC的数量是:1233516758776 - 282 = 1233516758494wei,换算是1233516
uDAI
攻击者获得DAI的数量是:
1 842788494291179981359598 - 281293952180029 = 842788494009886029179569wei
换算得到842788
hUSDT
攻击者获得的USDT的数量是:
1 1113430652960 - 281 = 1113430652679wei
换算得到1113430
hSUSD
攻击者获得SUSD的数量是:
1 865142911345306136082953 - 281135788585887 = 865142911064170347497066wei
换算得到865142
获利 通过前面的分析,统计出攻击者的所有获利信息:
1 2 3 4 5 6 Attacker ETH balance after exploit: 1021.915074224867122534 Attacker USDC balance after exploit: 1233516.758493 Attacker SNX balance after exploit: 20000.006040813679379832 Attacker sUSD balance after exploit: 865142.911064170347497066 Attacker USDT balance after exploit: 1113430.652678 Attacker DAI balance after exploit: 842788.494009886029179569
计算这些代币的总价值,大约是7M
总结 hwbtc没有用户产生借贷,导致黑客可以控制totalsupply,数据向下取整导致redeemunderlying可以全部赎回。
所以,在实际的借贷产品中,需要需要额外注意对于预言机的选择,在本例中相当于是兑换价格被操控了,就可以借贷出大量的代币。
参考 https://mp.weixin.qq.com/s/Zn-xgx1nbEgSJk0d21VUew
https://mp.weixin.qq.com/s/YWixV-k6xcqSPkMqa5fSvw