Platform: Code4rena
Start Date: 03/07/2023
Pot Size: $40,000 USDC
Total HM: 14
Participants: 74
Period: 7 days
Judge: alcueca
Total Solo HM: 9
Id: 259
League: ETH
Rank: 1/74
Findings: 8
Award: $9,339.71
🌟 Selected for report: 6
🚀 Solo Findings: 3
543.1424 USDC - $543.14
Please refer to the issue titled Implementation of Well shift() function allows attackers to completely manipulate the oracles
for relevant introduction and context.
The safety of the TWAP relies on calling the observation function (update()
) with the current reserve, before any reserve changes take place. Updating happens in the _updatePumps
call:
reserves = _getReserves(_numberOfTokens); if (numberOfPumps() == 0) { return reserves; } // gas optimization: avoid looping if there is only one pump if (numberOfPumps() == 1) { Call memory _pump = firstPump(); // Don't revert if the update call fails. try IPump(_pump.target).update(reserves, _pump.data) {} catch { // ignore reversion. If an external shutoff mechanism is added to a Pump, it could be called here. }
There is a bug in the sync()
function, whereby the reserves are directly updated from the contract balance, but updatePumps()
is not called beforehand.
/** * @dev Sync the reserves of the Well with its current balance of underlying tokens. */ function sync() external nonReentrant { IERC20[] memory _tokens = tokens(); uint256[] memory reserves = new uint256[](_tokens.length); for (uint256 i; i < _tokens.length; ++i) { reserves[i] = _tokens[i].balanceOf(address(this)); } _setReserves(_tokens, reserves); emit Sync(reserves); }
An attacker can abuse this issue by donating ERC20 tokens to one side of the token pair in the Well, calling sync()
to update the reserves without updating pumps, and then doing a negligible swap. At the first swapFrom()
call, the pumps will be updated. It will appear as if the entire duration until the sync()
call was with the wrong ratio. Both the EMA oracle and the Cumulative oracle will be skewed.
The TWAP oracle can be arbitrarily manipulated by an attacker.
The TWAP can always be manipulated. In order for it to be profitable, the gain from the exploit of a platform dependent on the TWAP needs to be higher than the donation to the Well contract used to skew the ratio. An example is provided:
Assume a Well holding 100 ETH : 100000 USDC, Fair ratio is 1:1000.
Last trade happens at t = T
Time is now t = T + 1000 sec.
Cumulative reserves in oracle contract are [X, Y]
. To remind, they are calculated as
pumpState.cumulativeReserves[i] = pumpState.cumulativeReserves[i].add(pumpState.lastReserves[i].mul(deltaTimestampBytes));
sync()
, new reserves are 200 : 100,000.swapFrom()
, to swap 1 wei of ETH. Pump update is triggered, with new reserve ratio:twaReserves = ([X + 200*1000, Y + 10000*1000]-[X,Y]) / 1000 twaReserves = [200, 10000]
Manual audit
In the sync()
function, call the _updatePumps()
function, even though the current reserve count is not necessary.
Oracle
#0 - c4-pre-sort
2023-07-11T13:58:38Z
141345 marked the issue as primary issue
#1 - c4-pre-sort
2023-07-11T14:00:48Z
141345 marked the issue as duplicate of #136
#2 - c4-judge
2023-08-03T08:02:58Z
alcueca marked the issue as partial-50
#3 - alcueca
2023-08-03T08:03:10Z
No mention of shift()
#4 - c4-judge
2023-08-15T21:08:36Z
alcueca marked the issue as full credit
#5 - c4-judge
2023-08-19T18:26:41Z
alcueca marked the issue as satisfactory
543.1424 USDC - $543.14
The TWAP mechanism relies on measurements sent to the oracle at various points in time. Before reserve counts change, the TWAP is sent the last reserve counts, which are multiplied by the time passed and added to the accumulator. In MultiFlowPump, it happens here:
for (uint256 i; i < numberOfReserves; ++i) { // Use a minimum of 1 for reserve. Geometric means will be set to 0 if a reserve is 0. pumpState.lastReserves[i] = _capReserve( pumpState.lastReserves[i], (reserves[i] > 0 ? reserves[i] : 1).fromUIntToLog2(), blocksPassed ); pumpState.emaReserves[i] = pumpState.lastReserves[i].mul((ABDKMathQuad.ONE.sub(alphaN))).add(pumpState.emaReserves[i].mul(alphaN)); pumpState.cumulativeReserves[i] = // @audit - TWAP update pumpState.cumulativeReserves[i].add(pumpState.lastReserves[i].mul(deltaTimestampBytes)); }
lastReserves[i]
is capped and updated with the parameter reserves[i].price
. Then it is used to determine cumulativeReserves[i]
. This behavior is correct, as discussed in the Uniswap v3 book.
The safety of the TWAP relies on calling the observation function (update()
) with the current reserve, before any reserve changes take place. Updating happens in the _updatePumps
call:
reserves = _getReserves(_numberOfTokens); if (numberOfPumps() == 0) { return reserves; } // gas optimization: avoid looping if there is only one pump if (numberOfPumps() == 1) { Call memory _pump = firstPump(); // Don't revert if the update call fails. try IPump(_pump.target).update(reserves, _pump.data) {} catch { // ignore reversion. If an external shutoff mechanism is added to a Pump, it could be called here. }
It is called in the following functions which change reserve counts:
_swapFrom()
swapTo()
_addLiquidity()
removeLiquidity()
removeLiquidityOneToken()
removeLiquidityImbalanced()
However, it is not called in the shift()
function, which is very similar to swapFrom()
and updates reserves counts.
function _swapFrom( IERC20 fromToken, IERC20 toToken, uint256 amountIn, uint256 minAmountOut, address recipient ) internal returns (uint256 amountOut) { IERC20[] memory _tokens = tokens(); uint256[] memory reserves = _updatePumps(_tokens.length); (uint256 i, uint256 j) = _getIJ(_tokens, fromToken, toToken); ... function shift( IERC20 tokenOut, uint256 minAmountOut, address recipient ) external nonReentrant returns (uint256 amountOut) { IERC20[] memory _tokens = tokens(); uint256[] memory reserves = new uint256[](_tokens.length); ...
At the end of the function, it will update the reserves from the current balances:
if (amountOut >= minAmountOut) { tokenOut.safeTransfer(recipient, amountOut); reserves[j] -= amountOut; _setReserves(_tokens, reserves); emit Shift(reserves, tokenOut, amountOut, recipient); } else { revert SlippageOut(amountOut, minAmountOut); }
This means an attacker can easily manipulate the TWAP price. They will transfer tokens to the contract and use shift()
to change the reserve count to an extreme ratio, then swap back the tokens using a normal swapFrom()
call, which will set update the oracle with the post-shift reserve counts. It will appear as if the entire duration until the shift was with the extreme ratio. Both the EMA oracle and the Cumulative oracle will be skewed.
The TWAP oracle can be arbitrarily manipulated by an attacker.
Assume a Well holding 100 ETH : 100000 USDC, Fair ratio is 1:1000.
Last trade happens at t = T
Time is now t = T + 1000 sec.
Cumulative reserves in oracle contract are [X, Y]
. To remind, they are calculated as
pumpState.cumulativeReserves[i] = pumpState.cumulativeReserves[i].add(pumpState.lastReserves[i].mul(deltaTimestampBytes));
shift()
, new reserves are 10100 : 990. Receive 100000-990
USDC to walletswapFrom()
, passing received USDC from shift()
. Pool returns to 100 : 100,000, user receives 10,000 ETH backswapFrom()
, cumulative reserves were updated:
For example, a victim now queries the TWAP price in the last 1000 sec, using
readTwaReserves(Well, [x,y], T)
:
It will calculate:
twaReserves = ([X + 10100*1000, Y + 990*1000]-[X,Y]) / 1000 twaReserves = [10100, 990]
Calculation shows the TWAP price for the past 1000 seconds is 1 ETH = 0.098 USDC. Attacker can now exploit any user of the TWAP through uncollateralized loans, swaps at wrong ratio, or any other target-specific way.
Manual audit
In the shift()
function, call the _updatePumps()
function, even though the current reserve count is not necessary.
Oracle
#0 - c4-pre-sort
2023-07-11T13:32:51Z
141345 marked the issue as duplicate of #278
#1 - c4-pre-sort
2023-07-11T14:00:06Z
141345 marked the issue as duplicate of #136
#2 - c4-judge
2023-08-03T08:00:32Z
alcueca marked the issue as partial-50
#3 - alcueca
2023-08-03T08:00:42Z
No mention of sync()
#4 - c4-judge
2023-08-15T21:08:44Z
alcueca marked the issue as full credit
#5 - c4-judge
2023-08-19T18:26:28Z
alcueca marked the issue as satisfactory
🌟 Selected for report: Trust
2152.3705 USDC - $2,152.37
The LibBytes
library is used to read and store uint128
types compactly for Well functions. The function getBytes32FromBytes()
will fetch a specific index as bytes32
.
/** * @dev Read the `i`th 32-byte chunk from `data`. */ function getBytes32FromBytes(bytes memory data, uint256 i) internal pure returns (bytes32 _bytes) { uint256 index = i * 32; if (index > data.length) { _bytes = ZERO_BYTES; } else { assembly { _bytes := mload(add(add(data, index), 32)) } } }
If the index is out of bounds in the data structure, it returns ZERO_BYTES = bytes32(0)
. The issue is that the OOB check is incorrect. If index=data.length
, the request is also OOB. For example:
data.length=0
-> array is empty, data[0]
is undefined.
data.length=32
-> array has one bytes32
, data[32]
is undefined.
In other words, fetching the last element in the array will return whatever is stored in memory after the bytes
structure.
Users of getBytes32FromBytes
will receive arbitrary incorrect data. If used to fetch reserves like readUint128
, this could easily cause severe damage, like incorrect pricing, or wrong logic that leads to loss of user funds.
The damage is easily demonstrated using the example below:
pragma solidity 0.8.17; contract Demo { event Here(); bytes32 private constant ZERO_BYTES = bytes32(0); function corruption_POC(bytes memory data1, bytes memory data2) external returns (bytes32 _bytes) { _bytes = getBytes32FromBytes(data1, 1); } function getBytes32FromBytes(bytes memory data, uint256 i) internal returns (bytes32 _bytes) { uint256 index = i * 32; if (index > data.length) { _bytes = ZERO_BYTES; } else { emit Here(); assembly { _bytes := mload(add(add(data, index), 32)) } } } }
Calling corruption_POC with the following parameters:
{ "bytes data1": "0x2222222222222222222222222222222222222222222222222222222222222222", "bytes data2": "0x3333333333333333333333333333333333333333333333333333333333333333" }
The output bytes32
is:
{ "0": "bytes32: _bytes 0x0000000000000000000000000000000000000000000000000000000000000020" }
The 0x20 value is in fact the size of the data2
bytes that resides in memory from the call to getBytes32FromBytes
.
Manual audit
Change the logic to:
if (index >= data.length) { _bytes = ZERO_BYTES; } else { assembly { _bytes := mload(add(add(data, index), 32)) } }
Invalid Validation
#0 - c4-pre-sort
2023-07-11T14:19:32Z
141345 marked the issue as primary issue
#1 - publiuss
2023-07-16T13:41:15Z
The report is valid, but the function is not used in the code base and will be removed. Because of this, it was never tested and/or intended for use. Recommend medium.
#2 - c4-sponsor
2023-07-16T13:41:20Z
publiuss requested judge review
#3 - c4-sponsor
2023-07-17T19:44:10Z
publiuss marked the issue as sponsor acknowledged
#4 - c4-sponsor
2023-07-17T19:44:17Z
publiuss marked the issue as disagree with severity
#5 - alcueca
2023-08-03T13:22:32Z
The finding doesn't impact code in scope in a way that would lead to loss of funds. Instead, it would affect only future code.
#6 - c4-judge
2023-08-03T13:22:38Z
alcueca changed the severity to 2 (Med Risk)
#7 - c4-judge
2023-08-03T14:13:36Z
alcueca marked the issue as satisfactory
#8 - c4-judge
2023-08-19T18:34:03Z
alcueca marked the issue as selected for report
#9 - publiuss
2023-08-23T23:31:07Z
The function has been removed from the codebase.
968.5667 USDC - $968.57
https://github.com/code-423n4/2023-07-basin/blob/c1b72d4e372a6246e0efbd57b47fb4cbb5d77062/src/libraries/LibBytes16.sol#L45 https://github.com/code-423n4/2023-07-basin/blob/c1b72d4e372a6246e0efbd57b47fb4cbb5d77062/src/libraries/LibLastReserveBytes.sol#L58
The MultiFlowPump contract stores reserve counts on every update, using the libraries LibBytes16 and LibLastReserveBytes. Those libs pack bytes16
values efficiently with the storeBytes16()
and storeLastReserves
functions.
In case of an odd number of items, the last storage slot will be half full. Care must be taken to not step over the previous value in that slot. This is done correctly in LibBytes
:
if (reserves.length & 1 == 1) { require(reserves[reserves.length - 1] <= type(uint128).max, "ByteStorage: too large"); iByte = maxI * 64; assembly { sstore( // @audit - overwrite SLOT+MAXI*32 add(slot, mul(maxI, 32)), // @audit - read SLOT+MAXI*32 (2nd entry) add(mload(add(reserves, add(iByte, 32))), shl(128, shr(128, sload(add(slot, mul(maxI, 32)))))) ) } }
As can be seen, it overwrites the slot with the previous 128 bits in the upper half of the slot, only setting the lower 128 bytes.
However, the wrong slot is read in the other two libraries. For example, in storeLastReserves()
:
if (reserves.length & 1 == 1) { iByte = maxI * 64; assembly { sstore( // @audit - overwrite SLOT+MAXI*32 add(slot, mul(maxI, 32)), // @audit - read SLOT+MAXI (2nd entry)?? add(mload(add(reserves, add(iByte, 32))), shr(128, shl(128, sload(add(slot, maxI))))) ) } }
The error is not multiplying maxI
before adding it to slot
. This means that the reserves count encoded in lower 16 bytes in add(slot, mul(maxI, 32))
will have the value of a reserve in a much lower index.
Slots are used in 32 byte increments, i.e. S, S+32, S+64...
When maxI==0
, the intended slot and the actual slot overlap. When maxI
is 1..31, the read slot happens to be zero (unused), so the first actual corruption occurs on maxI==32
. By substitution, we get:
SLOT[32*32] = correct reserve | SLOT[32]
In other words, the 4rd reserve (stored in lower 128 bits of SLOT[32]
) will be written to the 64th reserve.
The Basin pump is intended to support an arbitrary number of reserves safely, therefore the described storage corruption impact is in scope.
Reserve amounts in the pump will be corrupted, resulting in wrong oracle values.
[0,1,2,...,63,64]
SLOT[32*32] = UPPER: 64 | LOWER: SLOT[32] = 64 | 3
Manual audit
Change the sload()
operation in both affected functions to sload(add(slot, mul(maxI, 32)
en/de-code
#0 - c4-pre-sort
2023-07-11T14:17:41Z
141345 marked the issue as primary issue
#1 - publiuss
2023-07-16T14:35:08Z
This is a valid issue as the function incorrectly stores bytes, but it doesn't break anything of the Pump as the bytes that are incorrectly stored are never read.
Regardless it should be fixed. Recommend changing to medium.
#2 - c4-sponsor
2023-07-16T14:35:12Z
publiuss marked the issue as disagree with severity
#3 - c4-sponsor
2023-07-17T18:17:59Z
publiuss marked the issue as sponsor confirmed
#4 - alcueca
2023-08-03T13:20:13Z
The bug doesn't negatively impact the code in scope, only future code.
#5 - c4-judge
2023-08-03T13:20:28Z
alcueca changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-08-03T14:14:01Z
alcueca marked the issue as satisfactory
#7 - c4-judge
2023-08-05T21:37:25Z
alcueca marked the issue as selected for report
🌟 Selected for report: Trust
2152.3705 USDC - $2,152.37
It is advised to first read finding: Due to slot confusion, reserve amounts in the pump will be corrupted, resulting in wrong oracle values
, which provides all the contextual information for this separate bug.
We've discussed how a wrong sload()
source slot leads to corruption of the reserves. In LibBytes16
, another confusion occurs.
Recall the correct storage overwriting done in LibBytes
:
assembly { sstore( // @audit - overwrite SLOT+MAXI*32 add(slot, mul(maxI, 32)), // @audit - read SLOT+MAXI*32 (2nd entry) add(mload(add(reserves, add(iByte, 32))), shl(128, shr(128, sload(add(slot, mul(maxI, 32)))))) ) }
Importantly, it clears the lower 128 bytes of the source slot and replaces the upper 128 bytes of the dest slot using the upper 128 bytes of the source slot:
shl(128,shr(128,SOURCE))
In storeBytes16()
, the shl()
operation has been discarded. This means the code will use the upper 128 bytes of SOURCE to overwrite the lower 128 bytes in DEST.
if (reserves.length & 1 == 1) { iByte = maxI * 64; assembly { sstore( // @audit - overwrite SLOT+MAXI*32 add(slot, mul(maxI, 32)), // @audit - overwrite lower 16 bytes with upper 16 bytes? add(mload(add(reserves, add(iByte, 32))), shr(128, sload(add(slot, maxI)))) ) } }
In other words, regardless of the SLOT being read, instead of keeping the lower 128 bits as is, it stores whatever happens to be in the upper 128 bits. Note this is a completely different error from the slot confusion, which happens to be in the same line of code.
Reserve amounts in the pump will be corrupted, resulting in wrong oracle values
Assume slot confusion bug has been corrected for clarity.
[0,1,2,...,63,64]
[P0,P1,...,P64]
SLOT[32*32] = UPPER: 64 | LOWER: UPPER(SLOT[32*32]) = 64 | P64
Manual audit
Replace the affected line with the calculation below:
shr(128, shl(128, SLOT))
This will use the lower 128 bytes and clear the upper 128 bytes,as intended.
en/de-code
#0 - c4-pre-sort
2023-07-11T14:18:28Z
141345 marked the issue as duplicate of #260
#1 - c4-pre-sort
2023-07-11T14:55:22Z
141345 marked the issue as not a duplicate
#2 - c4-pre-sort
2023-07-11T14:55:26Z
141345 marked the issue as primary issue
#3 - c4-sponsor
2023-07-21T15:31:54Z
publiuss marked the issue as sponsor confirmed
#4 - c4-sponsor
2023-07-21T15:32:08Z
publiuss marked the issue as disagree with severity
#5 - publiuss
2023-07-21T15:32:58Z
This is a valid issue as the function incorrectly stores bytes, but it doesn't break anything of the Pump as the bytes that are incorrectly shifted are never non-zero.
Regardless it should be fixed. Recommend changing to medium.
#6 - alcueca
2023-08-03T13:17:02Z
@publiuss, I notice that in your fix you didn't change any tests. May I suggest that you increase your test coverage to be certain that the wardens are not missing other storage corruption issues?
Other than that, without a clear PoC, I can't accept this as High.
#7 - c4-judge
2023-08-03T13:17:11Z
alcueca changed the severity to 2 (Med Risk)
#8 - c4-judge
2023-08-03T14:14:22Z
alcueca marked the issue as satisfactory
#9 - c4-judge
2023-08-05T21:36:51Z
alcueca marked the issue as selected for report
#10 - publiuss
2023-08-23T01:03:49Z
@publiuss, I notice that in your fix you didn't change any tests. May I suggest that you increase your test coverage to be certain that the wardens are not missing other storage corruption issues?
Other than that, without a clear PoC, I can't accept this as High.
The test coverage didn't changed because the issue doesn't actually impact the functionality. This issue has been addressed and fixed in all bytes libraries.
968.5667 USDC - $968.57
The Well allows users to permissionless swap assets or add and remove liquidity. Users specify the intended slippage in swapFrom
, in minAmountOut
.
The ConstantProduct2 implementation ensures Kend - Kstart >= 0
, where K = Reserve1 * Reserve2
, and the delta should only be due to tiny precision errors.
Furthermore, the Well does not impose any fees to its users. This means that all conditions hold for a successful DOS of any swap transactions.
Note that such DOS attacks have serious adverse effects both on the protocol and the users. Protocol will use users due to disfunctional interactions. On the other side, users may opt to increment the max slippage in order for the TX to go through, which can be directly abused by the same MEV bots that could be performing the DOS.
All swaps can be reverted at very little cost.
Manual audit
Fees solve the problem described by making it too costly for attackers to DOS swaps. If DOS does takes place, liquidity providers are profiting a high APY to offset the inconvenience caused, and attract greater liquidity.
DoS
#0 - c4-pre-sort
2023-07-11T14:06:25Z
141345 marked the issue as primary issue
#1 - c4-sponsor
2023-07-16T14:46:09Z
publiuss marked the issue as sponsor disputed
#2 - publiuss
2023-07-16T14:46:35Z
#3 - publiuss
2023-07-16T14:46:39Z
This is an issue that is already present in other AMMs. The lack of a fee just makes the DOS cheaper than in other AMMs. However, it still requires paying for 2 Ethereum transaction fees. The use of a private mempool or a higher priority fee solves this problem.
#4 - c4-judge
2023-08-02T21:23:35Z
alcueca marked the issue as selected for report
#5 - c4-judge
2023-08-02T21:23:42Z
alcueca changed the severity to 2 (Med Risk)
#6 - alcueca
2023-08-02T21:27:02Z
It seems to me that the DoS can be economical enough for the attacker to disrupt the UX by forcing all users to use private mempools.
🌟 Selected for report: Trust
2152.3705 USDC - $2,152.37
Pumps receive the chain BLOCK_TIME in the constructor. In every update, it is used to calculate the blocksPassed
variable, which determines what is the maximum change in price (done in _capReserve()
).
The issue is that BLOCK_TIME is an immutable variable in the pump, which is immutable in the Well, meaning it is basically set in stone and can only be changed through a Well redeploy and liquidity migration (very long cycle). However, BLOCK_TIME actually changes every now and then, especially in L2s.For example, the recent Bedrock upgrade in Optimism completely changed the block time generation. It is very clear this will happen many times over the course of Basin's lifetime.
When a wrong BLOCK_TIME is used, the _capReserve()
function will either limit price changes too strictly, or too permissively. In the too strict case, this would cause larger and large deviations between the oracle pricing and the real market prices, leading to large arb opportunities. In the too permissive case, the function will not cap changes like it is meant too, making the oracle more manipulatable than the economic model used when deploying the pump.
Treating of BLOCK_TIME as permanent will cause serious economic flaws in the oracle when block times change.
Manual audit
The BLOCK_TIME should be changeable, given a long enough freeze period where LPs can withdraw their tokens if they are unsatisfied with the change.
Timing
#0 - c4-pre-sort
2023-07-11T16:03:03Z
141345 marked the issue as primary issue
#1 - c4-sponsor
2023-07-17T17:08:57Z
publiuss marked the issue as sponsor confirmed
#2 - c4-judge
2023-08-04T05:53:50Z
alcueca marked the issue as satisfactory
#3 - c4-judge
2023-08-05T21:44:31Z
alcueca marked the issue as selected for report
#4 - publiuss
2023-08-23T00:48:59Z
This issue was addressed by (1) changing the variable name from BLOCK_TIME
to CAP_INTERVAL
and (2) rounding up when calculating capInterval
(See: https://github.com/BeanstalkFarms/Basin/blob/91233a22005986aa7c9f3b0c67393842cd8a8e4d/src/pumps/MultiFlowPump.sol#L109).
(1) By changing the name it is clear that this parameter does not have to be the block time. (2) By rounding up, the system protects itself against the case where the chain block chain is greater than CAP_INTERVAL
capExponent will always be non-zero when time has passed.
🌟 Selected for report: 0xprinc
Also found by: 0x11singh99, 0xAnah, 0xWaitress, 0xkazim, 2997ms, 33audits, 404Notfound, 8olidity, CRIMSON-RAT-REACH, CyberPunks, DanielWang888, Deekshith99, Eeyore, Eurovickk, Inspecktor, JGcarv, John, Jorgect, Kaysoft, LosPollosHermanos, MohammedRizwan, Qeew, QiuhaoLi, Rolezn, TheSavageTeddy, Topmark, Trust, Udsen, a3yip6, alexzoid, bigtone, codegpt, erebus, fatherOfBlocks, ginlee, glcanvas, hunter_w3b, josephdara, kaveyjoe, kutugu, mahdirostami, max10afternoon, oakcobalt, peanuts, pfapostol, ptsanev, qpzm, radev_sw, ravikiranweb3, sces60107, seth_lawson, te_aut, twcctop, zhaojie, ziyou-
17.5208 USDC - $17.52
In Well, the shift()
function is used to transfer tokens directly to the next Well instead of the sender. This allows for a cheap sequence of swaps. The documentation states the exact call flow:
* 2. Using a router with {shift}: * WETH.transfer(sender=0xUSER, recipient=Well1) [1] * Call the router, which performs: * Well1.shift(tokenOut=DAI, recipient=Well2) * DAI.transfer(sender=Well1, recipient=Well2) [2] * Well2.shift(tokenOut=USDC, recipient=0xUSER) * USDC.transfer(sender=Well2, recipient=0xUSER) [3]
Notice that there are two separate actions, first (1) transfering the token (WETH) to the Well, and then (2) calling the router's function, which will shift the tokens to a second Well, and finally to the user's balance.
The issue is that between action (1) and (2) a malicious user can use the victim tokens from (1) as their own amountIn
. They can call one of the Well functions, like shift()
, to perform the swap and specify the attacker as recipient
.
From all the documentation provided and the codebase, the user is led to interact unsafely with the Well's shift()
call.
Users following the documented action flow can lose their entire input amount.
shift(tokenOut, ATTACKER_ADDRESS)
on Well directly. Since (1) and (2) are different user initiated TXs, there is definitely vulnerable time to abuse the extra token balance in Well1.Manual audit
Users should not interact with the Well directly. They should call a router function which will do all the swapping activity end-to-end.
MEV
#0 - c4-pre-sort
2023-07-11T08:24:05Z
141345 marked the issue as duplicate of #214
#1 - c4-pre-sort
2023-07-11T15:05:26Z
141345 marked the issue as not a duplicate
#2 - c4-pre-sort
2023-07-11T15:05:30Z
141345 marked the issue as primary issue
#3 - c4-sponsor
2023-07-16T13:41:43Z
publiuss marked the issue as sponsor disputed
#4 - publiuss
2023-07-16T13:43:36Z
The assumption is that the set of actions will occur within a single multi-call transaction. This is implied through the use of the word router
in the documentation. An example of a multi-call router that can be used is Pipeline:
https://github.com/BeanstalkFarms/Pipeline/blob/master/contracts/Pipeline.sol
Will update documentation to make this more clear, so recommending to change severity to QA.
#5 - c4-sponsor
2023-07-16T14:58:43Z
publiuss marked the issue as disagree with severity
#6 - c4-sponsor
2023-07-17T18:36:50Z
publiuss marked the issue as sponsor confirmed
#7 - c4-sponsor
2023-08-02T13:04:30Z
publiuss marked the issue as sponsor disputed
#8 - c4-sponsor
2023-08-02T13:05:24Z
publiuss marked the issue as sponsor confirmed
#9 - alcueca
2023-08-03T13:27:39Z
Agree with sponsor. The mention of the router
is clear in the documentation. I strongly recommend this is made even more clear in the documentation, to reduce the amount of immunefi reports that you'll receive once live.
#10 - c4-judge
2023-08-03T13:28:24Z
alcueca changed the severity to QA (Quality Assurance)
#11 - c4-judge
2023-08-05T10:37:21Z
alcueca marked the issue as grade-a
#12 - trust1995
2023-08-09T13:15:03Z
Agree with sponsor. The mention of the
router
is clear in the documentation. I strongly recommend this is made even more clear in the documentation, to reduce the amount of immunefi reports that you'll receive once live.
Hi, the mention of router
is clearly shown in the report body and wasn't hidden. The issue is the involvement of the router only occurs at stage (2). There is absolutely no indication that a router is involved in step (1), which is why the sponsor saw it necessary to update the documentation and clarify it.
I would argue that if a user reading the docs could rightfully interpret the sequence of operations in a way that causes them to lose funds, the submission is valid as High impact. We cannot downgrade a finding due to sponsor intentions.
#13 - alcueca
2023-08-15T19:49:21Z
I believe that the sponsor was aware that unattended tokens can be skimmed, since that has been the case since Uniswap v2. This is clearly an error in the documentation, and not an error in the design or the code.
#14 - trust1995
2023-08-15T22:37:58Z
I believe that the sponsor was aware that unattended tokens can be skimmed, since that has been the case since Uniswap v2. This is clearly an error in the documentation, and not an error in the design or the code.
I agree with everything you have said here, but it does not relate to the main point I have demonstrated. The argument was: The documentation was written in such a way that the average user following it would lose their funds. The rationalization for H/M impact is clear. A documentation / code spec mismatch issue CAN be regarding as HM if it brings forth substantial risk. One has to differentiate between wrong naming / typos / outdated explanations and actually harmful documentation. Judges are aligned on that and can be further consulted - links (1) and (2)
#15 - alcueca
2023-08-19T18:23:48Z
The average user doesn't build multicall batches out of direct contract calls.
Even developers integrating with a Well would have to be quite incompetent to lose assets due to the misleading comments. This is not a novel pattern.
Regarding the additional links provided, (1) states this should be QA, if applicable: <img width="303" alt="image" src="https://github.com/code-423n4/2023-07-basin-findings/assets/38806121/95fc7715-3e8f-4405-8c8c-9c9c01fdfa9c">
Considering the discussion linked as (2), I'm inclined to follow your own recommendation there:
“Not every code misdocumentation is QA max. But one that has quite limited impact is.”
I want you to have an honest look at this report, and consider if this code misdocumentation has more than a limited impact. Then I would like you to consider if this report is providing a lot of value for the sponsor, on the level of other Medium issues reported.
384.8008 USDC - $384.80
General
I've spent 2-3 days on this audit and covered the code in scope. Having been audited twice by private firms, I expected there to be very little in terms of low-hanging fruits.
Approach
Most of the focus was on deeply understanding the assembly optimizations and particular LP mechanics, informally proving their correctness and so on. Differential analysis between Basin and Uniswap was helpful for this task.
Architecture recommendations
The platform supports a very generic well implementation, and as the team understands, this leads to a broad variety of malicious deployed well risks. It should be a high priority task for the team to find good ways of representing all Well trust assumptions to its users, and expose that through a smart contract UI or an open source front-end library.
Qualitative analysis
Code quality and documentation is very mature. The test suite is pretty comprehensive and fuzz tests are a great way to complement the static tests. My suggestion is to add integration tests to verify behavior of the system as a whole, rather than all its specific sub-components.
Centralization risks
None. The architecture is fully permissionless.
Systemic risks
MEV and TWAP manipulation are the main systemic risks. Interacting with Wells registered in the Aquifer could possibly be risky, depending on the well's configuration.
The immutability of the Pump and Well co-efficients, such as LOG_MAX_INCREASE and ALPHA, present a systemic risk as time progresses and the optimal values start diverging.
As the entire platform is unupgradeable, migration in the event of a security hole or a black swan event will be challenging. The team should prepare multiple recovery scenarios and set up adequate action channels to prepare for such eventualities.
25 hours
#0 - c4-pre-sort
2023-07-12T12:18:27Z
141345 marked the issue as high quality report
#1 - c4-sponsor
2023-08-03T20:32:50Z
publiuss marked the issue as sponsor acknowledged
#2 - c4-judge
2023-08-05T20:15:35Z
alcueca marked the issue as grade-a
#3 - c4-judge
2023-08-05T20:19:17Z
alcueca marked the issue as selected for report
#4 - alcueca
2023-08-05T20:20:09Z
While other reports have included much more text, this report in particular stands out for the quality of the advice given.