All calls to functions in Stable2.sol from a Well.sol deployment that supports more than 2 tokens will succeed without reverting, but result in incorrect results returned. This will break a number of functionalities in the contract. For example, a malicious user can request a swap using the swapTo() function in Well.sol and receive as many tokens as they desire without paying the contract anything.
Background
Well.sol is a constant function AMM that allows the provisioning of liquidity into a single pooled on-chain liquidity position. It is important to note that Wells.sol supports many tokens in it's functionalities.
There are many functionalities provided by a Well.sol deployment. Examples include: swapping, implemented in functions swapFrom() and swapTo(), shift(), liquidity provision, implemented in functions addLiquidity(), removeLiquidity(), etc.
All of these functionalities depend on functions located in the wellFunction() contract. This is a contract, such as Stable2.sol, which performs the internal calculations of the swapping or the liquidity provisioning through functions like calcReserve(), calcLpTokenSupply() etc.
However, Stable2.sol assumes that the Well.sol implementation only has 2 tokens. Furthermore, it does not revert if the assumption is false. In the following, we demonstrate how this can be used to exploit the calcReserve() function in Stable2.sol through a swapTo() function call with a runnable POC, but note that other functions are similarly vulnerable.
This is a vulnerability is due to the fact that the incorrect calculations do not revert in the Stable2.sol functions. Even if Stable2.sol was created with the intention of only supporting two tokens, it should still revert if it is mistakenly deployed with more.
swapTo() Exploit Description
In the swapTo() function, the function swaps amountOut of a token j to a user, and receives in return amountIn of a token i.
Benign Swap
To perform a (benign) swap, the following steps are performed.
The reserve of token j is decreased by amountOut
The reserve of token i is saved
The required reserve of token i for the swap is computed by the function call _calcReserve(). __The _calcReserve() function invokes the calcReserve() function in the contract stored inwellFunction()(Stable2.sol)__. The later function is provided as parameters: reserves, or the stored reserves of all tokens, j, the token that should be provided by the user (in this case), totalSupply() which stores the total supply of lp tokens, and data, which stores the data regarding the tokens in the contract.
function calcReserve(
uint256[] memory reserves,
uint256 j,
uint256 lpTokenSupply,
bytes memory data
)
amountIn is computed as the difference between the required reserve and the initial reserve of token i.
amountIn = reserves[i] - reserveIBefore;
Finally, amountOut of token j are transfered to the user and amountIn of token i are transfered to the smart contract.
This is the intended behaviour.
Malicious Swap
However, the swap can be exploited when the Well.sol deployment supports more than 2 tokens andStable2.sol is used as the wellFunction. Specifically, when a swap occurs and the token received by the user has an id that is not 0 or 1.
The reason for the failure is due to incorrect accounting in the calcReserve() function in Stable2.sol. In particular, calcReserve() assumes that the tokens to be swapped always have ids of 0 or 1. As evidence, while the reserve parameter can store the reserves of all of the tokens, the call on line 121 to getScaledReserve() function which normalizes the balances up to 10^18 only performs normalization for reserves[0] and reserves[1].
Furthermore, on line 124, onwards, the calculations (i.e. (uint256 c, uint256 b) = getBandC(a * N * N, lpTokenSupply, j == 0 ? scaledReserves[1] : scaledReserves[0]);) are only performed with the first two reserves (scaledReserves[0] and scaledReserves[1]).
The exploit scenario is as follows:
A malicious user calls swapTo() with the output token j as neither 0 or 1, and any amount of amountOut.
The reserve of token j, reserve[j] is decreased by amountOut.
Everything proceeds as the same up until the calcReserve() function.
The calculations are performed with the reserves of the tokens with id 0 (reserve[0]) and 1 (reserve[1]). Since neither change, as j is not 0 or 1, the eventual output is as if nothing changed.
This means that in amountIn = reserves[i] - reserveIBefore;, reserves[i] is the same as reserveIBefore. Hence, amountIn = 0
The malicious user earns however many tokens as they want (amountOut) and pay nothing (amountIn) for it.
POC
Setup
TestHelper.sol needs to be slightly modified to support tokens outside of 0 and 1.
In particular, slight modifications need to be performed on lines 132 and 140.
...
function setupStable2Well() internal {
//@auditor: Change deployMockTokens(2 --> 3)
setupStable2Well(deployPumps(1), deployMockTokens(3));
}
function setupStable2Well(Call[] memory _pumps, IERC20[] memory _tokens) internal {
// deploy new LUT:
address lut = address(new Stable2LUT1());
// encode wellFunction Data
//@auditor: Add another MockToken address
bytes memory wellFunctionData =
abi.encode(MockToken(address(_tokens[0])).decimals(), MockToken(address(_tokens[1])).decimals(), MockToken(address(_tokens[2])).decimals());
Call memory _wellFunction = Call(address(new Stable2(lut)), wellFunctionData);
tokens = _tokens;
wellFunction = _wellFunction;
vm.label(address(wellFunction.target), "Stable2 WF");
for (uint256 i = 0; i < _pumps.length; i++) {
pumps.push(_pumps[i]);
}
...
To run the POC, copy the code into a forge test file and run it.
To see the logs, uncomment the assertEq() at the bottom and change the forge visibility to -vv or run with the forge visibility set to -vvv.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import {IERC20, Balances, Call, MockToken, Well} from "test/TestHelper.sol";
import {SwapHelper, SwapAction} from "test/SwapHelper.sol";
import {MockFunctionBad} from "mocks/functions/MockFunctionBad.sol";
import {IWellFunction} from "src/interfaces/IWellFunction.sol";
import {IWell} from "src/interfaces/IWell.sol";
import {IWellErrors} from "src/interfaces/IWellErrors.sol";
import "forge-std/console.sol";
contract WellStable2SwapToTest is SwapHelper {
function setUp() public {
// init 1000e18, 1000e18
setupStable2Well();
}
/// @dev tests assume 2 tokens in future we can extend for multiple tokens
function testFuzz_swapTo(uint256 amountOut) public prank(user) {
// User has 1000 of each token
// Given current liquidity, swapping 1000 of one token gives 500 of the other
uint256 maxAmountIn = 1000 * 1e18;
//amountOut = bound(amountOut, 0, 500 * 1e18);
amountOut = 157*1e18;
console.log("Begin test\n");
Balances memory userBalancesBefore = getBalances(user, well);
Balances memory wellBalancesBefore = getBalances(address(well), well);
uint256[] memory calcBalances = new uint256[](wellBalancesBefore.tokens.length);
console.log("Token 1 Balance: %d\n", wellBalancesBefore.tokens[0]);
console.log("Token 2 Balance: %d\n", wellBalancesBefore.tokens[1]);
console.log("Token 3 Balance: %d\n", wellBalancesBefore.tokens[2]);
console.log("Swapping token 0 for token 2\n");
well.swapTo(tokens[0], tokens[2], maxAmountIn, amountOut, user, type(uint256).max);
Balances memory userBalancesAfter = getBalances(user, well);
Balances memory wellBalancesAfter = getBalances(address(well), well);
console.log("Well Token 1 Balance: %d\n", wellBalancesAfter.tokens[0]);
console.log("Well Token 2 Balance: %d\n", wellBalancesAfter.tokens[1]);
console.log("Well Token 3 Balance: %d\n", wellBalancesAfter.tokens[2]);
console.log("User Token 1 Balance: %d\n", userBalancesAfter.tokens[0]);
console.log("User Token 2 Balance: %d\n", userBalancesAfter.tokens[1]);
console.log("User Token 3 Balance: %d\n", userBalancesAfter.tokens[2]);
//@auditor: Uncomment this to see logs
//assertEq(amountOut, 1, "End Exploit");
}
}
Expected Logs:
Logs:
Begin test
Well 1 Balance: 1000000000000000000000
Well 2 Balance: 1000000000000000000000
Well 3 Balance: 1000000000000000000000
User 1 Balance: 1000000000000000000000
User 2 Balance: 1000000000000000000000
User 3 Balance: 1000000000000000000000
Swapping token 0 for token 2
Well Token 1 Balance: 1000000000000000000000
Well Token 2 Balance: 1000000000000000000000
Well Token 3 Balance: 843000000000000000000
User Token 1 Balance: 1000000000000000000000
User Token 2 Balance: 1000000000000000000000
User Token 3 Balance: 1157000000000000000000
As demonstrated, the user's balance of token 3 increases (1000000000000000000000 -> 1157000000000000000000) while the contract experiences the opposite. Furthermore, the user does not lose amounts of any other tokens. This is equivalent to directly earning tokens.
Tools Used
Manual, ABAuditor
Recommended Mitigation:
Utilize reserve[j] and pass in i as a paramter to also use reserve[i] in all operations in Stable2.sol. Otherwise, save reserve[i] and reserve[j] as reserve[0] and reserve[1] (or vice versa) before calling any functions in Stable2.sol.
Conversely, there could be a require(j == 0 || j == 1) statement in all of the Stable2.sol functions, which would prevent the above from occuring.
Lines of code
https://github.com/code-423n4/2024-07-basin/blob/7d5aacbb144d0ba0bc358dfde6e0cc913d25310e/src/functions/Stable2.sol#L121-L124 https://github.com/code-423n4/2024-07-basin/blob/7d5aacbb144d0ba0bc358dfde6e0cc913d25310e/src/functions/Stable2.sol#L179 https://github.com/code-423n4/2024-07-basin/blob/7d5aacbb144d0ba0bc358dfde6e0cc913d25310e/src/functions/Stable2.sol#L252
Vulnerability details
Most functions of
Stable2.sol
are incompatible withWell.sol
, allowing malicious users to steal funds.Related Links: https://github.com/code-423n4/2024-07-basin/blob/7d5aacbb144d0ba0bc358dfde6e0cc913d25310e/src/functions/Stable2.sol#L121-L124 https://github.com/code-423n4/2024-07-basin/blob/7d5aacbb144d0ba0bc358dfde6e0cc913d25310e/src/functions/Stable2.sol#L179 https://github.com/code-423n4/2024-07-basin/blob/7d5aacbb144d0ba0bc358dfde6e0cc913d25310e/src/functions/Stable2.sol#L252
Impact
All calls to functions in
Stable2.sol
from aWell.sol
deployment that supports more than 2 tokens will succeed without reverting, but result in incorrect results returned. This will break a number of functionalities in the contract. For example, a malicious user can request a swap using theswapTo()
function inWell.sol
and receive as many tokens as they desire without paying the contract anything.Background
Well.sol
is a constant function AMM that allows the provisioning of liquidity into a single pooled on-chain liquidity position. It is important to note thatWells.sol
supports many tokens in it's functionalities.There are many functionalities provided by a
Well.sol
deployment. Examples include: swapping, implemented in functionsswapFrom()
andswapTo()
,shift()
, liquidity provision, implemented in functionsaddLiquidity()
,removeLiquidity()
, etc.All of these functionalities depend on functions located in the
wellFunction()
contract. This is a contract, such asStable2.sol
, which performs the internal calculations of the swapping or the liquidity provisioning through functions likecalcReserve()
,calcLpTokenSupply()
etc.However,
Stable2.sol
assumes that theWell.sol
implementation only has 2 tokens. Furthermore, it does not revert if the assumption is false. In the following, we demonstrate how this can be used to exploit thecalcReserve()
function inStable2.sol
through aswapTo()
function call with a runnable POC, but note that other functions are similarly vulnerable.This is a vulnerability is due to the fact that the incorrect calculations do not revert in the
Stable2.sol
functions. Even ifStable2.sol
was created with the intention of only supporting two tokens, it should still revert if it is mistakenly deployed with more.swapTo()
Exploit DescriptionIn the
swapTo()
function, the function swapsamountOut
of a tokenj
to a user, and receives in returnamountIn
of a tokeni
.Benign Swap
To perform a (benign) swap, the following steps are performed.
j
is decreased byamountOut
i
is savedi
for the swap is computed by the function call_calcReserve()
. __The_calcReserve()
function invokes thecalcReserve()
function in the contract stored inwellFunction()
(Stable2.sol
)__. The later function is provided as parameters:reserves
, or the stored reserves of all tokens,j
, the token that should be provided by the user (in this case),totalSupply()
which stores the total supply of lp tokens, anddata
, which stores the data regarding the tokens in the contract.amountIn
is computed as the difference between the required reserve and the initial reserve of tokeni
.amountOut
of tokenj
are transfered to the user andamountIn
of tokeni
are transfered to the smart contract.This is the intended behaviour.
Malicious Swap
However, the swap can be exploited when the
Well.sol
deployment supports more than 2 tokens andStable2.sol
is used as thewellFunction
. Specifically, when a swap occurs and the token received by the user has an id that is not0
or1
.The reason for the failure is due to incorrect accounting in the
calcReserve()
function inStable2.sol
. In particular,calcReserve()
assumes that the tokens to be swapped always have ids of0
or1
. As evidence, while thereserve
parameter can store the reserves of all of the tokens, the call on line 121 togetScaledReserve()
function which normalizes the balances up to 10^18 only performs normalization forreserves[0]
andreserves[1]
. Furthermore, on line 124, onwards, the calculations (i.e.(uint256 c, uint256 b) = getBandC(a * N * N, lpTokenSupply, j == 0 ? scaledReserves[1] : scaledReserves[0]);
) are only performed with the first two reserves (scaledReserves[0]
andscaledReserves[1]
).The exploit scenario is as follows:
swapTo()
with the output tokenj
as neither 0 or 1, and any amount ofamountOut
.j
,reserve[j]
is decreased byamountOut
.calcReserve()
function.reserve[0]
) and 1 (reserve[1])
. Since neither change, asj
is not 0 or 1, the eventual output is as if nothing changed.amountIn = reserves[i] - reserveIBefore;
,reserves[i]
is the same asreserveIBefore
. Hence,amountIn = 0
amountOut
) and pay nothing (amountIn
) for it.POC
Setup
TestHelper.sol
needs to be slightly modified to support tokens outside of 0 and 1.In particular, slight modifications need to be performed on lines 132 and 140.
To run the POC, copy the code into a forge test file and run it. To see the logs, uncomment the
assertEq()
at the bottom and change theforge
visibility to-vv
or run with theforge
visibility set to-vvv
.Expected Logs:
As demonstrated, the user's balance of token 3 increases (1000000000000000000000 -> 1157000000000000000000) while the contract experiences the opposite. Furthermore, the user does not lose amounts of any other tokens. This is equivalent to directly earning tokens.
Tools Used
Manual, ABAuditor
Recommended Mitigation:
Utilize
reserve[j]
and pass ini
as a paramter to also usereserve[i]
in all operations inStable2.sol
. Otherwise, savereserve[i]
andreserve[j]
asreserve[0]
andreserve[1]
(or vice versa) before calling any functions inStable2.sol
.Conversely, there could be a
require(j == 0 || j == 1)
statement in all of theStable2.sol
functions, which would prevent the above from occuring.Assessed type
Context