Platform: Code4rena
Start Date: 07/04/2023
Pot Size: $47,000 USDC
Total HM: 20
Participants: 120
Period: 6 days
Judge: GalloDaSballo
Total Solo HM: 4
Id: 230
League: ETH
Rank: 42/120
Findings: 4
Award: $110.05
🌟 Selected for report: 1
🚀 Solo Findings: 0
26.761 USDC - $26.76
The pool owner can use execute
to call any smartcontract function with pool as msg.sender
. This allows them to steal any NFT or base token that was approved for the pool.
execute
function allows owner to perform any function call from the pool:
/// @notice Executes a transaction from the pool account to a target contract. The caller must be the owner of the /// pool. This allows for use cases such as claiming airdrops. function execute(address target, bytes memory data) public payable onlyOwner returns (bytes memory) { // call the target with the value and data (bool success, bytes memory returnData) = target.call{value: msg.value}(data); // if the call succeeded return the return data if (success) return returnData; // if we got an error bubble up the error message if (returnData.length > 0) { // solhint-disable-next-line no-inline-assembly assembly { let returnData_size := mload(returnData) revert(add(32, returnData), returnData_size) } } else { revert(); } }
So any time a user or contract approves the pool to use their tokens (required for buy, sell and change) the owner can transfer those tokens to himself. This is not an issue when using periphery contract as intermediary to call the pool since the user does not directly approve the pool. However it poses a risk for custom contracts trying to integrate with the pool or users that want to use the change
function directly (this one has no slippage so it has no explicit warning against direct use in the function documentation).
Manual review
Either remove the execute function and make do without the possibility of claiming airdrops. Or document the issue clearly (risk of someone doing it wrong anyway)
#0 - c4-pre-sort
2023-04-20T16:40:09Z
0xSorryNotSorry marked the issue as duplicate of #184
#1 - c4-judge
2023-05-01T19:20:22Z
GalloDaSballo changed the severity to 3 (High Risk)
#2 - c4-judge
2023-05-01T19:21:26Z
GalloDaSballo marked the issue as satisfactory
44.2571 USDC - $44.26
EthRouter is meant to support multiple changes in one tx, but that would fail
The function EthRouter.change
sends msg.value
to pool in a for loop:
for (uint256 i = 0; i < changes.length; i++) { Change memory _change = changes[i]; ... // execute change PrivatePool(_change.pool).change{value: msg.value}( _change.inputTokenIds, _change.inputTokenWeights, _change.inputProof, _change.stolenNftProofs, _change.outputTokenIds, _change.outputTokenWeights, _change.outputProof );
The pool subtracts the fee, and sends the rest back to the router. After the first iteration the router contains less ETH than msg.value
and will revert
Add to Change.t.sol
and run with forge test --match test_twoChanges -vvvv
function test_twoChangesOneCall() public { uint256[] memory inputTokenIds = new uint256[](1); uint256[] memory inputTokenWeights = new uint256[](0); uint256[] memory outputTokenIds = new uint256[](1); uint256[] memory outputTokenWeights = new uint256[](0); uint256[] memory inputTokenIds2 = new uint256[](1); uint256[] memory inputTokenWeights2 = new uint256[](0); uint256[] memory outputTokenIds2 = new uint256[](1); uint256[] memory outputTokenWeights2 = new uint256[](0); inputTokenIds[0] = 5; outputTokenIds[0] = 0; inputTokenIds2[0] = 6; outputTokenIds2[0] = 1; EthRouter.Change[] memory changes = new EthRouter.Change[](2); changes[0] = EthRouter.Change({ pool: payable(address(privatePool)), nft: address(milady), inputTokenIds: inputTokenIds, inputTokenWeights: inputTokenWeights, inputProof: PrivatePool.MerkleMultiProof(new bytes32[](0), new bool[](0)), stolenNftProofs: new IStolenNftOracle.Message[](0), outputTokenIds: outputTokenIds, outputTokenWeights: outputTokenWeights, outputProof: PrivatePool.MerkleMultiProof(new bytes32[](0), new bool[](0)) }); changes[1] = EthRouter.Change({ pool: payable(address(privatePool)), nft: address(milady), inputTokenIds: inputTokenIds2, inputTokenWeights: inputTokenWeights2, inputProof: PrivatePool.MerkleMultiProof(new bytes32[](0), new bool[](0)), stolenNftProofs: new IStolenNftOracle.Message[](0), outputTokenIds: outputTokenIds2, outputTokenWeights: outputTokenWeights2, outputProof: PrivatePool.MerkleMultiProof(new bytes32[](0), new bool[](0)) }); (uint256 changeFee,) = privatePool.changeFeeQuote(inputTokenIds.length * 1e18); //WARDEN: multiply with 10 just to make sure there really is enough ethRouter.change{value: changeFee*10}(changes, 0); }
Output:
</details>... │ ├─ [0] PrivatePool::change{value: 50000000000000000000}([6], [], ([], []), [], [1], [], ([], [])) │ │ └─ ← "EvmError: OutOfFund" │ └─ ← "EvmError: Revert" └─ ← "EvmError: Revert"
Manual review
only send the required change fee and not msg.value
#0 - c4-pre-sort
2023-04-18T19:49:48Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-20T16:53:30Z
0xSorryNotSorry marked the issue as primary issue
#2 - c4-sponsor
2023-04-22T10:08:12Z
outdoteth marked the issue as sponsor confirmed
#3 - outdoteth
2023-04-24T10:46:08Z
Fixed in: https://github.com/outdoteth/caviar-private-pools/pull/5
The proposed fix is to add a baseTokenAmount
field in the Change struct and to use this field instead of msg.value when making the change operation.
PrivatePool(_change.pool).change{value: _change.baseTokenAmount}( // -- snip -- );
#4 - GalloDaSballo
2023-04-28T11:06:21Z
The Warden has shown how, because msg.value
is passed in a loop, to functions that could reduce this.balance
, the tx can revert, breaking the functionality for those use cases
Because that doesn't cause a loss of principal, but shows a broken functionality for some cases, I agree with Medium Severity
#5 - c4-judge
2023-04-28T11:06:26Z
GalloDaSballo marked the issue as selected for report
🌟 Selected for report: adriro
Also found by: 0xNorman, 0xRobocop, Aymen0909, ElKu, GT_Blockchain, Josiah, KrisApostolov, RaymondFam, SpicyMeatball, ToonVH, Voyvoda, anodaram, aviggiano, bin2chen, climber2002, giovannidisiena, jpserrat, minhtrng, rbserver, sashik_eth, shaka, wintermute
8.0283 USDC - $8.03
flashFee
is not scaled and returns a value that is too low. And hence fees paid are lower than they should be.
The view flashFee
returns the storage variable changeFee
which expresses an amount of baseTokens with 4 decimals:
function flashFee(address, uint256) public view returns (uint256) { return changeFee; }
Correct scaling would be:
function changeFeeQuote(uint256 inputAmount) public view returns (uint256 feeAmount, uint256 protocolFeeAmount) { // multiply the changeFee to get the fee per NFT (4 decimals of accuracy) uint256 exponent = baseToken == address(0) ? 18 - 4 : ERC20(baseToken).decimals() - 4; uint256 feePerNft = changeFee * 10 ** exponent;
Manual review
Adjust the view flashFee
to use correct scaling
#0 - c4-pre-sort
2023-04-20T15:07:55Z
0xSorryNotSorry marked the issue as duplicate of #864
#1 - c4-judge
2023-05-01T07:08:05Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: AkshaySrivastav
Also found by: 0x5rings, 0xbepresent, ABA, Bauchibred, BenRai, DadeKuma, ElKu, RaymondFam, Rolezn, adriro, btk, chaduke, devscrooge, dingo2077, minhtrng, nemveer, p0wd3r, rbserver, ulqiorra
30.9954 USDC - $31.00
PrivatePool.flashLoan
does not check if ETH has been sent, even though baseToken is not ETH. The functions buy
, change
, deposit
do perform this check:
if (baseToken != address(0) && msg.value > 0) revert InvalidEthAmount();
This could lead to users sending ETH to the contract on accident.
Mitigation: perform the check
Function PrivatePool.changeFeeQuote
will revert if the baseToken has less than 4 decimals, rendering change
function unusable:
function changeFeeQuote(uint256 inputAmount) public view returns (uint256 feeAmount, uint256 protocolFeeAmount) { uint256 exponent = baseToken == address(0) ? 18 - 4 : ERC20(baseToken).decimals() - 4;
Tokens with less than 4 decimals are too rare for this to likely be an issue, but it could be fixed by adjusting the math (dividing by 10 ** (4-token.decimals)
if decimals <= 3
).
The functions setFeeRate
and setVirtualReserves
can be used by the owner to do MEV by frontrunning user transactions and adjusting the values to reach the users max slippage values.
Only viable mitigation would be removing them. But slippage is something that a user has to take into account anyway, so only a minor issue and likely users responsibility to use periphery properly.
According to https://docs.caviar.sh/: "Custom Pools: [...] Like shared pools, liquidity providers earn fees from trades against their pool."
This is not the case in the current implementation (by design) and users might deposit in the believe of earning yield and being able to redeem, but would lose their deposits instead. Mitigation: update docs
EthRouter.buy
EthRouter.buy
states:
/// @param payRoyalties Whether to pay royalties or not.
It might not be clear to users that private pools can not be opted out and the decision to pay royalties is taken by the pools owner. For a better user experience, consider reflecting this in the function documentation.
The function PrivatePool.deposit
can not incur any slippage since it is not designed to mint LPs. Hence the following function documentation is obsolete:
/// @dev DO NOT call this function directly unless you know what you are doing. Instead, use a wrapper contract that /// will check the current price is within the desired bounds.
#0 - GalloDaSballo
2023-05-01T06:37:55Z
EthRouter.buy
#1 - c4-judge
2023-05-01T09:17:04Z
GalloDaSballo marked the issue as grade-b