Overview
Table of Contents
On June 17, 2023, the project named Midas Capital, operating on the BNB Chain, suffered a hack resulting in a loss of approximately $600,000 worth of assets. The attackers exploited a rounding error in the lending protocol, leveraged flash loans, and artificially inflated voucher values to execute this breach.
About Midas Capital
Midas Capital is a lending platform operating on the BNB chain, specifically designed for partners from Ankr and Helio Finance. It is a cross-chain money market solution that unlocks and maximizes the usage of all digital assets.
What Caused the Hack?
The core vulnerability that led to the exploit at Midas Capital was due to a rounding issue within its lending protocol. This protocol, which was a derivative of the V2 codebase of Compound Finance, had a flawed redemption process. Specifically, the attacker manipulated the exchange rate due to a bug related to division calculation during the redeem tokens operation.
What is a Rounding Issue?
A minor difference when rounding numbers, especially in financial contexts, can lead to significant discrepancies. In the case of blockchain and smart contracts, even a tiny rounding error can be exploited to generate vast profits if the flaw is detected by malicious actors.
Detailed Analysis
At the heart of the issue was the contract’s mismanagement of the redeem counter function in the smart contract.
The function allowed for multiple redemptions via the mint function based on token amounts without proper checks. Specifically, the attacker manipulated the exchange rate due to a bug related to division calculation during the redeem tokens operation.
Here’s a code snippet highlighting the problematic areas:
/**
* @notice User redeems cTokens in exchange for the underlying asset
* @dev Assumes interest has already been accrued up to the current block
* @param redeemer The address of the account which is redeeming the tokens
* @param redeemTokensIn The number of cTokens to redeem into underlying
* (only one of redeemTokensIn or redeemAmountIn may be non-zero)
* @param redeemAmountIn The number of underlying tokens to receive from redeeming cTokens
* (only one of redeemTokensIn or redeemAmountIn may be non-zero)
* @return uint 0=success, otherwise a failure (see ErrorReporter.sol for details)
*/
function redeemFresh(address redeemer, uint256 redeemTokensIn, uint256 redeemAmountIn) internal returns (uint256) {
require(redeemTokensIn == 0 || redeemAmountIn == 0, "!redeemTokensInorOut!=0");
RedeemLocalVars memory vars;
/* exchangeRate = invoke Exchange Rate Stored() */
(vars.mathErr, vars.exchangeRateMantissa) = exchangeRateStoredInternal();
if (vars.mathErr != MathError.NO_ERROR) {
return failOpaque(Error.MATH_ERROR, FailureInfo.REDEEM_EXCHANGE_RATE_READ_FAILED, uint256(vars.mathErr));
}
if (redeemAmountIn == type(uint256).max) {
redeemAmountIn = comptroller.getMaxRedeemOrBorrow(redeemer, address(this), false);
}
/* If redeemTokensIn > 0: */
if (redeemTokensIn > 0) {
/*
* We calculate the exchange rate and the amount of underlying to be redeemed:
* redeemTokens = redeemTokensIn
* redeemAmount = redeemTokensIn x exchangeRateCurrent
*/
vars.redeemTokens = redeemTokensIn;
(vars.mathErr, vars.redeemAmount) = mulScalarTruncate(Exp({mantissa: vars.exchangeRateMantissa}), redeemTokensIn);
if (vars.mathErr != MathError.NO_ERROR) {
return
failOpaque(Error.MATH_ERROR, FailureInfo.REDEEM_EXCHANGE_TOKENS_CALCULATION_FAILED, uint256(vars.mathErr));
}
} else {
/*
* We get the current exchange rate and calculate the amount to be redeemed:
* redeemTokens = redeemAmountIn / exchangeRate
* redeemAmount = redeemAmountIn
*/
(vars.mathErr, vars.redeemTokens) =
divScalarByExpTruncate(redeemAmountIn, Exp({mantissa: vars.exchangeRateMantissa}));
if (vars.mathErr != MathError.NO_ERROR) {
return
failOpaque(Error.MATH_ERROR, FailureInfo.REDEEM_EXCHANGE_AMOUNT_CALCULATION_FAILED, uint256(vars.mathErr));
}
vars.redeemAmount = redeemAmountIn;
}
/* Fail if redeem not allowed */
uint256 allowed = comptroller.redeemAllowed(address(this), redeemer, vars.redeemTokens);
if (allowed != 0) {
return failOpaque(Error.COMPTROLLER_REJECTION, FailureInfo.REDEEM_COMPTROLLER_REJECTION, allowed);
}
/* Verify market's block number equals current block number */
if (accrualBlockNumber != getBlockNumber()) {
return fail(Error.MARKET_NOT_FRESH, FailureInfo.REDEEM_FRESHNESS_CHECK);
}
/*
* We calculate the new total supply and redeemer balance, checking for underflow:
* totalSupplyNew = totalSupply - redeemTokens
* accountTokensNew = accountTokens[redeemer] - redeemTokens
*/
(vars.mathErr, vars.totalSupplyNew) = subUInt(totalSupply, vars.redeemTokens);
if (vars.mathErr != MathError.NO_ERROR) {
return failOpaque(Error.MATH_ERROR, FailureInfo.REDEEM_NEW_TOTAL_SUPPLY_CALCULATION_FAILED, uint256(vars.mathErr));
}
(vars.mathErr, vars.accountTokensNew) = subUInt(accountTokens[redeemer], vars.redeemTokens);
if (vars.mathErr != MathError.NO_ERROR) {
return
failOpaque(Error.MATH_ERROR, FailureInfo.REDEEM_NEW_ACCOUNT_BALANCE_CALCULATION_FAILED, uint256(vars.mathErr));
}
/* Fail gracefully if protocol has insufficient cash */
if (getCashPrior() < vars.redeemAmount) {
return fail(Error.TOKEN_INSUFFICIENT_CASH, FailureInfo.REDEEM_TRANSFER_OUT_NOT_POSSIBLE);
}
/////////////////////////
// EFFECTS & INTERACTIONS
// (No safe failures beyond this point)
/* We write previously calculated values into storage */
totalSupply = vars.totalSupplyNew;
accountTokens[redeemer] = vars.accountTokensNew;
/*
* We invoke doTransferOut for the redeemer and the redeemAmount.
* Note: The cToken must handle variations between ERC-20 and ETH underlying.
* On success, the cToken has redeemAmount less of cash.
* doTransferOut reverts if anything goes wrong, since we can't be sure if side effects occurred.
*/
doTransferOut(redeemer, vars.redeemAmount);
/* We emit a Transfer event, and a Redeem event */
emit Transfer(redeemer, address(this), vars.redeemTokens);
emit Redeem(redeemer, vars.redeemAmount, vars.redeemTokens);
/* We call the defense hook */
comptroller.redeemVerify(address(this), redeemer, vars.redeemAmount, vars.redeemTokens);
return uint256(Error.NO_ERROR);
}
This vulnerability was then exacerbated when the attacker initially took a flash loan of ANKR-like tokens. They then deposited other assets to acquire vouchers, subsequently transferring all of them into the pool. This action artificially inflated the voucher values, allowing the attacker to boost their borrowing capacity.
The attacker exploited the vulnerability in two distinct phases:
Phase 1: Supplying Collateral and Minting LP Tokens
- The attacker utilized a special smart contract named “borrower”.
- Minted and supplied 21,184 LP tokens using the root attack contract.
- Most of the cTokens were redeemed, leaving only 1001 wei in the address.
- Donated the redeemed 21,184 LP tokens to the market.
- Transferred the total supply of 1001 Wei LP tokens to the borrower contract.
- Used a flash loan of ANKR to borrow $25,000 worth of HAY and 1,148 ankrBNB.
- Swapped ankrBNB for BUSD using the root attack contract.
- Minted 259,826.61 HAY/BUSD LP tokens valued at about $519,000.
- The LP tokens were backed by $220,000 worth of HAY and $22.3k worth of BUSD from the attacker and borrowed assets from the Ankr-Helio pool.
Phase 2: Borrowing Assets, Repaying Flash Loan Fees, and Exploiting the Vulnerability
- The attacker was able to borrow assets from the Ankr-Helio pool because they supplied $689k worth of ANKR collateral that they flash-loaned from Thena and PancakeSwap.
- Borrowed 115 ankrBNB (worth about $28,700) and supplied it back from another contract named “liquidator”, bypassing debt ceilings.
- With the fresh collateral behind 1001 wei of cTokens (worth approximately $519k), the attacker borrowed a smaller amount of ANKR from the pool (worth around $13.5k) to repay the flash loan fees.
- Redeemed the supplied ANKR worth $689,000, leaving an account balance in the pool with $519,000 worth of collateral and $320,000 worth of debt.
- Repaid the flash loan and ended their first transaction of the exploit by withdrawing 519.13 LP tokens for just 1 wei of the market cToken. This specific amount was due to a calculation exploiting the rounding error.
- After the flash loan transaction, the attacker looped transactions to provide HAY/BUSD LP tokens of an amount equal to 1 wei for cTokens and withdraw LP tokens that are equal to 1.998 wei of cTokens — rounded down to just 1 wei.
- In the final stages, when the value of the chipped LP tokens reached around $30k, the attacker used 90.9 out of their 115 supplied ankrBNB to liquidate their own bad debt, seizing the LP tokens and redeeming them for the remaining value.
Here are the key details of the exploit
- Attacker’s Address:
0x4b92cC3452Ef1E37528470495B86d3F976470734
- Attack Contract:
0xC40119C7269A5FA813d878BF83d14E3462fC8Fde
- Vulnerable Contract:
0xF8527Dc5611B589CbB365aCACaac0d1DC70b25cB
- Attack Transaction:
0x1ebc03f0f2257c275f4990b4130e6c3e451125aa98ee8bbde8aba5dc0320c659
- Borrower Smart Contract Address:
0xd2094b870D80Cfb7DaDa4893aD0030d642CA9f72
Stolen Fund Details
Post-exploitation, assets approximating the value of 510 BNB were laundered by the exploiter to Tornado Cash. Some of these assets were also bridged to the Ethereum blockchain.
Hack Aftermath
Following is the list of events that happened post-exploit:
- Initially, all pools under Midas Capital were halted but were gradually re-opened later.
- Midas Capital began re-evaluating its business proposition and strategic direction.
- A final fix for the bug was implemented.
Lessons Learnt
These were the learning from the Midas Capital exploit:
- Properly audit and test all code, especially when forking from another project.
- Implement and adhere to strict limits for borrowing and lending.
- Continuously monitor and assess the system for vulnerabilities, especially when new features or tokens are added.
Conclusion
The Midas Capital incident serves as a poignant reminder of the intricacies and vulnerabilities present in blockchain protocols. Proper code audits, consistent monitoring, and rigorous testing are imperative. Blockchain security is paramount, and engaging with proficient auditors like Immunebytes can prevent such unfortunate incidents.