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: 101/189
Findings: 3
Award: $79.49
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVaultLP.sol#L145
In PerpetualAtlanticVaultLP, users can deposit WETH to get shares, which can be redeemed for WETH yield (and RDPX in the future). The rewards are payed in drip-manner and also at the start of each epoch (related to the funding rate of the entire epoch). These epoch funding payments have two things to consider:
nextFundingPaymentTimestamp()
to check
value = (shares * totalWethSupply) / sharesSupply
. Each epoch start pays the WETH yield, so totalWethSupply
gets bigger and affects the equation numerator in a way that the value
of a share becomes bigger. The following function is the reference:function _convertToAssets( uint256 shares ) internal view virtual returns (uint256 assets, uint256 rdpxAmount) { uint256 supply = totalSupply; return (supply == 0) ? (shares, 0) : ( shares.mulDivDown(totalCollateral(), supply), shares.mulDivDown(_rdpxCollateral, supply) );
So, with this two properties in mind, an attacker can exploit this in the following steps:
Attacker checks nextFundingPaymentTimestamp()
to know when the funding payment will happen and picks a close timestamp to start the second step
Attacker deposits and get shares at the valuation of totalSupply()
(total WETH supply) as X
Epoch Funding Payment happens and increases totalSupply()
. Now the valuation of a share is X + how much the payment increased the totalSupply()
. Let's say it's X + 0.1X (10% increase)
Attacker immediately redeem his shares
Result: Attacker got shares in a valuation X and instantly redeem them for a valuation 1.1X for a profit of around 10% WETH in a Risk-Free trade, since the funding payment has a predictable timestamp for any epoch.
The reason why the attacker can do this is a lack of fees in the redeem()
, a function to redeem shares for underlying assets. While most Yield protocols has a withdrawFee
to prevent this kind of attack, PerpetualAtlanticVaultLP
dont, which causes this vulnerability.
Attacker can steal a considerable amount of funds without giving liquidity to protocol
Manual Audit
There are two possibilities:
ERC4626
#0 - c4-pre-sort
2023-09-12T12:14:51Z
bytes032 marked the issue as sufficient quality report
#1 - bytes032
2023-09-12T12:14:56Z
Looks similar to #1871
#2 - c4-pre-sort
2023-09-15T13:18:00Z
bytes032 marked the issue as duplicate of #1584
#3 - c4-judge
2023-10-20T14:26:28Z
GalloDaSballo changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-10-20T14:27:41Z
GalloDaSballo changed the severity to 3 (High Risk)
#5 - c4-judge
2023-10-20T14:52:54Z
GalloDaSballo marked the issue as not a duplicate
#6 - c4-judge
2023-10-20T18:37:01Z
GalloDaSballo marked the issue as duplicate of #867
#7 - c4-judge
2023-10-20T18:37:29Z
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
deposit
checks how many assets the user may receive in the line require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES");
deposit
calls updateFundings
, which is the method used to update the "rewards" to PerpetualAtlanticVaultLP.sol
. Also, updateFundings
calls updateFundingPaymentPointer
, which is a method that updates the fundings payment of the entire epoch.shares
in an ERC-4626-Like protocol are calculated in the following way: shares = (assets to deposit * Total Supply of Shares) / Total Supply of Underlying Tokens
. So, the amount of collateral the protocol has impacts the denominator, so (+) collateral supply = (-) shares get per deposit
function convertToShares( uint256 assets ) public view returns (uint256 shares) { uint256 supply = totalSupply; uint256 rdpxPriceInAlphaToken = perpetualAtlanticVault.getUnderlyingPrice(); uint256 totalVaultCollateral = totalCollateral() + ((_rdpxCollateral * rdpxPriceInAlphaToken) / 1e8); return supply == 0 ? assets : assets.mulDivDown(supply, totalVaultCollateral); }
updateFundings
increases the supply of collateral for the protocol, changing the demoninator. However, shares of the first depositor are calculated before the updateFundings
, effectively making the first depositor of each epoch to get more shares than It should.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(); // Need to transfer before minting or ERC777s could reenter. collateral.transferFrom(msg.sender, address(this), assets); _mint(receiver, shares); _totalCollateral += assets; emit Deposit(msg.sender, receiver, assets, shares); }
The calculation of assets to get for the redeem
of shares is the following: assets = (Shares to burn * CollateralTotalSupply) / SharesTotalSupply
. So, the updateFundings
increases the CollateralTotalSupply
, which is in the numerator, so it effectivelly also increases the valuation of a shares.
In conclusion, the first depositor of an epoch can get more shares he should and redeem it for a higher valuation.
function _convertToAssets( uint256 shares ) internal view virtual returns (uint256 assets, uint256 rdpxAmount) { uint256 supply = totalSupply; return (supply == 0) ? (shares, 0) : ( shares.mulDivDown(totalCollateral(), supply), shares.mulDivDown(_rdpxCollateral, supply) ); }
An epoch for Perpetual Options is only 7 days, which allows this bug to be triggered four times a month. Considering the valuation of shares increases by just 10% each epoch, around 1 WETH can be "lost" each 10 epochs (70 days). Also, if protocol is deployed in a chain with MEV, attackers can use this for Risk-Free trades since he can be more sure that is going to be the first depositor of the epoch.
Manual Review
Protocol should updateFundings()
before the calculation of shares in deposit
method.
Timing
#0 - c4-pre-sort
2023-09-07T13:30:14Z
bytes032 marked the issue as duplicate of #1948
#1 - c4-pre-sort
2023-09-07T13:42:27Z
bytes032 marked the issue as duplicate of #867
#2 - c4-pre-sort
2023-09-11T09:05:15Z
bytes032 marked the issue as sufficient quality report
#3 - c4-judge
2023-10-20T19:56:35Z
GalloDaSballo changed the severity to 3 (High Risk)
#4 - c4-judge
2023-10-20T19:57:03Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: 0xWagmi
Also found by: 836541, Bauchibred, GangsOfBrahmin, Hama, IceBear, Inspecktor, Matin, MohammedRizwan, catellatech, erebus, lsaudit, niki, okolicodes, ravikiranweb3, tapir, vangrim, zaevlad
46.2486 USDC - $46.25
Consider totalAsset()
as the total value of underlying assets deposited in PerpetualAtlanticVaultLP.sol
and totalSupply()
as the total value of existing shares.
When users deposit in PerpetualAtlaticVaultLP.sol
, shares are minted so he can redeem in the future to get Yield. The calculation of how many shares he gets for an amount of deposited assets is: shares = (assets deposited * totalSupply()) / totalAsset()
and happens in this function:
function convertToShares( uint256 assets ) public view returns (uint256 shares) { uint256 supply = totalSupply; uint256 rdpxPriceInAlphaToken = perpetualAtlanticVault.getUnderlyingPrice(); uint256 totalVaultCollateral = totalCollateral() + ((_rdpxCollateral * rdpxPriceInAlphaToken) / 1e8); return supply == 0 ? assets : assets.mulDivDown(supply, totalVaultCollateral); }
Here's how the contract is vulnerable:
PerpetualAtlanticVaultLP.sol
has been deployed and configured, no depositors yetERC20::transfer
to transfer 1e18 (1 WETH) directly to the contract without using deposit
method so he inflates the denominator of shares calculation. Thus, totalAsset()==1e18 + 1, totalSupply()==1.shares = (1 * 2e18) / (1e18 + 1)
, which results in 1.9999
, but since integer truncation
happens in Solidity, the actual value is 1 share
.totalSupply() == 3 WETH
. So, attacker redeems his 1 share, which is 50% of the totalSupply()
pool (1 for Victim and 1 for attacker) and gets 1.5 WETH, a profit of 50% of his initial deposit.Since the protocol is initially being deployed in a MEVless chain, the probability of the attack is low. However, the impact is high because of the steal of funds, so the protocol needs to be protected against any possibility of frontrun.
Manual Audit
UniswapV2 fixed this with two steps:
First, the first mint it actually mints the first 1000 shares to the zero-address
Second, it requires that the minted shares are not 0
The second step is already implemented in deposit
function, so only the first step is required.
ERC4626
#0 - c4-pre-sort
2023-09-07T13:25:52Z
bytes032 marked the issue as duplicate of #863
#1 - c4-pre-sort
2023-09-11T09:10:03Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-18T12:49:59Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: 0xTheC0der
Also found by: 0Kage, 0xDING99YA, 0xHelium, 0xbranded, 836541, ABA, Kow, QiuhaoLi, SpicyMeatball, T1MOH, __141345__, alexfilippov314, ayden, bart1e, bin2chen, chaduke, degensec, jasonxiale, joaovwfreire, nirlin, peakbolt, pep7siup, rvierdiiev, tnquanghuy0512
15.9268 USDC - $15.93
The epoch in the protocol is used as the "expiry date" of options and the frequency which fundings are payed to depositors in PerpetualAtlanticVaultLP.sol
. The standard epoch duration is set to 7 days in the fundingDuration
state variable at PerpetualAtlanticVault.sol
.
uint256 public fundingDuration = 7 days;
It can also be changed via updateFundingDuration
method in PerpetualAtlanticVault.sol#L237
.
function updateFundingDuration( uint256 _fundingDuration ) external onlyRole(DEFAULT_ADMIN_ROLE) { fundingDuration = _fundingDuration; }
The function nextFundingPaymentTimestamp()
calculates the ending of an epoch using the state variable fundingDuration
.
function nextFundingPaymentTimestamp() public view returns (uint256 timestamp) { return genesis + (latestFundingPaymentPointer * fundingDuration); }
genesis + (latestFundingPaymentPointer * fundingDuration
, which can be translated to genesis + (Counter of Epochs * Duration of an Epoch)
.
It assumes that all epochs have the same duration, which is a mistake:fundingDuration == 7 days
, so nextFundingPaymentDuration()
returns genesis + 70 days
.fundingDuration
to 10 days
.nextFundingPaymentDuration()
returns genesis + 100 days
. However, as an epoch now lasts 10 days, the 30 days difference from one fundingDuration
value to another isn't correct.fundingDuration
value since important methods updateFundings
, updateFundingPaymentPointer
and purchase
(all from PerpetualAtlanticVault.sol
) uses nextFundingPaymentTimestamp
in some way.First, we need to understand the function that uses the vulnerable nextFundingPaymentTimestamp
and is linked to purchase
and updateFundings
. It's updateFundingPaymentPointer
and it's purpose is to pay an epoch when it ends and also update the latestFundingPaymentPointer
, which is the counter of epochs. To do that, first it checks if block.timestamp
(current time) is higher or equal to nextFundingPaymentTimestamp
, so it only updates if the current epoch ended. That's done in a loop way to update even if the counter is 2 epochs outdated.
 function updateFundingPaymentPointer() public { // 1 - LOOPS UNTIL CURRENT TIME IS LOWER THAN EPOCH ENDING   while (block.timestamp >= nextFundingPaymentTimestamp()) { /// some code // 2 - PAY THE LAST EPOCH BEFORE STARTING A NEW ONE     collateralToken.safeTransfer(      addresses.perpetualAtlanticVaultLP,      (currentFundingRate * (nextFundingPaymentTimestamp() - startTime)) /       1e18     ); /// some code // 3 - UPDATE THE CODE TO HANDLE THE CURRENT EPOCH latestFundingPaymentPointer += 1;
Impacts are scenarios of denial of service, wrong funding payments and breaking the correct calculation of nextFundingPaymentTimestamp
for an unlimited amount of time:
fundingDuration
to a lower value:fundingDuration = 7 days
and latestFundingPaymentPointer - 7
(Counter of Epochs = 7, so we are in epoch 7). With this state variables, nextFundingPaymentTimestamp == genesis + 49 days
fundingDuration
to 5 days
via updateFundingDuration
.nextFundingPaymentTimestamp == genesis + 35 days
. So, even that fundingDuration
changed to 5 days
, which should only produce a difference of 2 days to the original output, the function is now returning a difference of 14 days, effectively acting like the contract is 2-3 epochs away from the actual epoch.updateFundingPaymentPointer
will loop and increment lastFundingPaymentPointer
a few times to get updated to make nextFundingPaymentTimestamp
get updated againwhile (block.timestamp >= nextFundingPaymentTimestamp() { // ... Â Â Â Â collateralToken.safeTransfer( Â Â Â Â Â addresses.perpetualAtlanticVaultLP, Â Â Â Â Â (currentFundingRate * (nextFundingPaymentTimestamp() - startTime)) / Â Â Â Â Â Â 1e18 Â Â Â Â ); // ... lastFundingPaymentPointer += 1; }
lastFundingPaymentPointer
was incremented unduly, two things happen with the protocol:currentFundingRate
of the epoch, which is a value based on sold option's premiums is lost since it's based in the current epoch and the protocol skipped the current epoch
lastUpdatePaymentPointer
, which is the counter of epochs now is forever wrong. If before the output of nextFundingPaymentTimestamp
was genesis + 49 days
, even if admin changes back to 7 days
the fundingDuration
, nextFundingPaymentTimestamp
will output a bigger value since the calculation relies on lastFundingPaymentPointer
. This will make every epoch lasts more than it should, since all epoch-related functions relies on nextFundingPaymentTimestamp
.
Scenario of updating fundingDuration
to a higher value.
fundingDuration = 7 days
and latestFundingPaymentPointer - 7
(Counter of Epochs = 7, so we are in epoch 7). With this state variables, nextFundingPaymentTimestamp == genesis + 49 days
fundingDuration
to 9 days
.nextFundingPaymentTimestamp == genesis + (7 * 9)
, which is genesis + 63 days
.updateFundingPaymentPointer
isn't affected anymore, since it relies that block.timestamp >= nextFundingPaymentTimestamp
. However, updateFundings()
, which is the function used in PerpetualAtlanticVault.sol
and called in important functions, like purchase
(used to buy Options), redeem
and deposit
(both used in PerpetualAtlanticVaultLP.sol
for ERC-4626-Like Yield Farm) is now affected:Â 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 Â Â ); // ... }
PerpetualAtlanticVaultLP.sol
in a drip-maner way since it's called by a lot of methods and transfers to VaultLP a quantity of funds based on how much time of the epoch has already passed currentFundingRate * (block.timestamp - startTime)
.nextFundingPaymentTimestamp
output is bigger than it should, updateFundingPaymentPointer
will take longer to update the epoch, so the epoch will efectively last longer.The calculation currentFundingRate * (block.timestamp - startTime)
will start to get unexpected values, since the epoch duration wasn't expected to be so big.
Because of (5), more funds than it should will be transfered to VaultLP
depositors. Also, safeTransfer
(using SafeERC20.sol
library) will revert if PerpetualAtlanticVault.sol
doesn't have enough funds, which probably will happen since the protocol was expecting to drip-maner pay a 8 days
epoch, not a bigger one.
So, denial of service will happen and even if admin updates updateFunding
to the standard value 7 days
again, the nextFundingPaymentTimestamp
can have unexpected actions since his calculation is wrong and can't handle how much days passed correctly.
Manual Review
fundingDuration
values used.fundingDuration
usedupdateFundingPaymentPointer
, which is the function that updates epochs to interact with this mappingnextFundingPaymentTimestamp
can correctly calculate the end of an epoch function nextFundingPaymentTimestamp()   public   view   returns (uint256 timestamp)  { uint256 value; for (uint x; x < fundingDurationValues.length - 1; ++ x) { uint256 fundingDurationValue = fundingDurationValues[x]; value += epochsForEachFundingDuration[fundingDurationValue] * fundingDurationValue;   return genesis + value;  }
Math
#0 - c4-pre-sort
2023-09-08T06:32:18Z
bytes032 marked the issue as duplicate of #980
#1 - c4-pre-sort
2023-09-11T08:23:48Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T11:12:00Z
GalloDaSballo marked the issue as satisfactory