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: 56/189
Findings: 5
Award: $206.57
π 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
https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L315 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVaultLP.sol#L199
The subtractLoss
function within the PerpetualAtlanticVaultLP
contract uses collateral.balanceOf(address(this))
under the assumption that it's synchronized with _totalCollateral
. However, direct transfers to PerpetualAtlanticVaultLP
aren't reflected in _totalCollateral
, creating a discrepancy between these two balances.
The settle
function in the PerpetualAtlanticVault
contract depends on the above function to verify the accurate amount transferred. A mismatch in these balances renders the settle
function inoperative.
The subtractLoss function is presented below:
function subtractLoss(uint256 loss) public onlyPerpVault { require(collateral.balanceOf(address(this)) == _totalCollateral - loss, "Not enough collateral was sent out"); _totalCollateral -= loss; }
The settle function is as follows:
function settle(uint256[] memory optionIds) external nonReentrant onlyRole(RDPXV2CORE_ROLE) returns (uint256 ethAmount, uint256 rdpxAmount) { // rest of the code IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).subtractLoss(ethAmount); IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).unlockLiquidity(ethAmount); IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).addRdpx(rdpxAmount); emit Settle(ethAmount, rdpxAmount, optionIds); }
A user can send collateral tokens directly to the PerpetualAtlanticVaultLP
contract. When the settle
function calls subtractLoss
, the require
statement will fail because of the imbalance between the active collateral and the actual token balance.
Consequences:
_totalCollateral
and collateral.balanceOf(address(this))
will consistently cause the subtractLoss
function to revert, rendering the settle
function unusable.Foundry test case
function testBreakSettle() public { // initial set up taken from testSettle rdpxV2Core.bond(5 * 1e18, 0, address(this)); rdpxV2Core.bond(1 * 1e18, 0, address(this)); vault.addToContractWhitelist(address(rdpxV2Core)); uint256[] memory _ids = new uint256[](1); // User mints and transfers 1 WETH to vaultlp contract address user1 = vm.addr(1); weth.mint(user1, 1 * 1e18); weth.transfer(address(vaultLp), 1 * 1e18); // test ITM options // This will now revert as _totalCollateral is not synced to collateral.balanceOf(address(this)) anymore _ids[0] = 0; rdpxPriceOracle.updateRdpxPrice(1e7); vm.expectRevert(bytes("Not enough collateral was sent out")); rdpxV2Core.settle(_ids); }
Remove the require validation from the subtractLoss function. Given that the collateral is WETH and the usage of SafeERC20, any transfer failures will inherently cause the settle function to revert. Thus, an additional validation to ensure the successful transfer of funds is redundant.
DoS
#0 - c4-pre-sort
2023-09-09T10:00:11Z
bytes032 marked the issue as duplicate of #619
#1 - c4-pre-sort
2023-09-11T16:15:08Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T19:35:29Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: bart1e
Also found by: 0x3b, 0xCiphky, Aymen0909, HHK, Inspex, bin2chen, chaduke, gkrastenov, jasonxiale, josephdara, kodyvim, peakbolt, pep7siup, rokinot, rvierdiiev, tapir
181.367 USDC - $181.37
The recoverERC721 function in the UniV3LiquidityAMO contract is designed to recover ERC721 tokens (NFTs) and transfer them to the RdpxV2Core contract. The transfer is conducted using the safeTransferFrom method, which inherently checks if the receiving contract implements the onERC721Received function. This function acts as a safety mechanism to ensure that the recipient contract can handle ERC721 tokens.
function recoverERC721(address tokenAddress, uint256 token_id) external onlyRole(DEFAULT_ADMIN_ROLE) { // Only the owner address can ever receive the recovery withdrawal // INonfungiblePositionManager inherits IERC721 so the latter does not need to be imported INonfungiblePositionManager(tokenAddress).safeTransferFrom(address(this), rdpxV2Core, token_id); emit RecoveredERC721(tokenAddress, token_id); }
In the scenario presented, the RdpxV2Core contract indeed implements the onERC721Received function. However, the contract lacks the other essential functionalities of the regular ERC721 standard. This deficiency implies that while the RdpxV2Core contract can receive NFTs, it cannot subsequently interact with or transfer them. As a result, any NFTs sent to this contract will become irretrievable.
Manual analysis
Modify the RdpxV2Core contract to fully implement the ERC721 standard. Ensure that it can not only receive but also interact with and transfer ERC721 tokens.
DoS
#0 - c4-pre-sort
2023-09-12T06:11:56Z
bytes032 marked the issue as duplicate of #935
#1 - c4-judge
2023-10-20T18:05:59Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: said
Also found by: 0Kage, 0xCiphky, 0xkazim, 836541, AkshaySrivastav, Evo, HChang26, HHK, KrisApostolov, Neon2835, QiuhaoLi, Tendency, Toshii, bart1e, bin2chen, carrotsmuggler, chaduke, etherhood, gjaldon, glcanvas, josephdara, lanrebayode77, mahdikarimi, max10afternoon, nobody2018, peakbolt, qpzm, rvierdiiev, sces60107, tapir, ubermensch, volodya
17.313 USDC - $17.31
https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVaultLP.sol#L118 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVaultLP.sol#L145
The PerpetualAtlanticVaultLP contract is vulnerable to flash loan attacks. A malicious actor can exploit the distribution mechanism by performing a rapid deposit-withdrawal sequence, allowing them to benefit disproportionately from accumulated collateral.
The root of the problem lies in the different timings of the updateFunding()
function call between deposit and withdrawal functions. During a deposit, the shares are calculated before the updateFunding()
call. In contrast, during withdrawal, assets are calculated after updateFunding()
is invoked. This inconsistency, combined with the potential for accumulated tokens if updateFunding()
hasn't been called for a significant period, creates an exploitable scenario.
Scenario
updateFunding()
, adding funding.updateFunding()
.totalSupply
of shares: 100_totalCollateral
: 1,000_rdpxCollateral
and its conversion rate: 10 (with a conversion rate of 10)When depositing, the formula used is:
totalVaultCollateral
= totalCollateral() + ((_rdpxCollateral * rdpxPriceInAlphaToken) / 1e8);shares
= assets.mulDivDown(supply, totalVaultCollateral)totalVaultCollateral
= 1000 + (10 * 10)shares
= 500 Γ 100 / 1100 β 45 sharesState changes:
totalSupply
increases to 145_totalCollateral
increases to 1,500_rdpxCollateral
and its conversion rate: 10 (with a conversion rate of 10)At the beginning of the redemption process, within the withdrawal function, the updateFunding() function is invoked. If the updateFunding() function hasn't been called for a while and tokens have accumulated, there can be a significant increase in the Collateral. In our scenario, this results in an addition of 200 collateral tokens, taking the total to 1700.
assets_returned
= shares.mulDivDown(totalCollateral(), supply);rdpx_returned
= shares.mulDivDown(_rdpxCollateral, supply);assets_returned
= 45 x 1700 / 145 = 527rdpx_returned
= 45 x 10 / 145 β 3_rdpxCollateral
* rdpxPriceInAlphaToken = 3 x 10 = 30net_gain
= 527 + 30 = 557 - 500 = 57The user was able to drain significant tokens without real participation.
Manual analysis
Rearrange the operations within the deposit function to update funding prior to calculating user shares:
function deposit(uint256 assets, address receiver) public virtual returns (uint256 shares) { perpetualAtlanticVault.updateFunding(); // Check for rounding error since we round down in previewDeposit. require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES"); // rest of the code }
Context
#0 - c4-pre-sort
2023-09-12T12:39:40Z
bytes032 marked the issue as duplicate of #867
#1 - c4-pre-sort
2023-09-12T12:39:55Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T19:26:36Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: said
Also found by: 0Kage, 0xCiphky, 0xkazim, 836541, AkshaySrivastav, Evo, HChang26, HHK, KrisApostolov, Neon2835, QiuhaoLi, Tendency, Toshii, bart1e, bin2chen, carrotsmuggler, chaduke, etherhood, gjaldon, glcanvas, josephdara, lanrebayode77, mahdikarimi, max10afternoon, nobody2018, peakbolt, qpzm, rvierdiiev, sces60107, tapir, ubermensch, volodya
17.313 USDC - $17.31
The deposit function in the PerpetualAtlanticVaultLP contract allows users to add collateral to the liquidity pool in exchange for shares. A flaw is apparent where shares for a user are calculated prior to invoking the updateFunding call in PerpetualAtlanticVault. This order of operations results in users receiving more shares than they should, as the calculation is based on a lesser collateral amount present before the updateFunding call.
Analyzing the existing deposit function reveals the sequence flaw:
function deposit(uint256 assets, address receiver) public virtual returns (uint256 shares) { // Check for rounding error since we round down in previewDeposit. require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES"); perpetualAtlanticVault.updateFunding(); // rest of the code }
Within the updateFunding function, there's an evident increase in the contract's collateral:
function updateFunding() public { // ... [rest of the code] collateralToken.safeTransfer(addresses.perpetualAtlanticVaultLP, (currentFundingRate * (block.timestamp - startTime)) / 1e18); IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).addProceeds((currentFundingRate * (block.timestamp - startTime)) / 1e18); // ... [rest of the code] }
The shares' computation, being based on the pre-update balances, is effectively outdated at the moment of assignment.
Lets look at the two scenarios and compare the results
Starting with the same initial values:
shares = 100 * 1000 / 2200 = 45 shares
shares = 100 * 1000 / 2400 = 41 shares
Manual analysis
To rectify this, the order of operations within the deposit function should be modified to ensure the funding gets updated before the user's shares are calculated:
function deposit(uint256 assets, address receiver) public virtual returns (uint256 shares) { perpetualAtlanticVault.updateFunding(); // Check for rounding error since we round down in previewDeposit. require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES"); // rest of the code }
Context
#0 - c4-pre-sort
2023-09-12T12:40:27Z
bytes032 marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-09-12T12:40:34Z
bytes032 marked the issue as duplicate of #867
#2 - c4-judge
2023-10-20T19:56:35Z
GalloDaSballo changed the severity to 3 (High Risk)
#3 - c4-judge
2023-10-20T19:57:07Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: 0xrafaelnicolau
Also found by: 0x111, 0xCiphky, 0xMosh, 0xWaitress, 0xc0ffEE, 0xkazim, 0xnev, 0xvj, ABAIKUNANBAEV, Aymen0909, Baki, ElCid, HChang26, HHK, Inspex, Jorgect, Kow, Krace, KrisApostolov, LFGSecurity, MiniGlome, Nyx, QiuhaoLi, RED-LOTUS-REACH, Talfao, Toshii, Vagner, Viktor_Cortess, Yanchuan, _eperezok, asui, atrixs6, bart1e, bin2chen, carrotsmuggler, chaduke, chainsnake, deadrxsezzz, degensec, dethera, dimulski, dirk_y, ether_sky, gizzy, glcanvas, grearlake, gumgumzum, halden, hals, kodyvim, koo, ladboy233, lanrebayode77, max10afternoon, minhtrng, mussucal, nobody2018, peakbolt, pontifex, qbs, ravikiranweb3, rvierdiiev, said, tapir, ubermensch, volodya, wintermute, yashar, zaevlad, zzebra83
0.0367 USDC - $0.04
https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L975 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L995
The protocol allows users to delegate WETH, which can subsequently be used by others through the bondWithDelegate functionality. Users can manage their delegation using the addToDelegate and withdraw functions as long as the funds are not active collateral.
An oversight in the withdraw function results in the totalWethDelegated
not being updated when users withdraw their WETH from delegation.
Below is the withdraw function from the smart contract:
function withdraw(uint256 delegateId) external returns (uint256 amountWithdrawn) { _whenNotPaused(); _validate(delegateId < delegates.length, 14); Delegate storage delegate = delegates[delegateId]; _validate(delegate.owner == msg.sender, 9); amountWithdrawn = delegate.amount - delegate.activeCollateral; _validate(amountWithdrawn > 0, 15); delegate.amount = delegate.activeCollateral; IERC20WithBurn(weth).safeTransfer(msg.sender, amountWithdrawn); emit LogDelegateWithdraw(delegateId, amountWithdrawn); }
Potential attack scenario:
addToDelegate
function.withdraw
function for 1000 WETH. (The totalWethDelegated
should be decremented here but isn't.)This results in:
NOTE: This issue will happen regardless as users delegate and withdraw their funds but we show an extreme scenario above to simulate the worst case.
Consequences:
totalWethDelegated
.Foundry test case
function testBreakAccounting() public { // lets start with 1000 WETH in reserves and 0 WETH delegated weth.mint(address(rdpxV2Core), 1000 * 1e18); // call sync function to update contract balances rdpxV2Core.sync(); // lets look at the protocols accounting // WETH reserve = 1000 WETH (, uint256 wethBalance1,) = rdpxV2Core.getReserveTokenInfo("WETH"); assertEq(wethBalance1, 1000 * 1e18); // User 1 takes flash loan of 1000 WETH address user1 = vm.addr(1); weth.mint(user1, 1000 * 1e18); assertEq(weth.balanceOf(user1), 1000 * 1e18); // User adds to delegate with 1000 WETH and X% fee (lets say 99% so other users don't use our collateral) vm.startPrank(user1); weth.approve(address(rdpxV2Core), 1000 * 1e18); uint256 delegateId = rdpxV2Core.addToDelegate(1000 * 1e18, 99 * 1e8); vm.stopPrank(); // lets look at the protocols and users accounting after adding to delegate // user balance = 0 WETH assertEq(weth.balanceOf(user1), 0); // UserDelegated = 1000 WETH (, uint256 amount,,) = rdpxV2Core.getDelegatePosition(delegateId); assertEq(amount, 1000 * 1e18); // totalWethDelegated = 1000 WETH assertEq(rdpxV2Core.totalWethDelegated(), 1000 * 1e18); // Reserve WETH = 1000 WETH (, uint256 wethBalance2,) = rdpxV2Core.getReserveTokenInfo("WETH"); assertEq(wethBalance2, 1000 * 1e18); // User undelegetes 1000 WETH and sync balances vm.startPrank(user1); rdpxV2Core.withdraw(delegateId); rdpxV2Core.sync(); vm.stopPrank(); // lets look at the protocols accounting now // totalWethDelegated = 1000 WETH assertEq(rdpxV2Core.totalWethDelegated(), 1000 * 1e18); // Reserve WETH = 0 WETh (, uint256 wethBalance3,) = rdpxV2Core.getReserveTokenInfo("WETH"); assertEq(wethBalance3, 0 * 1e18); // User 1 balance = 1000 WETH assertEq(weth.balanceOf(user1), 1000 * 1e18); // pay back flash loan }
Integrate the following line within the withdraw function:
totalWethDelegated -= amountWithdrawn;
Context
#0 - c4-pre-sort
2023-09-07T07:58:13Z
bytes032 marked the issue as duplicate of #2186
#1 - c4-pre-sort
2023-09-07T07:58:19Z
bytes032 marked the issue as high quality report
#2 - c4-judge
2023-10-20T17:55:32Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-10-20T17:56:36Z
GalloDaSballo marked the issue as satisfactory
#4 - c4-judge
2023-10-21T07:38:54Z
GalloDaSballo changed the severity to 3 (High Risk)
#5 - c4-judge
2023-10-21T07:46:47Z
GalloDaSballo marked the issue as partial-25
#6 - 0xCiphky
2023-10-22T04:01:41Z
Hey GalloDaSballo,
Can i get a brief explanation on the reason for labelling the finding partial-25, i have included a detailed analysis, attack situation, working POC and recommendation. What could i have done instead to get a higher ratio?
Thanks
#7 - GalloDaSballo
2023-10-24T07:51:55Z
Hey GalloDaSballo,
Can i get a brief explanation on the reason for labelling the finding partial-25, i have included a detailed analysis, attack situation, working POC and recommendation. What could i have done instead to get a higher ratio?
Thanks
I have explained this here: https://github.com/code-423n4/2023-08-dopex-findings/issues/239#issuecomment-1773169121
Showing revert of sync -> 50% Showing revert of lowerDepeg -> 100%
π 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
https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/amo/UniV2LiquidityAmo.sol#L160 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/amo/UniV3LiquidityAmo.sol#L313 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L315 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L372
The RdpxV2Core contract interacts with several other contracts such as UniV2LiquidityAMO, UniV3LiquidityAMO, PerpetualAtlanticVault, and ReLPContract. While funds are transferred between these contracts and RdpxV2Core, most functions update the accounting of the RdpxV2Core contract after this transfer by calling the sync function but not all. This can lead to discrepancies in calculations that rely on these values, such as the bond discount. As a result, users might be offered rates that do not reflect the most recent balances.
Certain functions transfer assets to the RdpxV2Core contract but donβt invoke the sync
function afterward, which is used to update the internal state:
The following functions transfer funds to the RdpxV2Core contract but donβt call sync after.
_sendTokensToRdpxV2Core()
in the UniV2LiquidityAMO contract.recoverERC20
in the UniV3LiquidityAMO contract.settle
and payFunding
in the PerpetualAtlanticVault contract.This omission can cause inaccurate calculations when determining rates. Given that multiple functions omit the necessary syncing step, it's likely that at some point the reserves used in calculations will be out of date, causing discrepancies.
Manual analysis
Integrate the sync function into the specified functions or prior to any computations involving reserve assets, guaranteeing that users receive rates based on the latest balances.
Token-Transfer
#0 - c4-pre-sort
2023-09-09T03:49:08Z
bytes032 marked the issue as duplicate of #798
#1 - c4-pre-sort
2023-09-09T04:09:18Z
bytes032 marked the issue as duplicate of #269
#2 - c4-pre-sort
2023-09-11T11:58:02Z
bytes032 marked the issue as sufficient quality report
#3 - c4-judge
2023-10-15T18:13:47Z
GalloDaSballo marked the issue as satisfactory