Platform: Code4rena
Start Date: 21/08/2023
Pot Size: $125,000 USDC
Total HM: 26
Participants: 189
Period: 16 days
Judge: GalloDaSballo
Total Solo HM: 3
Id: 278
League: ETH
Rank: 78/189
Findings: 3
Award: $108.90
π Selected for report: 0
π Solo Findings: 0
π Selected for report: klau5
Also found by: 0x3b, 0xCiphky, 0xDING99YA, 0xWaitress, 0xbranded, 0xc0ffEE, 0xklh, 0xsurena, 0xvj, ABA, AkshaySrivastav, Anirruth, Aymen0909, Baki, Blockian, BugzyVonBuggernaut, DanielArmstrong, Evo, GangsOfBrahmin, HChang26, Inspex, Jiamin, Juntao, Kow, Krace, KrisApostolov, LFGSecurity, LokiThe5th, Mike_Bello90, Norah, Nyx, QiuhaoLi, RED-LOTUS-REACH, SBSecurity, Snow24, SpicyMeatball, T1MOH, Tendency, Toshii, Udsen, Yanchuan, __141345__, ak1, asui, auditsea, ayden, bart1e, bin2chen, blutorque, carrotsmuggler, chaduke, chainsnake, circlelooper, clash, codegpt, crunch, degensec, dirk_y, ge6a, gjaldon, grearlake, jasonxiale, juancito, ke1caM, kodyvim, kutugu, ladboy233, lanrebayode77, mahdikarimi, max10afternoon, mert_eren, nirlin, nobody2018, oakcobalt, parsely, peakbolt, pks_, pontifex, ravikiranweb3, rokinot, rvierdiiev, said, savi0ur, sces60107, sh1v, sl1, spidy730, tapir, tnquanghuy0512, ubermensch, visualbits, volodya, wintermute
0.0098 USDC - $0.01
PerpetualAtlanticVaultLP.sol is vulnerable to donation attacks. A malicious user can simply donate 1 wei of Weth to PerpetualAtlanticVaultLP.sol, and this will DOS settle()
functionality of RdpxV2core. In addition, PerputalAtlanticVaultLP.sol doesn't have methods to withdraw the donated tokens to mitigate the impact, which will cause DOS to persist.
Aside from DOS, this also means options can not be settled in a timely manner, resulting in an unhealthy state of the options market.
I think this is High severity because the attack is very easy and low cost, and creates a significant impact on the protocol health.
In the settlement process, the admin role will initiate settle()
on RdpxV2Core.sol, which will call settle()
on PerpetualAtlanticVault.sol which verifies and burn the optionIds
passed through. And the end of settle()
, it calls subtractLoss()
on PerpetualAtlanticVaultLP.sol.
The vulnerability lies in the fact that in subtractLoss()
, a straight equal is used in the require statement to check whether collateral.balanceOf(address(this) == _totalCollateral - loss
. This will cause the whole settle flow to revert if a malicious user transfers a dust amount of collateral token to PerpetualAtlanticVaultLP.sol.
//RdpxV2Core.sol function settle( uint256[] memory optionIds ) external onlyRole(DEFAULT_ADMIN_ROLE) returns (uint256 amountOfWeth, uint256 rdpxAmount) { ... (amountOfWeth, rdpxAmount) = IPerpetualAtlanticVault( addresses.perpetualAtlanticVault ).settle(optionIds); ...} // PerpetualAtlanticVault.sol function settle( uint256[] memory optionIds ) external nonReentrant onlyRole(RDPXV2CORE_ROLE) returns (uint256 ethAmount, uint256 rdpxAmount) { ... IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP) .subtractLoss(ethAmount); ...} //PerpetualAtlanticLP.sol function subtractLoss(uint256 loss) public onlyPerpVault { //@audit donation attacK?? require( |> collateral.balanceOf(address(this)) == _totalCollateral - loss, "Not enough collateral was sent out" ); _totalCollateral -= loss; }
We also see that in PerpetualAtlanticVaultLP.sol, there is no function to manually intervene to mitigate the impact of DOS attacks. The dust amount of malicious donation, will cause a persistent inequality between collateral.balanceOf(address(this))
and the contract's internal accounting and no options can be settled.
See POC test that modifies tests/rdpxV2-core/Unit.t.sol by adding function testSettleRevertDueToDonationAttack()
:
//Unit.t.sol function testSettleRevertDueToDonationAttack() public { rdpxV2Core.bond(5 * 1e18, 0, address(this)); rdpxV2Core.bond(1 * 1e18, 0, address(this)); (, uint256 rdpxReserve1, ) = rdpxV2Core.getReserveTokenInfo("RDPX"); (, uint256 wethReserve1, ) = rdpxV2Core.getReserveTokenInfo("WETH"); vault.addToContractWhitelist(address(rdpxV2Core)); uint256[] memory _ids = new uint256[](1); (uint256 strike1, uint256 amount1, ) = vault.optionPositions(0); // test ITM options _ids[0] = 0; rdpxPriceOracle.updateRdpxPrice(1e7); // donation attack 2wei weth.transfer(address(vaultLp), 2); // settle will now always fail since balanceOf weth will not straight equal to accounting anymore vm.expectRevert("Not enough collateral was sent out"); rdpxV2Core.settle(_ids); }
Test result:
Running 1 test for tests/rdpxV2-core/Unit.t.sol:Unit [PASS] testSettleRevertDueToDonationAttack() (gas: 1619588) Test result: ok. 1 passed; 0 failed; finished in 1.21s
Manual Review Foundry
Consider change to collateral.balanceOf(address(this)) >= _totalCollateral - loss
.
Other
#0 - c4-pre-sort
2023-09-09T09:57:23Z
bytes032 marked the issue as duplicate of #619
#1 - c4-judge
2023-10-20T19:32:19Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: Vagner
Also found by: 0Kage, 0xCiphky, 0xnev, ABAIKUNANBAEV, Aymen0909, Evo, KmanOfficial, MohammedRizwan, T1MOH, Viktor_Cortess, Yanchuan, ak1, alexzoid, bin2chen, codegpt, hals, ladboy233, mrudenko, nemveer, oakcobalt, peakbolt, pep7siup, qbs, said, savi0ur, tapir, wintermute, zaevlad, zzebra83
7.8372 USDC - $7.84
Judge has assessed an item in Issue #1784 as 2 risk. The relevant finding follows:
Low -2 UniV2LiquidityAmo.sol accounting might be temporarily out of sync In UniV2LiquidityAmo.sol, sync() is an external function that can be called by anyone to update the lpTokenBalance. And lpTokenBalance is modified in addLiquidity() and removeLiquidity().
The issue is addLiquidity() and removeLiquidity() wonβt sync() the token balance before updating the token balance. In the case that lpTokenBalance is changed by either one of the function, but sync() is not called in time by users, and when the other function is modifying lpTokenBalance again the change in balance will be added or subtracted on an outdated number. if sync() is never called for a long time by users or anyone, these changes will continuously be added or substracted based on an out-of-sync lpTokenBalance.
//UniV2LiquidityAmo.sol function addLiquidity( uint256 tokenAAmount, uint256 tokenBAmount, uint256 tokenAAmountMin, uint256 tokenBAmountMin ) external onlyRole(DEFAULT_ADMIN_ROLE) returns (uint256 tokenAUsed, uint256 tokenBUsed, uint256 lpReceived) { ... // update LP token Balance lpTokenBalance += lpReceived; ...}
function removeLiquidity( uint256 lpAmount, uint256 tokenAAmountMin, uint256 tokenBAmountMin ) external onlyRole(DEFAULT_ADMIN_ROLE) returns (uint256 tokenAReceived, uint256 tokenBReceived) {
... lpTokenBalance -= lpAmount; ...} (https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/amo/UniV2LiquidityAmo.sol#L235) (https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/amo/UniV2LiquidityAmo.sol#L283)
function sync() external { lpTokenBalance = IERC20WithBurn(addresses.pair).balanceOf( address(this) ); }
Recommendations: Consider calling sync() inside addLiquidity() and removeLiquidity before updating lpTokenBalance.
#0 - c4-judge
2023-10-25T07:26:40Z
GalloDaSballo marked the issue as duplicate of #269
#1 - c4-judge
2023-10-30T20:01:13Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: c3phas
Also found by: 0xgrbr, HHK, LeoS, QiuhaoLi, Sathish9098, __141345__, flacko, oakcobalt, pontifex
101.0486 USDC - $101.05
updateFunding()
in PerpetualAtlanticVault.solupdateFunding()
updates transfers funding into PerpetualAtlanticVaultLP.sol and is used in two ways: (1) called publicly by anyone at any time; (2) used as a hook in key functions related to depositing and purchasing options though deposit()
in PerpetualAtlanticVaultLP.sol and purchase()
in PerpetualAtlanticVault.sol.
Depending on time and context where updateFunding()
is called, there might not be any funding to be sent to PerpetualAtlanticVaultLP.sol at times. However, even when there is no funding to be sent, updateFunding()
will still perform all state changes including sending '0' tokens, adding '0' to state variables, which is a waste of gas.
//PerpetualAtlanticVault.sol function updateFunding() public { updateFundingPaymentPointer(); uint256 currentFundingRate = fundingRates[latestFundingPaymentPointer]; uint256 startTime = lastUpdateTime == 0 ? (nextFundingPaymentTimestamp() - fundingDuration) : lastUpdateTime; lastUpdateTime = block.timestamp; |> collateralToken.safeTransfer( addresses.perpetualAtlanticVaultLP, (currentFundingRate * (block.timestamp - startTime)) / 1e18 ); |> IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).addProceeds( (currentFundingRate * (block.timestamp - startTime)) / 1e18 ); emit FundingPaid( msg.sender, ((currentFundingRate * (block.timestamp - startTime)) / 1e18), latestFundingPaymentPointer ); }
(https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L502-L524)
(a) Transfer '0' token
When currentFundingRate
is '0', and this is possible when a new epoch (latestFundingPaymentPointer
) starts and fundingRates
is not updated per the new epoch. this means collateralToken.safeTransfer()
will send 0 amount token to LP contract. This is wasteful.
Or when block.timestamp is the same as startTime
and this can happen when updateFunding()
is called too soon, this will also result in 0 amount token being sent.
(b) Add '0'
When the above two scenarios happen, the function will also pass '0' to IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).addProceeds()
, and inside addProceeds()
0 will be added to state variable _totalCollateral
. This is wasteful.
/// @inheritdoc IPerpetualAtlanticVaultLP function addProceeds(uint256 proceeds) public onlyPerpVault { require( collateral.balanceOf(address(this)) >= _totalCollateral + proceeds, "Not enough collateral token was sent" ); _totalCollateral += proceeds; }
Recommendations: Add a condition if statement to bypass 0 value transfer or adding '0' to make sure only when there is non-zero amount of token to transfer will token and proceeds be updated.
calculateFunding()
on PerpetualAtlanticVault.solIn PerpetualAtlanticVault.sol calculateFunding()
, timeToExpiry
is calculated in a wasteful way.
function calculateFunding( uint256[] memory strikes ) external nonReentrant returns (uint256 fundingAmount) { ... uint256 timeToExpiry = nextFundingPaymentTimestamp() - (genesis + ((latestFundingPaymentPointer - 1) * fundingDuration)); ...
(https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L426-L427)
As seen above, since the nextFundingPaymentTimestamp()
be design will always be genesis + (latestFundingPaymentPointer * fundingDuration)
. At any given time, timeToExpiry
will always be fundingDuration
. There is no need for the math operation here.
function nextFundingPaymentTimestamp() public view returns (uint256 timestamp) { return genesis + (latestFundingPaymentPointer * fundingDuration); }
Recommendations: Remove the math operations as stated above.
calculateFunding()
In PerpetualAtlanticVault.sol calculateFunding()
, there are scenarios where 0 is added to a state variable which is a waste of gas. calculateFunding()
is a public function and can be called by anyone and at any time.
As seen from below, when amount
is '0'. Zero value operations and state change will still proceeds in calculatePremium()
, updating state variables fundingPaymentsAccountedFor
and fundingPaymentsAccountedForPerStrike
and totalFundingForEpoch
.
And amount
can be '0' when fundingPaymentsAccountedForPerStrike
has already taken into account optionsPerStrike[strike]
in earlier transactions that might have be submitted by other users or admin.
function calculateFunding( uint256[] memory strikes ) external nonReentrant returns (uint256 fundingAmount) { ... |> uint256 amount = optionsPerStrike[strike] - fundingPaymentsAccountedForPerStrike[latestFundingPaymentPointer][ strike ]; uint256 timeToExpiry = nextFundingPaymentTimestamp() - (genesis + ((latestFundingPaymentPointer - 1) * fundingDuration)); |> uint256 premium = calculatePremium( strike, amount, timeToExpiry, getUnderlyingPrice() ); latestFundingPerStrike[strike] = latestFundingPaymentPointer; fundingAmount += premium; // Record number of options that funding payments were accounted for, for this epoch |> fundingPaymentsAccountedFor[latestFundingPaymentPointer] += amount; // record the number of options funding has been accounted for the epoch and strike |> fundingPaymentsAccountedForPerStrike[latestFundingPaymentPointer][ strike ] += amount; // Record total funding for this epoch // This does not need to be done in purchase() since it's already accounted for using `addProceeds()` |> totalFundingForEpoch[latestFundingPaymentPointer] += premium; ...
(https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L405-L459)
Recommendations:
Add a '0' value bypass, and only proceed for state changes when amount
is not zero.
In UniV3LiquidityAmo.sol removeLiquidity()
, after decreasing liquidity from UniswapV3 and collect tokens through decreaseLiquidity()
and collect()
, tokens collected (tokenA and tokenB) are directly sent from UniswapV3 to RdpxV2Core.sol.
However, removeLiquidity()
will call _sendTokensToRdpxV2Core()
which under the hood send tokenA and tokenB balance to RdpxV2Core.sol. This is unnecessary operation because UniswapV3 will not send tokenA and tokenB to UniV3LqiduityAmo.sol during removeLiquidity()
. And this is because collect()
param. will pass RdpxV2Core.sol as the recipient in the first place. In this case, _sendTokensToRdpxV2Core()
will transfer '0' amount tokens.
In addition, _sendTokensToRdpxV2Core()
although intended as a token sweeping function, is already included in swap()
and addLiquidity()
where tokenA and tokenB were actually transferred to UniV3LiquidityAmo.sol contract. Since (A) removeLiquidity()
is different from other functions where tokenA and tokenB are not transferred to the contract itself , and (B) dust tokens would have been already taken care of when tokenA and tokenB are transferred in other functions, and (C) there is a ERC20 token recovery function in place recoverERC20()
for any donations, there is no need to transfer tokenA and tokenB balance in removeLiquidity()
and it would be wasteful to do so.
Instead, we only need to call IRdpxV2Core(rdpxV2Core).sync();
to make sure that RdpxV2Core.sol will sync the tokens transferred directly from UniswapV3.
function removeLiquidity( uint256 positionIndex, uint256 minAmount0, uint256 minAmount1 ) public onlyRole(DEFAULT_ADMIN_ROLE) { Position memory pos = positions_array[positionIndex]; INonfungiblePositionManager.CollectParams memory collect_params = INonfungiblePositionManager.CollectParams( pos.token_id, |> rdpxV2Core, type(uint128).max, type(uint128).max ); ( , , address tokenA, address tokenB, , , , uint128 liquidity, , , , ) = univ3_positions.positions(pos.token_id); // remove liquidity INonfungiblePositionManager.DecreaseLiquidityParams memory decreaseLiquidityParams = INonfungiblePositionManager .DecreaseLiquidityParams( pos.token_id, liquidity, minAmount0, minAmount1, block.timestamp ); univ3_positions.decreaseLiquidity(decreaseLiquidityParams); univ3_positions.collect(collect_params); univ3_positions.burn(pos.token_id); positions_array[positionIndex] = positions_array[ positions_array.length - 1 ]; positions_array.pop(); delete positions_mapping[pos.token_id]; // send tokens to rdpxV2Core |> _sendTokensToRdpxV2Core(tokenA, tokenB); emit log(positions_array.length); emit log(positions_mapping[pos.token_id].token_id); }
function _sendTokensToRdpxV2Core(address tokenA, address tokenB) internal { uint256 tokenABalance = IERC20WithBurn(tokenA).balanceOf(address(this)); uint256 tokenBBalance = IERC20WithBurn(tokenB).balanceOf(address(this)); // transfer token A and B from this contract to the rdpxV2Core |> IERC20WithBurn(tokenA).safeTransfer(rdpxV2Core, tokenABalance); |> IERC20WithBurn(tokenB).safeTransfer(rdpxV2Core, tokenBBalance); // sync token balances IRdpxV2Core(rdpxV2Core).sync(); emit LogAssetsTransfered(tokenABalance, tokenBBalance, tokenA, tokenB); }
Recommendations:
In removeLiquidity()
, consider replacing _sendTokensToRdpxV2Core()
with IRdpxV2Core(rdpxV2Core).sync();
.
#0 - c4-pre-sort
2023-09-10T11:29:49Z
bytes032 marked the issue as sufficient quality report
#1 - GalloDaSballo
2023-10-10T18:45:19Z
updateFunding()
in PerpetualAtlanticVault.solL
calculateFunding()
on PerpetualAtlanticVault.solNC
calculateFunding()
L
L
3L 1NC
#2 - c4-judge
2023-10-20T10:19:40Z
GalloDaSballo marked the issue as grade-b