Addressing the ShareStaking _checkpoint() Vulnerability: A Proactive Approach
Introduction
On October 30, 2023, the Tranchess team was notified via Tranchess’ Immunefi Bug Bounty program of a critical vulnerability in the ShareStaking contract’s _checkpoint()
function. This function updates the global reward checkpoint and ensures the total supplies are aligned with the latest fund rebalance version.
A temporary solution was immediately deployed post-discovery to halt all rebalance activities while the team worked on a permanent mitigation plan. At the time of this recap, the vulnerability had been mitigated. All funds are safe. A bounty of 200,000 USDC (the maximum bounty of the program) has been paid to the whitehat via Tranchess treasury. We want to thank them again for discovering and reporting the vulnerability and the ImmuneFi community for their support.
The Vulnerability
The vulnerability emerged from the _checkpoint()
function's logic that originally meant for skipping costly executions within the same block to save gas.
uint256 timestamp = _checkpointTimestamp;
if (timestamp >= block.timestamp) {
return;
}
An attacker could exploit this by causing the _checkpoint()
to be skipped, leading to a mismatch in total supplies after a rebalance.
Through a calculated series of transactions, the attacker could then use this discrepancy to manipulate the spareAmount
in deposit()
function to drain the funds in ShareStaking.
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");
}
Timeline of Events and Resolution
- Oct. 30, 2023, 09:50:00 UTC. Tranchess received the escalated report from Immunefi.
- Oct. 30, 2023, 16:15:00 UTC. Tranchess confirmed the report and proposed a temporary solution using
BscAprOracleProxy
. - Oct. 31, 2023, 03:00:00 UTC. The temporary solution was deployed to the BSC mainnet.
- Nov. 06, 2023, 09:00:00 UTC. The permanent solution was deployed to the BSC mainnet.
The Resolution
The fix focuses on preventing checkpoints before Fund.settle()
in the same block. Since Fund
contract is immutable itself, the BscAprOracle
and the BscAprOracle.capture()
function in particular, was modified to prevent the attack.
In the emergency patch, the BscAprOracleProxy
contract was introduced. This proxy contract is intended to mirror the behavior of BscAprOracle
, underlyingly calling BscAprOracle.capture()
to return the APR value. We explicitly lock in the fund version to the current version for BTCB, ETH and BNB funds, respectively. Any attempt to rebalance would result in a revert, which will 100% prevent the attack. The fund settlements are designed to be robust against the triggering time variation; therefore, if market shifts lead to any rebalance, the fund can always synchronize with the market movement once after the permanent fix in place.
function capture() external override returns (uint256 dailyRate) {
require(IFundV3(msg.sender).getRebalanceSize() == lockedVersion, "Version locked");
return IAprOracle(aprOracle).capture();
}
To fully address this issue, we have upgrade the BscAprOracleProxy
contract to function as a protective layer around the existing BscAprOracle
. The new mechanism detects a fund version change, and will deposit a significant enough amount of BISHOP and ROOK into the ShareStaking
contract to preemptively trigger this potential vulnerability. It then compares the total supply in terms of QUEEN in ShareStaking.totalSupply(TRANCHE_Q)
before and after. If a mismatch is found, the proxy flags the possibility of an attack attempt and reverts the transaction.
function capture() external override returns (uint256 dailyRate) {
uint256 newVersion = fund.getRebalanceSize();
if (newVersion != currentVersion) {
currentVersion = newVersion;
uint256 oldStakingQ = shareStaking.totalSupply(TRANCHE_Q);
shareStaking.deposit(TRANCHE_B, DEPOSIT_AMOUNT, address(this), newVersion);
shareStaking.deposit(TRANCHE_R, DEPOSIT_AMOUNT, address(this), newVersion);
uint256 newStakingQ = shareStaking.totalSupply(TRANCHE_Q);
require(newStakingQ == oldStakingQ, "Rebalance check failed");
shareStaking.withdraw(TRANCHE_B, DEPOSIT_AMOUNT, newVersion);
shareStaking.withdraw(TRANCHE_R, DEPOSIT_AMOUNT, newVersion);
}
return aprOracle.capture();
}
Note that with this fix, a malicious user, while theoretically capable of delaying the rebalance by frontrunning settle()
continuously with this attack, would find the strategy economically unsound over time. The associated costs of such an attack would quickly outweigh the potential gains, if any.
Conclusion
The swift response to the _checkpoint()
vulnerability reflects the commitment to security at Tranchess. It serves as a reminder of the continuous vigilance required to safeguard user funds and maintain trust in decentralized platforms. With the emergency patch 87b4dfc
and the permanent fix aa53c2b
now in place, users can rest assured that proactive measures are being taken to mitigate such risks in the future.
For those interested in the technical details, the full implementation and tests of the fix are available in the public repository BscAprOracleProxy and CheckpointBypassAttack test cases.
Emergency fix: 87b4dfc24b3b7d708e8821a22f6cb4413d035b7d
Permanent fix: aa53c2bf716e9e93cfc4f4af67df3458751a29e6
2024.01.23 Update
In the initial implementation for BscAprOracleProxy
, the team has been made aware of another medium-level security bug, particularly concerning the crashing price of the underlying asset. This scenario, although rare, would trigger a condition where BISHOP and ROOK amounts are reduced to zero for holders, rendering rebalance completion indefinitely delaying the settlement of new days for the fund. In detail, the flaw emerges when the crashing price caused the rebalance, resulting in all BISHOP and ROOK balances and allowances being set to zero and BISHOP holders receiving QUEEN. This situation would cause the ShareStaking contract to revert due to zero allowances, and the BscAprOracleProxy’s capture() logic would fail.
In response to the whitehat’s report, we made further changes involving splitting any existing QUEEN in the BscAprOracleProxy
contract and reapproving BISHOP/ROOK before making the deposit.