Tender. fi Attack Walk-Through

Branch Indigo
System Weakness
Published in
7 min readMar 13, 2023

--

Photo by Shubham Dhage on Unsplash

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.

Transaction (0x38ae60739af0726831957546d9d16c92ed75164a1581d4e4e6f270917913ab9c)

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.

Mint Transaction (0xaebc68faecfcf6dcf06fed038572510091e6fb37037b5bbf525a89f502a11110)

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.

Attacker borrowed 100 ETH (0xc6e90441a684f370f0a1cd845e0d639088df20548a5c2d37f770cc85ea7306f9)

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.

--

--