Tender. fi Attack Walk-Through
The recent whitehat attack on Tender. fi’s lending protocol stole $1.59 million funds from the liquidity pools. Let’s trace back the attack step by step on Arbiscan to see how the exploit took place and discover key vulnerabilities.
Context
Tender. fi is a crypto borrowing and lending platform. Tender protocol is designed on top of Compound’s V2 protocols, which allow users to borrow any supported crypto assets given proportional deposits as collaterals. Unlike compounds, Tender fi also allows using novel crypto assets such as GMX and GLP as collaterals.
The Attack
After the funds were stolen, the whitehat signed an on-chain message alerting Tender’s team which mentions a certain vulnerability in their price oracle. This allows the team to move fast to inspect their smart contracts. After negotiation, the attacker paid back the stolen funds and received $97,000 as bug bounty rewards. We will trace the attack from the beginning.
GMX as collateral
The whitehat first transferred 0.1 ETH
to the attacker account (0x896DF3759205C141c97640B2B7345FA479FEB1aB), then interacted with UniswapRouter swapping out 1 GMX
token. Note GMX is a leading decentralized derivatives trading platform and GMX token is its governance token.
The attacker then deposited 1 GMX
token to Tender.fi‘s GMX lending pool and received 19537 tGMX
as liquidity pool tokens to represent his position in the pool. Note that the amount of liquidity pool tokens is calculated based on the deposit amount of underlying assets and exchangeRate
as defined by Compound V2 protocols here.
In the step above, the attacker interacted first with GMX ERC20 token contract to approve the liquidity pool contract CERC20DelegatorGMX.sol
to spend GMX and then called mint
function on CERC20DelegatorGMX.sol
to finish the deposit.
Now the attacker is ready to borrow assets from Tender. fi using the deposited GMX as collateral. Everything seems normal up to this point.
Borrowing
We then see a series of attacks by borrowing multiple assets disproportionate to the collateral. Among these transfers, the attacker managed to borrow 100ETH
, 1000 USDC
, 15 wBTC
, 16203 Link
etc.
In this step, the attacker interacted with multiple liquidity pool contracts by calling a single borrow
function. Similar to compound V2, a user can only borrow assets less than the combined value of the assets they deposited. And this is enforced with collateral factor
, a percentage (0–90%) of the total deposited value.
Upon checking the liquidity pool contracts, we see they all inherit CToken.sol
which defines the logic of borrow
. Under the hood, CToken.sol
calls Comptroller.sol
(the authorizer logic contract) to verify the eligibility of the account with the specified borrow amount by invoking borrowAllowed
function. And the key part of borrowAllowed
is checking the total collateral value of the user account by invoking internal getHypotheticalAccountLiquidity
. This is what we need to focus on.
//Comptroller.sol
function getHypotheticalAccountLiquidityInternal(
address account,
CToken cTokenModify,
uint redeemTokens,
uint borrowAmount,
bool liquidation) internal view returns (Error, uint, uint) {
...
// For each asset the account is in
CToken[] memory assets = accountAssets[account];
for (uint i = 0; i < assets.length; i++) {
CToken asset = assets[i];
// Read the balances and exchange rate from the cToken
(oErr, vars.cTokenBalance, vars.borrowBalance, vars.exchangeRateMantissa) = asset.getAccountSnapshot(account);
...
// Get the normalized price of the asset
vars.oraclePriceMantissa = oracle.getUnderlyingPrice(asset);
if (vars.oraclePriceMantissa == 0) {
return (Error.PRICE_ERROR, 0, 0);
}
vars.oraclePrice = Exp({mantissa: vars.oraclePriceMantissa});
// Pre-compute a conversion factor from tokens -> ether (normalized price value)
vars.tokensToDenom = mul_(mul_(vars.collateralFactor, vars.exchangeRate), vars.oraclePrice);
// sumCollateral += tokensToDenom * cTokenBalance
vars.sumCollateral = mul_ScalarTruncateAddUInt(vars.tokensToDenom, vars.cTokenBalance, vars.sumCollateral);
// sumBorrowPlusEffects += oraclePrice * borrowBalance
vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.oraclePrice, vars.borrowBalance, vars.sumBorrowPlusEffects);
... }
// These are safe, as the underflow condition is checked first
if (vars.sumCollateral > vars.sumBorrowPlusEffects) {
return (Error.NO_ERROR, vars.sumCollateral - vars.sumBorrowPlusEffects, 0);
} else {
return (Error.NO_ERROR, 0, vars.sumBorrowPlusEffects - vars.sumCollateral);
}
}
There are two things we need to know in getHypotheticalAccountLiquidity
function. First, the combined collateral value is compared with the total borrow value to determine whether the action can be authorized. Comptroller.sol
combines all assets the user owns in USD to figure out whether there is still collateral left that can be borrowed against. This is done in for loop where each asset’s price is fetched from the oracle through oracle.getUnderlyingPrice(asset)
. Then calculated values are added to vars.sumCollateral
and vars.sumBorrowPlusEffects
at the end of each loop.
Second, Comptroller.sol
expects prices fetched from oracles to be in 18 decimals. We see thatoracle.getUnderlyingPrice(asset)
is assigned to EXP
— a struct type that expects an 18-decimal uin256
input value. With these two points in mind, let’s look at price oracles.
//ExponentialNoError.sol
//Exp is a struct which stores decimals with a fixed precision of 18 decimal places.
// Thus, if we wanted to store the 5.1, mantissa would store 5.1e18. That is:
//`Exp({mantissa: 5100000000000000000})`.
struct Exp {
uint mantissa;
}
Price Oracles
From the borrow
internal transaction history on Arbiscan, we can find the original oracle contract exploited GMXPriceOracle.sol
(0x614157925D4b6f7396cde6434998bFD04789272D).
Upon looking at getUnderlyingPrice
function in GMXPriceOracle.sol
, we might notice some potential vulnerabilities.
//GMXPriceOracle.sol
function getGmxPrice() public view returns (uint256) {
return gmxTokenPriceOracle.latestAnswer().mul(1e20);
}
function getUnderlyingPrice(CToken cToken) public override view returns (uint) {
if(cToken.isGLP()){
return getGlpAum().mul(1e18).div(glpToken.totalSupply());
} else if(compareStrings(cToken.symbol(), "tGMX")){
return getGmxPrice().mul(1e10);
} else if(compareStrings(cToken.symbol(), "tTND")){
// four hour twap
uint32 interval = 60*60*4;
return tndOracle.getTndPrice(interval);
} else {
IERC20 underlying = IERC20(_getUnderlyingAddress(cToken));
uint256 decimals = underlying.decimals();
uint256 defaultDecimals = 18;
return gmxPriceFeed.getPrice(_getUnderlyingAddress(cToken), true, true, false).mul(10**(defaultDecimals.sub(decimals).add(defaultDecimals))).div(1e30);
}
}
(1) Different price oracle implementations. This increases complexities in unifying formats of the price. Different oracle service providers may implement different decimals. And if it’s Twap oracle, the time interval configuration needs to be consistent.
In getUnderlyingPrice
, there are three sources of prices. For GMX token, it directly pulls prices from chainlink which returns non-ETH prices in 8 decimals. For TND, it pulls prices from UniswapV3 TND/USD pool and converted returned prices to 30 decimals. For GLP and other ERC20 tokens, it pulls prices from existing GMX oracles, which return prices in 30 decimals.
This gave the attacker opportunity to exploit. Note that in getGMXPrice()
function, chainlink’s oracle price in 8 decimal is returned by invoking latestAnswer()
. This number is then scaled up by 1e20
.The return value now has 28 decimals. In getUnderlyingPrice()
, when GMX token price is called for, the function scales the price again by 1e10
. Now the GMX token price is scaled up by 38 decimals.
Since only prices in 18 decimals were factored into accounting, we see that GMX token values were inflated 10²⁰ times as before. This allowed the attacker to borrow a significant amount of assets.
In addition, one might also notice the TND token price was not scaled properly. In getUnderlyingPrice()
, TND token price is directly returned without scaling. But as mentioned above, the TND price fetched from TndOracle.sol is in 30 decimals. This means that TND token values were inflated 1¹² times when used in Comptroller.sol
.
(2) Verify tokens by comparing token symbols. This potentially allows other token contracts with the same symbol to pass. A bad actor may create a fake cToken.sol
contract with listed Token symbols in the market and get quoted with the real token price.
Fortunately, in the context of Tender. fi, this is safeguarded by checks added in addToMarketInternal
function, which verifies whether a given asset is listed by the market before allowing a user to enter it into liquidity calculation.
//Comptroller.sol
function addToMarketInternal(CToken cToken, address borrower)
internal
returns (Error)
{
Market storage marketToJoin = markets[address(cToken)];
if (!marketToJoin.isListed) {
// market is not listed, cannot join
return Error.MARKET_NOT_LISTED;
}
...}
After the attack
Quick actions were taken by the team, including updating the comptroller proxy with a new oracle contract which streamlines the price fetching process.
In the new price oracle TenderPriceOracle.sol
, we see that except for GLP, all other tokens including GMX are fetched directly from chainlink oracles. And TND price is no longer fetched in his function. Since GLP is an index token with a unique price calculation, the price has to be fetched from GLPManager.sol
, a deployed GMX contract.
//TenderPriceOracle.sol
function getUnderlyingPrice(CToken ctoken) public view returns (uint) {
bytes32 key = stringToBytes(ctoken.symbol());
if(ctoken.isGLP()) {
return getGlpPrice();
}
ChainLinkPriceOracle oracle = ChainLinkPriceOracle(Oracles[key]);
// scale to USD value with 18 decimals
return oracle.latestAnswer().mul(10**(28-getUnderlyingDecimals(ctoken)));
}
}
Note that although chainlink, uniswapV3, and GMX’s VaultPricefeed each have their own advantage in verifying token prices, using them together in one contract needs to be done with caution. Compound V2’s open price feed and GMX’s VaultPriceFeed offer a good example of how two oracles can be used together to cross-check price reporting.
Thanks for reading! And credit to Numen lab for monitoring this attack in real-time.