Open hats-bug-reporter[bot] opened 1 month ago
@Ghoulouis Just pinging to see if there are any updates on this issue. I am the submitter and just seeing where this issue is at in regards to the judging process. Happy to discuss this finding further or provide further testing if needed!
The main problem lies in the _calc_withdraw_one_coin
function and its interactions with other internal functions. Let's break it down:
_calc_withdraw_one_coin
functionThis internal function is the primary source of the issue. Here's why:
function _calc_withdraw_one_coin(uint256 _token_amount, uint256 i) internal view returns (uint256, uint256) {
uint256 amp = get_A();
uint256 _fee = (fee * N_COINS) / (4 * (N_COINS - 1));
uint256[N_COINS] memory precisions = PRECISION_MUL;
uint256 total_supply = token.totalSupply();
uint256[N_COINS] memory xp = _xp();
uint256 D0 = get_D(xp, amp);
uint256 D1 = D0 - (_token_amount * D0) / total_supply;
uint256[N_COINS] memory xp_reduced = xp;
uint256 new_y = get_y_D(amp, i, xp, D1);
uint256 dy_0 = (xp[i] - new_y) / precisions[i]; // w/o fees
for (uint256 k = 0; k < N_COINS; k++) {
uint256 dx_expected;
if (k == i) {
dx_expected = (xp[k] * D1) / D0 - new_y;
} else {
dx_expected = xp[k] - (xp[k] * D1) / D0;
}
xp_reduced[k] -= (_fee * dx_expected) / FEE_DENOMINATOR;
}
uint256 dy = xp_reduced[i] - get_y_D(amp, i, xp_reduced, D1);
dy = (dy - 1) / precisions[i]; // Withdraw less to account for rounding errors
return (dy, dy_0 - dy);
}
Key issues within this function:
a. Precision Loss: The function performs multiple divisions and subtractions with large numbers, leading to precision loss. This is especially problematic for dust amounts.
b. Rounding Down: The line dy = (dy - 1) / precisions[i];
intentionally rounds down the withdrawal amount to account for rounding errors. For dust amounts, this could result in zero output, causing the transaction to fail but still updating the state.
c. Fee Calculation: The fee calculation in the loop can lead to very small changes in xp_reduced
for dust amounts, which compound over multiple attempts.
a. get_A()
:
b. get_D(xp, amp)
:
c. get_y_D(amp, i, xp, D)
:
_calc_withdraw_one_coin
, and any inaccuracies here are magnified.The remove_liquidity_one_coin
function, which calls _calc_withdraw_one_coin
, updates the state even when the withdrawal fails:
function remove_liquidity_one_coin(uint256 _token_amount, uint256 i, uint256 min_amount) external nonReentrant {
// ... (other code)
uint256 value = (balances[i] * _token_amount) / total_supply;
require(value >= min_amount, "Not enough coins removed");
balances[i] -= value;
token.burnFrom(msg.sender, _token_amount);
// ... (other code)
}
The require
statement checks if the withdrawal amount is sufficient, but the state calculations leading up to this point are not reverted if the check fails.
The root cause of this issue is the cumulative effect of precision loss and state updates in the _calc_withdraw_one_coin
function and its supporting functions, even when withdrawals fail due to dust amounts. The intentional rounding down for safety paradoxically creates a vulnerability when combined with the contract's behavior of not reverting state calculations for failed withdrawals.
To fix this, the contract should:
_calc_withdraw_one_coin
.
Github username: -- Twitter username: -- Submission hash (on-chain): 0x230b05832b3595fe7212d5d7b62d9ada40acefca622796098244d30516c9c7a9 Severity: medium
Description: Description
The
StableSwapThreePool
contract'sremove_liquidity_one_coin
function and its supporting internal functions are vulnerable to manipulation through repeated dust withdrawal attempts. These attempts, while failing, lead to cumulative precision losses and potential state inconsistencies that significantly impact subsequent legitimate large withdrawals.Malicious actors could exploit this vulnerability to manipulate the pool's state, causing users to receive substantially less than expected when making large withdrawals. My tests demonstrated a reduction of approximately 40% in the amount received during a large withdrawal after multiple failed dust withdrawal attempts. This could result in significant financial losses for users, and potentially be used as a vector for DOS attacks by exhausting users' gas limits.
Proof of Concept (PoC)
Issue Summary:
The core of the issue lies in the contract's handling of dust withdrawal attempts. While these attempts fail, they trigger complex calculations involving multiple internal functions. These calculations, even when failing, can lead to cumulative precision losses and potential state changes. The main affected functions are
remove_liquidity_one_coin
,_calc_withdraw_one_coin
,get_y_D
,get_D
, andget_A
. The combination of precision loss, rounding errors, and potential state inconsistencies across these function calls results in significant discrepancies for large withdrawals performed after multiple dust withdrawal attempts.Here is the foundry test suite created that showcases the issue.
Here is the test suite output that assist in discovering this issue.
Revised Code File
The proposed fix introduces a minimum withdrawal amount and a dust accumulation mechanism. This prevents calculations with dust amounts, reduces gas consumption for failed attempts, and allows users to eventually withdraw accumulated dust. Additionally, the
_calc_withdraw_one_coin
function should be modified to use higher precision for intermediate calculations.