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: 69/189
Findings: 3
Award: $158.31
🌟 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
settle at PerpetualAtlanticVault can be permanently Dos-ed by donation due to the strict balance check requirement at VaultLP::subtractLoss.
settle at PerpetualAtlanticVault is an essential function to settle an Atlantic put option as soon as it gets into strike price/ITM, it's an important step to settle using the underlying WETH by pulling the collateralToken(aka WETH) from the VaultLP into rdpxV2Core.
function settle( uint256[] memory optionIds ) external nonReentrant onlyRole(RDPXV2CORE_ROLE) returns (uint256 ethAmount, uint256 rdpxAmount) { ... // Transfer collateral token from perpetual vault to rdpx rdpxV2Core collateralToken.safeTransferFrom( addresses.perpetualAtlanticVaultLP, addresses.rdpxV2Core, ethAmount );
After the transfer, the function then proceeds to call subtractLoss at the VaultLP to complete the accounting so that the latest collateralToken balance is in sync with the loss incurred by the settlement.
... IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).subtractLoss( ethAmount ); ...
However, subtractLoss at the VaultLP requires a strict balance check. A malicious user can Dos any settle tx by simply donating 1wei of collateralToken into the VaultLP such that the balanceOf the VaultLP becomes more than _totalCollateral - loss
, since now _totalCollateral
become out-of-sync with balanceOf. The function would revert due to failure to comply with the strict balance check.
function subtractLoss(uint256 loss) public onlyPerpVault { require( collateral.balanceOf(address(this)) == _totalCollateral - loss, "Not enough collateral was sent out" ); _totalCollateral -= loss; }
Impact: permissionless attack + low cost + permanent bricking an essential function for user to settle financial vehicles = High Risk Issue
Copying & Modifying from tests/perp-vault/Unit.t.sol
function testSettleWithDonation() public { weth.mint(address(1), 1 ether); deposit(1 ether, address(1)); vault.purchase(1 ether, address(this)); uint256[] memory ids = new uint256[](1); ids[0] = 0; priceOracle.updateRdpxPrice(0.2 gwei); // initial price * 10 uint256[] memory strikes = new uint256[](1); strikes[0] = 0.015 gwei; skip(86500); // expire priceOracle.updateRdpxPrice(0.010 gwei); // ITM //donation of weth weth.mint(address(vaultLp), 1 wei); vm.expectRevert("Not enough collateral was sent out"); vault.settle(ids); }
Consider: 1.simply sync the balance with _totalCollateral. 2. or use a loose form of balance check:
function subtractLoss(uint256 loss) public onlyPerpVault { require( --- collateral.balanceOf(address(this)) == _totalCollateral - loss, +++ collateral.balanceOf(address(this)) >= _totalCollateral - loss, --- "Not enough collateral was sent out" +++ "LP Vault is over-subtracted" ); _totalCollateral -= loss; }
Invalid Validation
#0 - c4-pre-sort
2023-09-09T06:22:49Z
bytes032 marked the issue as duplicate of #619
#1 - c4-pre-sort
2023-09-11T16:14:05Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T19:37:35Z
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.0734 USDC - $0.07
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L995-L1008
totalWethDelegated is not updated during withdraw leading to potential underflow in sync function in RpdxV2Core
Consider:
addToDelegate
with 1 WETH, totalWethDelegated=1WETH, core has 1WETH in total balancewithdraw
to retrieve his 1WETH. his delegate.amount is updated to his delegate.activeCollateral as 0. 1WETH is withdrawn.balance = balance - totalWethDelegated;
. balance is 0 but totalWethDelegated is still 1WETH.Impact:
sync function
function sync() external { for (uint256 i = 1; i < reserveAsset.length; i++) { uint256 balance = IERC20WithBurn(reserveAsset[i].tokenAddress).balanceOf( address(this) ); if (weth == reserveAsset[i].tokenAddress) { balance = balance - totalWethDelegated; } reserveAsset[i].tokenBalance = balance; } emit LogSync(); }
However, totalWethDelegated is not reduced during withdraw
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); }
reduce totalWethDelegated by the amount being withdrawn in withdraw
function withdraw( uint256 delegateId ) external returns (uint256 amountWithdrawn) { ... +++ totalWethDelegated -= amountWithdrawn;
Under/Overflow
#0 - c4-pre-sort
2023-09-07T08:09:32Z
bytes032 marked the issue as duplicate of #2186
#1 - c4-judge
2023-10-20T17:55:32Z
GalloDaSballo changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-10-20T17:59:37Z
GalloDaSballo marked the issue as satisfactory
#3 - c4-judge
2023-10-21T07:38:55Z
GalloDaSballo changed the severity to 3 (High Risk)
#4 - c4-judge
2023-10-21T07:49:27Z
GalloDaSballo marked the issue as partial-50
🌟 Selected for report: said
Also found by: 0xWaitress, Nyx, RED-LOTUS-REACH, Tendency, __141345__, carrotsmuggler, nirlin, peakbolt, qpzm, wallstreetvilkas
158.2271 USDC - $158.23
purchase of at-the-expiry option is possible creating free option opportunity for people to bond for 1 week.
purchase is a function for the rpdxV2Core to purchase a perpetual atlantic put which can be settled into the collateral whenever it's price becomes ITM. Premium is calculated based on the timeToExpiry (with ref to blackScholes model).
Here is the workflow:
purchase uses timeToExpiry to calculate the premium needed for buying an option, which is the difference between nextFundingPaymentTimestamp()
to the current block timestamp.
nextFundingPaymentTimestamp = genesis + (latestFundingPaymentPointer * fundingDuration)
, where fundingDuration is a fixed 7 days, genesis is immutably set upon construction.
latestFundingPaymentPointer is updated every time the block.timestamp >= nextFundingPaymentTimestamp()
. There is a possibility that block.timestamp == nextFundingPaymentTimestamp()
When such things happen, according to black scholes model, the premium required to purchase a put option with zero time to expiry is 0. Essentially if an attacker can always choose to bond/purchase option at this time, such that the Vault collect no premium for that initial epoch from the V2Core even it keeps writing options.
Although the vault would collect funding from the V2Core according to pre-defined funding rate, however, the payFunding is only callable by V2Core itself, and only triggerable by provideFunding at V2Core which is also an admin function. This control flow means if the admin forget or miss the funding call then the premium would never be collected.
function purchase( uint256 amount, address to ) external nonReentrant onlyRole(RDPXV2CORE_ROLE) returns (uint256 premium, uint256 tokenId) { _whenNotPaused(); _validate(amount > 0, 2); updateFunding(); uint256 currentPrice = getUnderlyingPrice(); // price of underlying wrt collateralToken uint256 strike = roundUp(currentPrice - (currentPrice / 4)); // 25% below the current price IPerpetualAtlanticVaultLP perpetualAtlanticVaultLp = IPerpetualAtlanticVaultLP( addresses.perpetualAtlanticVaultLP ); // Check if vault has enough collateral to write the options uint256 requiredCollateral = (amount * strike) / 1e8; _validate( requiredCollateral <= perpetualAtlanticVaultLp.totalAvailableCollateral(), 3 ); uint256 timeToExpiry = nextFundingPaymentTimestamp() - block.timestamp; // Get total premium for all options being purchased premium = calculatePremium(strike, amount, timeToExpiry, 0); ...
purchase can be called by bond -> _purchaseOption at the V2Core
function bond( uint256 _amount, uint256 rdpxBondId, address _to ) public returns (uint256 receiptTokenAmount) { _whenNotPaused(); // Validate amount _validate(_amount > 0, 4); // Compute the bond cost (uint256 rdpxRequired, uint256 wethRequired) = calculateBondCost( _amount, rdpxBondId ); IERC20WithBurn(weth).safeTransferFrom( msg.sender, address(this), wethRequired ); // update weth reserve reserveAsset[reservesIndex["WETH"]].tokenBalance += wethRequired; // purchase options uint256 premium; if (putOptionsRequired) { premium = _purchaseOptions(rdpxRequired); } ...
_purchaseOption
function _purchaseOptions( uint256 _amount ) internal returns (uint256 premium) { /** * Purchase options and store ERC721 option id * Note that the amount of options purchased is the amount of rDPX received * from the user to sufficiently collateralize the underlying DpxEth stored in the bond **/ uint256 optionId; (premium, optionId) = IPerpetualAtlanticVault( addresses.perpetualAtlanticVault ).purchase(_amount, address(this)); optionsOwned[optionId] = true; reserveAsset[reservesIndex["WETH"]].tokenBalance -= premium; }
Impact: free option purchase + exercise for ITM strikes at the time of expiry possible. Since settle
at RdpxV2Core can only be called by admin, thus the attack can only completed if any user called purchase of option through bondWithDelegate
and the the admin settle the option within the same block (time == block.timestamp).
privileged call + severe impact = medium risk issue
Consider revoking the ability to purchase at the expiry time.
Invalid Validation
#0 - c4-pre-sort
2023-09-13T15:24:27Z
bytes032 marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-09-14T16:13:46Z
bytes032 marked the issue as duplicate of #761
#2 - c4-judge
2023-10-20T15:47:11Z
GalloDaSballo marked the issue as satisfactory