I was searching on Immunefi for projects that piqued my interest, scanning contract after contract, the Tranchess protocol caught my eye. It has every component developed from the ground up, with quite a unique implementation on the risk/return matrix compared to other yield-farming protocols. This uniqueness of the codebase steered me to anticipate the presence of a protocol-specific type of bug that oftentimes leads to surprising damage.
- On Oct. 30, 2023, at 09:50:00 UTC, an attack vector in the
ShareStaking
contract was disclosed. It was mitigated by the Tranchess team with a temporary fix within 18 hours of its receipt, the permanent solution was deployed 6 days later. User assets remain secure and there were no losses. - As part of the security process, the Tranchess team has acknowledged and rewarded me with the maximum bounty of $200,000 from the Tranchess treasury.
- The vulnerability originated from the omission of the
_checkpoint()
function, leading to a potential mismatch of the tokens' balance theShareStaking
contract holds. - An important note from the vulnerability for developers and security researchers is to pay close attention to any gas optimization techniques. A seemingly innocuous method to cut execution costs can at times cause dangerous behaviours.
Tranchess is a yield-enhancing asset tracker protocol with varied risk-return solutions. It provides a different risk/return matrix out of a single main fund that tracks a specific underlying asset (e.g. BTC, ETH, BNB) or a basket of crypto assets.
The main fund is an asset tracking index fund. Queen’s Net Asset Value (NAV) tracks the underlying asset's price on a fully correlated basis with deduction of protocol fees. Token Queen can be further split into/merge from two sub-tranches, token Bishop and token Rook. Token Rook leverages exposure to the main fund without forced liquidation risk. Token Bishop provides BUSD yield at a variable interest rate.
ShareStaking.deposit()
The deposit()
function enables users to stake their Queen/Bishop/Rook tokens. The crucial variable, spareAmount
within this function is the amount of tokens the ShareStaking
contract has received for a given deposit, which is determined by calculating the disparity between token total supply of the ShareStaking
contract and the actual token balance the contract holds.
function deposit(uint256 tranche, uint256 amount, address recipient, uint256 version) external {
_checkpoint(version);
_userCheckpoint(recipient, version);
_balances[recipient][tranche] = _balances[recipient][tranche].add(amount);
uint256 oldTotalSupply = _totalSupplies[tranche];
_totalSupplies[tranche] = oldTotalSupply.add(amount);
_updateWorkingBalance(recipient, version);
uint256 spareAmount = fund.trancheBalanceOf(tranche, address(this)).sub(oldTotalSupply);
if (spareAmount < amount) {
// Retain the rest of share token (version is checked by the fund)
fund.trancheTransferFrom(
tranche,
msg.sender,
address(this),
amount - spareAmount,
version
);
} else {
require(version == _fundRebalanceSize(), "Invalid version");
}
emit Deposited(tranche, recipient, amount);
}
Rebalance mechanism and ShareStaking._checkpoint()
A rebalance can be initiated in the FundV3
contract when the Fair Value ratio (ROOK/BISHOP) is below 0.5 or over 2. A rebalance will reset this ratio back to 1. Following a rebalance event, the balance of tokens in the ShareStaking
contract will change due to the adjustment of the fair value of token BISHOP and ROOK. This change involves an increase in the Q balance (information about the additional Q amount can be found here).
The vulnerability stems from _checkpoint()
function, which is responsible for making a global reward checkpoint and updating the token total supplies based on the latest rebalance version.
The _checkpoint()
will be skipped if we have called _checkpoint()
in the same block previously.
function _checkpoint(uint256 rebalanceSize) private {
uint256 timestamp = _checkpointTimestamp;
if (timestamp >= block.timestamp) {
return;
}
...
}
Exploiting the vulnerability
In the transaction that triggers a rebalance, if the attacker calls _checkpoint
earlier and causes the subsequent checkpoint()
within deposit()
to be skipped, the spareAmount
value for the Queen tranche would become the amount of Queen tokens that is drainable from the ShareStaking
contract. This happens because the Queen total supply has not been synchronized with the ShareStaking
contract's Queen balance of the most recent rebalance version stored in the FundV3
contract.
If the attacker has the fund, they can obtain Bishop and Rook tokens to deposit them into the ShareStaking
contract before the rebalance in order to increase the spareAmount
value (since the more Bishop and Rook tokens the ShareStaking
contract holds, the more Queen tokens it will receive after a rebalance).
Otherwise, the summarized attack steps are as follows:
-
The attacker monitors the underlying price and waits for the time when they can initiate a rebalance by calling
settle()
in theFundV3
contract, potentially employing frontrunning and private transaction services (accessible at https://bloxroute.com/ for BSC chain) to execute the rebalance before the Tranchess team does. -
When the price reaches the rebalance threshold at 14:00 UTC (the settlement time), the attacker calls
claimableRewards()
in theShareStaking
contract with the argument of any address other than the attacker's address (to prevent the transaction from reverting later due to subtraction overflow). The purpose of this call is to invoke_checkpoint()
in theShareStaking
contract, causing it to skip the subsequent_checkpoint()
when we invoke later in the same transaction indeposit()
. -
The attacker proceeds to call
settle()
in theFundV3
contract, triggering a rebalance. -
The attacker calls
deposit()
function in theShareStaking
contract to deposit tranche Q, with theamount
argument being precomputed and equal to thespareAmount
value within thedeposit()
function. -
Finally, the attacker withdraws and redeem the drained Q to obtain underlying tokens, successfully drains users' funds.
Check out the POC here
Whenever the condition is right for a rebalance to happen, an attacker can directly steal funds from existing stakers. The impact of this attack depends on the size of the attacker's fund:
-
If the attacker possesses a fund, the maximum total loss is approximately 815.1 BTC and 1438.5 ETH on BSC chain, based on the on-chain funds at the time of reporting.
-
If the attacker does not possess any fund, the total loss is approximately 46.8 BTC and 156.2 ETH on BSC chain, based on the on-chain funds at the time of reporting.
Following the attack, the ShareStaking
contract within the Tranchess protocol will become insolvent, and the deposit()
function in the contract will always revert due to subtraction overflow.
Additionally, the accounting of _workingSupply
and _totalSupplies
for the three tranches in the ShareStaking
will be perpetually miscalculated.
For a detailed explanation of the mitigation, please refer to the Tranchess team's publication.