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: 42/189
Findings: 7
Award: $445.89
🌟 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
The function subtractLoss
subtracts the loss amount from the vault's LP during a settlement. There is also a require statement which makes sure the balance in the contract lines up with the accounting.
require( collateral.balanceOf(address(this)) == _totalCollateral - loss, "Not enough collateral was sent out" ); _totalCollateral -= loss;
The issue is that this condition can be easily violated with an external donation. The value in _totalColalteral
is changed during deposits, redeems, premium additions, and losses. Importantly, it is NOT dependent on the actual contract balance. So even before loss reporting, the amount of collateral in the contract is not necessarily equal to _totalCollateral
, since any user can send weth to the contract directly.
Lets assume the _totalcollateral
is 100 WETH, and the actual balance is 101 WETH due to an external donation. During a settle
call on the vault, WETH is withdrawn from the LP vault. Lets assume this amount is 50 WETH.
collateralToken.safeTransferFrom( addresses.perpetualAtlanticVaultLP, addresses.rdpxV2Core, ethAmount ); IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).subtractLoss( ethAmount );
Lets assume ethAmount
here is 50 WETH. Now, when subtractLoss
is called, collateral in contract = 101 - 50 = 51 WETH, and _totalCollateral-loss
= 100 - 50 = 50 WETH. This will cause the require statement to fail, and revert the transaction.
Since only the perp vault can call this function, there is no way for admins to fix this. This will break the whole settle
process.
Below POC is scavenged from the test suite. A donation is made which cause settlement to revert. If the donation is removed or commented out, the settlement goes through.
function testAttackSettle() 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; // skip(86500); // expire priceOracle.updateRdpxPrice(0.010 gwei); // ITM // Donation weth.transfer(address(vaultLp), 1 ether); vm.expectRevert("Not enough collateral was sent out"); vault.settle(ids); }
Foundry
Relax the require statement to allow the balance to be higher.
require( collateral.balanceOf(address(this)) >= _totalCollateral - loss, "Not enough collateral was sent out" );
Invalid Validation
#0 - c4-pre-sort
2023-09-09T10:02:27Z
bytes032 marked the issue as duplicate of #619
#1 - c4-pre-sort
2023-09-11T16:15:16Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T19:34:41Z
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 function deposit
in PerpetualAtlanticVaultLP.sol
processes deposits from users into the LP Vault. It first checks the shares to be minted, and then updates the funding state.
require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES"); perpetualAtlanticVault.updateFunding();
The updateFunding
function call transfers pending premium funds from the Vault to the LP. This basically transfers in the premium payments pending since the last update time. The issue is that the shares
is calculated before the premium is collected.
The vault acts similarly to ERC4626 vaults, but in this implementation actually handles multiple tokens, WETH and Rdpx. On deposits, users are minted shares proportional to their deposit. The amount of shares depends on the assets already in the vault. Since here the shares are calculated before transferring in more funds, the vault actually calculates the proportion of shares on a smaller pool of assets, and thus mints the user more shares than they should have received.
A common test for ERC4626 vaults is that if a user deposits some assets, and then immediately redeems the shares gained form that, the user will always end up with the same or less than the amount of tokens they started with. Since in this case the user is minted more shares than they should have been, they can redeem more tokens than they deposited. This is shown in the POC.
The following POC carries out the following steps:
If the vault was primed first, with a updateFunding
call, then the user receives the exact amount of eth they deposited. This call is in the POC as a comment, and if uncommented, will show that the user receives the correct amount of ETH back.
function testAttack() external { // Setup deposit(1 ether, address(this)); vault.purchase(1 ether, address(1)); skip(86500); uint256 wethBalLP = weth.balanceOf(address(vaultLp)); uint256 wethBalBefore = weth.balanceOf(address(this)); emit log_named_uint("wethBalLP before", wethBalLP); // Uncomment to check other variation // vault.updateFunding(); // Deposit uint256 lpBalBefore = vaultLp.balanceOf(address(this)); weth.approve(address(vaultLp), type(uint256).max); vaultLp.deposit(1 ether, address(this)); uint256 lpMinted = vaultLp.balanceOf(address(this)) - lpBalBefore; emit log_named_uint("lpGained", lpMinted); // redeem vaultLp.redeem(lpMinted, address(this), address(this)); uint256 wethAfter = weth.balanceOf(address(this)); emit log_named_uint("wethBalBefor", wethBalBefore); emit log_named_uint("wethBalAfter", wethAfter); }
Output:
Running 1 test for tests/perp-vault/Unit.t.sol:Unit [PASS] testAttack() (gas: 677624) Logs: wethBalLP before: 1000000000000000000 lpGained: 1000000000000000000 wethBalBefor: 2998950000000000000000 wethBalAfter: 2998974999999999999999
We see here that by depositing and immediately redeeming, the user can extract a part of the pending premium. If the user takes a flashloan and does the same with a large amount, they can extract a majority of the pending premium.
Foundry
Update vault state before calculating shares. Call perpetualAtlanticVault.updateFunding();
At the very beginning, like in the redeem
function.
Math
#0 - c4-pre-sort
2023-09-10T07:44:36Z
bytes032 marked the issue as duplicate of #867
#1 - c4-pre-sort
2023-09-10T07:44:40Z
bytes032 marked the issue as high quality report
#2 - c4-pre-sort
2023-09-11T09:08:37Z
bytes032 marked the issue as sufficient quality report
#3 - c4-judge
2023-10-20T19:26:04Z
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
The addToDelegate
function is used by users to deposit ETH to be used by other users to bond with rdpx. The function withdraw
does the opposite, and lets users receive their deposited eth back if not used. The amount of WETH deposited for delegated bonding is tracked by the variable totalWethDelegated
. This is increased when users deposit WETH using addToDelegate
.
totalWethDelegated += _amount;
However, this value is not decremented in the withdraw
function. Thus totalWethDelegated
does not actually track the amount of available WETH for delegated bonding. This not only makes the public variable meaningless, but also breaks the sync()
function since totalWethDelegated
can be much larger than the actual WETH balance in the contract.
if (weth == reserveAsset[i].tokenAddress) { balance = balance - totalWethDelegated; }
This bit in the sync function will revert. Also large amounts of WETH can be flashloaned, deposited and immediately withdrawn to unilaterally increase the totalWethDelegated
value. With enough iterations, this can be used to make the totalWethDelegated
value hit uint256.max which will break the addToDelegate
function.
No POC is provided since it is evident from looking at the function that totalWethDelegated
is not decremented.
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); }
Manual Review
Decrement totalWethDelegated
in the withdraw
function.
totalWethDelegated -= amountWithdrawn;
Math
#0 - c4-pre-sort
2023-09-07T08:27:20Z
bytes032 marked the issue as duplicate of #2186
#1 - c4-judge
2023-10-20T17:53:44Z
GalloDaSballo marked the issue as satisfactory
#2 - c4-judge
2023-10-21T07:43:29Z
GalloDaSballo marked the issue as partial-50
🌟 Selected for report: bin2chen
Also found by: 0Kage, 0xDING99YA, QiuhaoLi, Toshii, Yanchuan, carrotsmuggler, deadrxsezzz, ether_sky, flacko, gjaldon, kutugu, mert_eren, pep7siup, qpzm, said, sces60107, tapir, ubermensch
39.433 USDC - $39.43
The value stored in tokenAinfo.tokenAPrice
stores the price of Rdpx in ETH. Thus it holds the ETH per Rdpx amount. This evident from the following calculation, where this is multiplied by the amount of Rdpx.
uint256 mintokenBAmount = ((tokenAToRemove * tokenAInfo.tokenAPrice) / 1e8) - ((tokenAToRemove * tokenAInfo.tokenAPrice) * liquiditySlippageTolerance) / 1e16;
Here tokenAToRemove
is the amount of Rdpx to remove, and tokenAInfo.tokenAPrice
is the price of Rdpx in ETH. Thus the result is the amount of ETH to remove. This is used to set the minimum amount of eth expected from the liquidity removal.
Next these ETH tokens are swapped for Rdpx, and the minimum of amount of Rdpx tokens expected is calculated.
mintokenAAmount = (((amountB / 2) * tokenAInfo.tokenAPrice) / 1e8) - (((amountB / 2) * tokenAInfo.tokenAPrice * slippageTolerance) / 1e16);
However, the issue here is that the amountB
, which is the amount of ETH, is multiplied by the Rdpx price instead of dividing it.
tokenAprice
stores ETH / Rdpx ratio. So to calculate the amount of Rdpx, we need to divide the price, amount Rdpx
= amount ETH / (ETH / Rdpx)
Since the oracle price is multiplied instead of being divided, this give the wrong minimum amount. This will cause the contract to either expect too little, leading to slippage loss, or too much, leading to a revert. Thus depending on the price, it can lead to a DOS of the contract.
It is clear from the code, that both instances A and B cannot be correct. Both are multiplying the amount and the price, but in one case amount is in Rdpx, and the other is in Eth, andthe price in both cases is the same value. One must be a division.
// Instance A uint256 mintokenBAmount = ((tokenAToRemove * tokenAInfo.tokenAPrice) // Instance B mintokenAAmount = (((amountB / 2) * tokenAInfo.tokenAPrice) / 1e8)
Manual Review
Divide the amountB
by the price, instead of multiplying it.
Math
#0 - c4-pre-sort
2023-09-09T05:29:02Z
bytes032 marked the issue as duplicate of #1805
#1 - c4-pre-sort
2023-09-11T07:00:04Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-16T08:47:52Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-10-20T09:26:29Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: QiuhaoLi
Also found by: 0xDING99YA, 0xvj, SBSecurity, T1MOH, Toshii, Udsen, bart1e, bin2chen, carrotsmuggler, pep7siup, said, sces60107, wintermute
90.6302 USDC - $90.63
The _curveswap
function takes an amount parameter, and then either converts dpxeth into eth or the other way around depending on the passed _ethToDpxEth
bool value using curve LP.
uint256 minOut = _ethToDpxEth ? (((_amount * getDpxEthPrice()) / 1e8) - (((_amount * getDpxEthPrice()) * slippageTolerance) / 1e16)) : (((_amount * getEthPrice()) / 1e8) - (((_amount * getEthPrice()) * slippageTolerance) / 1e16));
For slippage, a minimum amount out is calculated in the above shown snippet if not passed into the function already. The oracle price is given by the function getDpxEthPrice
, which reports the amount of ETH per dpxETH. Since it is ETH / dpxETH, it must be multiplied with the dpxETH amount. However in the above snippet we see that it i used wrongly.
If _ethToDpxEth
is true, then ETH is being converted to dpxETH, and the passed _amount
is the amount of ETH to be converted. But if _ethToDpxEth
is true, the minimum is calculated as amount * getDpxEthPrice()
. Since getDpxEthPrice()
is ETH / dpxETH, we are calculating ETH * ETH / dpxETH. This is wrong. So the wrong value of minimum amount will be calculated.
The issue is that if the oracle shows a price higher than 1 ETH / dpxETH, then the eth-to-dpxeth minimum amount will be calculated to be higher than the input amount. This will lead to the swap always reverting.
More proof that getDpxEthPrice()
is ETH / dpxETH is given in the function upperDepeg
. Here, the upperdepeg condition is checked as follows.
_validate(getDpxEthPrice() > 1e8, 10);
This shows that the oracle price is ETH / dpxETH, and not dpxETH / ETH.
In the testUpperDepeg
test function, we see that the price is set to more than 1e8. This shows that the oracle price is in ETH/dpxETH.
dpxEthPriceOracle.updateDpxEthPrice(103012950);
Manual Review
Flip the ternary operator in _curveswap
. Currently the wrong code path is taken for the wrong value of ethToDpxEth
. Flipping the ternary statements will resolve the issue.
Math
#0 - c4-pre-sort
2023-09-09T05:31:27Z
bytes032 marked the issue as primary issue
#1 - c4-pre-sort
2023-09-12T05:05:10Z
bytes032 marked the issue as duplicate of #970
#2 - c4-pre-sort
2023-09-14T05:00:25Z
bytes032 marked the issue as sufficient quality report
#3 - c4-judge
2023-10-18T12:34:51Z
GalloDaSballo marked the issue as satisfactory
#4 - c4-judge
2023-10-21T07:53:20Z
GalloDaSballo changed the severity to 2 (Med Risk)
🌟 Selected for report: said
Also found by: 0xWaitress, Nyx, RED-LOTUS-REACH, Tendency, __141345__, carrotsmuggler, nirlin, peakbolt, qpzm, wallstreetvilkas
158.2271 USDC - $158.23
The PerpetualAtlanticVault.sol
:purchase
function allows users to buy options after paying a premium. This is called from the core contract, and returns the premium
value which will be deducted from the user. The price or the premium
to be paid is calculated in the following snippet.
uint256 timeToExpiry = nextFundingPaymentTimestamp() - block.timestamp; // Get total premium for all options being purchased premium = calculatePremium(strike, amount, timeToExpiry, 0);
The timeToExpiry
is calculated as the time difference between block.timestamp
and the next funding epoch timestamp. The issue is that users can bond close to the epoch timestamp to have cheaper premiums due to lower time to expiry.
According to the docs here, the Atlantic Perpetual PUTS Options
section states that the premium will be calculated according to the BlackScholes model.
The BlackScholes model for put options pricing is as follows:
Where T-t is the time to expiry. All options decay with time as can be seen from the exponential decay term, thus the premium of the option also goes down as the time to expiry decreases.
So if a user times their purchase close to the epoch timestamp, their timeToExpiry
will be very low, which translates to a very cheap option price. These options dont expire, and can be held until settlement. So users can time their bonding to get cheaper options and thus cheaper bonding costs compared to users who dont time their bonding purchases.
A POC couldn't be developed due to the Mock contracts returning a fixed premium value and not implementing the BlackScholes model. However the snippet above clearly shows that time to expiry depends on block.timestamp, and the option premiums always decrease according to the Scholes model. Thus the issue is valid.
Manual Review
Calculate timeToExpiry
as difference between two epoch timestamps. That way every user will be charged the same premium provided the price and strike remain the same.
Timing
#0 - c4-pre-sort
2023-09-13T07:19:44Z
bytes032 marked the issue as duplicate of #237
#1 - c4-pre-sort
2023-09-14T09:28:44Z
bytes032 marked the issue as not a duplicate
#2 - c4-pre-sort
2023-09-14T09:35:17Z
bytes032 marked the issue as duplicate of #761
#3 - c4-judge
2023-10-20T11:58:56Z
GalloDaSballo marked the issue as satisfactory
#4 - c4-judge
2023-10-21T07:54:55Z
GalloDaSballo changed the severity to 2 (Med Risk)
🌟 Selected for report: juancito
Also found by: 0xDING99YA, 0xTiwa, 0xkazim, 0xnev, ABA, ArmedGoose, Aymen0909, Bauchibred, Evo, IceBear, KrisApostolov, MohammedRizwan, Nikki, QiuhaoLi, T1MOH, Toshii, WoolCentaur, Yanchuan, __141345__, asui, bart1e, carrotsmuggler, catellatech, chaduke, codegpt, deadrxsezzz, degensec, dethera, dirk_y, erebus, ether_sky, gjaldon, glcanvas, jasonxiale, josephdara, klau5, kodyvim, ladboy233, lsaudit, minhquanym, parsely, peakbolt, pep7siup, rvierdiiev, said, savi0ur, sces60107, tapir, ubermensch, volodya, zzebra83
140.2087 USDC - $140.21
The function bondWithDelegate
is used to bond rdpx tokens to WETH tokens provided by delegators. The function takes an array of amounts and delegation ids, and loops over the arrays and takes the amount form each delegate id sent.
for (uint256 i = 0; i < _amounts.length; i++) { // Validate amount _validate(_amounts[i] > 0, 4);
The only validation done is that each amount is non-zero. When a user bonds this way, part of the bond is staked on the user's account, and the rest is staked on the delegator's account. The user can choose to set the value of amount[]
as low as possible, and the transaction will go through as long as the amount is non zero.
So if a malicious user takes a bunch of delegations and match small amounts for each of them, the delegators will get small amount of the bonds. These amounts can be so small that it isnt worth it for the delegators to redeem them on expiry due to gas costs. The attacker can also choose to match the same delegation multiple times in small amounts, and this also give rise to the same scenario.
The attacker gets an inherent advantage since their actions are batched, but the delegators have to redeem their bonds one at a time, and so will have to pay far higher gas costs. This can be used to grief the delegators.
This issue arises from there being no minimum amount to match delegations. This can be seen in the following snippet.
for (uint256 i = 0; i < _amounts.length; i++) { // Validate amount _validate(_amounts[i] > 0, 4);
Manual Review
Specify a minimum _amounts[i]
value so that the delegators are not griefed by gas costs.
Other
#0 - c4-pre-sort
2023-09-14T18:38:43Z
bytes032 marked the issue as duplicate of #1883
#1 - c4-judge
2023-10-20T09:18:10Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#2 - c4-judge
2023-10-20T18:16:47Z
GalloDaSballo marked the issue as grade-a