Purchase token with zero USDC repeat
Description
Function _getAverageCostInY
performs the actual trade on the curve for an amount of tokens, returning the cost in USDC scaled.
First, remainingDistance
, isForward
, and stepRemaining
are defined as below:
uint256 remainingDistance = fMath.dist(x_0, x_1) // x_1 - x_0. influenced by the buy amount.
bool isForward = x_1 >= x_0; // In buy process, most case follow: x_1 >= x_0
uint256 finalX = lastStep.x_rel;
stepRemaining = finalX > 0 ? (isForward ? currentStep.dx - finalX : finalX) : currentStep.dx;
When the buy amount is sufficiently small, remainingDistance
becomes less than stepRemaining
. In this case, the execution will pass through the conditional statement below,
if (remainingDistance <= stepRemaining) {
// Final partial step
uint256 y = _calculateY(currentStep, remainingDistance);
totalY += y;
// [...]
if (remainingDistance == stepRemaining) {
finalIdx = isForward ? uint16(finalIdx + 1) : finalIdx > 0 ? uint16(finalIdx - 1) : 0;
}
// [...]
break;
}
and the _calculateY
return value will be totalY
, the average value Y.
This value will be divided with PRECISION_MULTIPLIER
and QUOTE_SCALING
. Consequently, if the value of Y at this point is less than PRECISION_MULTIPLIER * QUOTE_SCALING
, it will be rounded down to 0. This enables the purchase of tokens for zero USDC.
During this process, finalIdx
is only updated when remainingDistance
equals stepRemaining
. Consequently, if these values are inconsistent, finalIdx
remains unchanged.
Subsequently, newLastStep
is returned as shown below. This return value will subsequently alter currentStep
.
newLastStep = LastStepData({
i: finalIdx,
x_rel: remainingDistance < stepRemaining
? (isForward ? remainingDistance : stepRemaining - remainingDistance)
: 0,
x_abs: uint256(int256(lastStep.x_abs) + totalStepDeltaX)
});
When remainingDistance
is less than stepRemaining
, during a buy operation, since isForward
is true, x_rel
becomes stepRemaining
.
If the same amount
is subsequently bought again, the value of remainingDistance
remains unchanged, while stepRemaining
becomes currentStep.dx - lastStep.x_rel(=remainingDistance)
.
In this case as well, if remainingDistance
is less than stepRemaining
, the above process repeats, potentially allowing for infinite repetition of this cycle.
Impact
To execute this attack, the quantity of LaunchToken that can be purchased at once is approximately 1e17, which is significantly small compared to the total supply of 1e27. However, this method enables unlimited initial purchases at zero USDC. This vulnerability has substantial potential impact if the token price increases due to factors such as listing on Uniswap pools or similar events.
Recommendations
Either enhance the division policy to prevent losses or implement precise step updates. While blocking purchases at zero USDC is another potential solution.
Remediation
This issue has been acknowledged by Liquid Labs, Inc., and a fix was implemented in commit df320180↗.
Liquid Labs, Inc. provided the following response:
The commit goes with Zellic's second recommendation of cleaning the amountIn using (amountIn / 1e18) * 1e18. This prevents buys that are small enough from costing 0.
However, we decided for the next audit, which covers SimpleLaunchpad and SimpleBondingCurve (less precision loss prone implementations of this curve) to go with Zellic's first recommendation, which is to revert if the cost is 0