HundredFinance-vuln

说明

本漏洞的原理如果深入分析之后,发现其实是一个简单的问题。因为底层的兑换采用的是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-1Attack 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

文章作者: Oooverflow
文章链接: https://www.oooverflow.com/2023/04/17/HundredFinance-vuln/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Oooverflow