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: 12/189
Findings: 4
Award: $1,482.76
🌟 Selected for report: 3
🚀 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/main/contracts/perp-vault/PerpetualAtlanticVaultLP.sol#L201 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L359-L361
The PerpetualAtlanticVaultLP::subtractLoss()
function has a strict balance check with a require
.
By providing any amount of collateral to it, like 1 wei, the function will always revert. This function is used by the PerpetualAtlanticVault::settle()
function, which is called by the RdpxV2Core::settle()
function.
This will make the settle function always revert.
With a DOS of the RdpxV2Core::settle()
function, no option can be settled. This can be undone as the strict balance check on the substractLoss()
function will always make the function revert.
The attack is extremely cheap, and easy to exploit, just requiring the attacker to send 1 wei of collateral to the PerpetualAtlanticVaultLP
contract at any time.
The root of the finding is this strict equality check.
Once an attacker sends any amount of collateral tokens to the contract, the require
statement will not pass, making the function always revert.
function subtractLoss(uint256 loss) public onlyPerpVault { require( @> collateral.balanceOf(address(this)) == _totalCollateral - loss, // @audit "Not enough collateral was sent out" ); _totalCollateral -= loss; }
PerpetualAtlanticVaultLP.sol#L201
This function is called by the PerpetualAtlanticVault
on its settle()
function:
IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP).subtractLoss( ethAmount );
PerpetualAtlanticVault.sol#L359-L361
And originally, PerpetualAtlanticVault::settle()
, is called within the Core contract settle()
function:
function settle( uint256[] memory optionIds ) external onlyRole(DEFAULT_ADMIN_ROLE) returns (uint256 amountOfWeth, uint256 rdpxAmount) { _whenNotPaused(); (amountOfWeth, rdpxAmount) = IPerpetualAtlanticVault( addresses.perpetualAtlanticVault @> ).settle(optionIds); // @audit for (uint256 i = 0; i < optionIds.length; i++) { optionsOwned[optionIds[i]] = false; } reserveAsset[reservesIndex["WETH"]].tokenBalance += amountOfWeth; reserveAsset[reservesIndex["RDPX"]].tokenBalance -= rdpxAmount; emit LogSettle(optionIds); }
So, all option settlements can be prevented with a DOS of the subtractLoss()
, function which will make all calls to settle()
revert.
This tests shows how the settle()
functions becomes DOS, after 1 wei of collateral is sent to the vault LP.
Add this test to tests/rdpxV2-core/Unit.t.sol
and run forge test --mt "testSettleDos"
:
function testSettleDos() public { address _this = address(this); // track address(this) for later vm.prank address attacker = address(666); weth.transfer(attacker, 1); // give the attacker 1 wei of weth (,,,, address perpetualAtlanticVaultLp,,,,,) = rdpxV2Core.addresses(); vault.addToContractWhitelist(address(rdpxV2Core)); rdpxV2Core.bond(5 * 1e18, 0, address(this)); rdpxPriceOracle.updateRdpxPrice(1e7); // The attacker can send 1 wei of the collateral at any point in time // This will make all calls to `settle()` revert vm.prank(attacker); weth.transfer(perpetualAtlanticVaultLp, 1); // remove this line to check that the test passes without it uint256[] memory _ids = new uint256[](1); _ids[0] = 0; // The `settle()` function will always revert and become in a DOS state vm.prank(_this); vm.expectRevert("Not enough collateral was sent out"); rdpxV2Core.settle(_ids); }
Manual Review
Replace the strict equality with a >=
:
function subtractLoss(uint256 loss) public onlyPerpVault { require( - collateral.balanceOf(address(this)) == _totalCollateral - loss, + collateral.balanceOf(address(this)) >= _totalCollateral - loss, "Not enough collateral was sent out" ); _totalCollateral -= loss; }
Invalid Validation
#0 - c4-pre-sort
2023-09-09T10:02:21Z
bytes032 marked the issue as duplicate of #619
#1 - c4-pre-sort
2023-09-11T16:15:15Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T19:34:40Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: juancito
Also found by: 0x3b, 0xmuxyz, 0xnev, ArmedGoose, Bauchibred, KrisApostolov, RED-LOTUS-REACH, Viktor_Cortess, ciphermarco, ginlee, ladboy233, mitko1111, nemveer
117.8193 USDC - $117.82
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/reLP/ReLPContract.sol#L286-L295
The Uniswap addLiquidity()
function expects the slippage params amountAMin
, and amountBMin
to be passed.
The reLP()
sets those values as 0
, which in other terms, means that the contract is ok with receiveing less amount of tokens than the fair market price when providing liquidity.
Less LP tokens will be received during the reLP()
call, as it will accept any amount of tokens while adding liquidity to Uniswap.
The reLP()
is used by the bond functions, and uses 0
for the amountAMin
, and amountBMin
values passed to the addLiquidity()
function on the Uniswap router:
(, , uint256 lp) = IUniswapV2Router(addresses.ammRouter).addLiquidity( addresses.tokenA, addresses.tokenB, tokenAAmountOut, amountB / 2, 0, // @audit 0, // @audit address(this), block.timestamp + 10 );
This will make the function return less lp tokens than expected, while rebalancing.
Manual Review
Set the amountAMin
and amountBMin
parameters to the expected minimum values
Uniswap
#0 - c4-pre-sort
2023-09-07T12:42:44Z
bytes032 marked the issue as duplicate of #1259
#1 - c4-pre-sort
2023-09-11T07:51:10Z
bytes032 marked the issue as high quality report
#2 - c4-pre-sort
2023-09-11T07:52:29Z
bytes032 marked the issue as not a duplicate
#3 - c4-pre-sort
2023-09-11T07:52:34Z
bytes032 marked the issue as primary issue
#4 - c4-sponsor
2023-09-25T16:47:58Z
psytama (sponsor) confirmed
#5 - c4-judge
2023-10-15T19:21:15Z
GalloDaSballo marked the issue as selected for report
1182.6582 USDC - $1,182.66
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/decaying-bonds/RdpxDecayingBonds.sol#L36-L44 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/decaying-bonds/RdpxDecayingBonds.sol#L122
The RdpxDecayingBonds
contract keeps track of a bonds
mapping with bonds information including the bond token owner. When the token is transfered, the bonds[bondId].owner
value should be updated, but it isn't.
The owner
value will be bricked when a token is transfered, as it can't be changed by any means.
Any integration relying on this value will make wrong trusted assumptions related to the bond token owner, potentially leading to critical issues as loss of funds, because of the importance of the owner
attribute has.
Ranking it as Medium, as there is no direct loss of funds within the current scope, but bricks the contract functionality, as there is no way to fix it.
The owner
attribute of the bonds
mapping is set on each mint()
, but its value is never updated:
// Array of bonds mapping(uint256 => Bond) public bonds; // Structure to store the bond information struct Bond { address owner; // @audit uint256 expiry; uint256 rdpxAmount; }
function mint( address to, uint256 expiry, uint256 rdpxAmount ) external onlyRole(MINTER_ROLE) { _whenNotPaused(); require(hasRole(MINTER_ROLE, msg.sender), "Caller is not a minter"); uint256 bondId = _mintToken(to); @> bonds[bondId] = Bond(to, expiry, rdpxAmount); // @audit emit BondMinted(to, bondId, expiry, rdpxAmount); }
This test shows how the owner
remains the same on the bonds
transfer after performing a transfer.
Add this test to the tests/RdpxDecayingBondsTest.t.sol
file, and run forge test --mt "testSameOwnerOnTransfer"
:
function testSameOwnerOnTransfer() public { // Mint a token rdpxDecayingBonds.mint(address(this), 1223322, 5e18); // The tokenId is 1 // This can be confirmed by the fact that it changes its `ownerOf()` result on the OZ ERC721 after the transfer uint256 tokenId = 1; // Check the bond token owner before the transfer // Check for both via the `bonds` mapping, and the OZ `ownerOf()` function (address ownerBeforeTransfer,,) = rdpxDecayingBonds.bonds(tokenId); assertEq(ownerBeforeTransfer, address(this)); assertEq(rdpxDecayingBonds.ownerOf(tokenId), address(this)); // Transfer token rdpxDecayingBonds.safeTransferFrom(address(this), address(420), tokenId); // The `owner` changes on the OZ `ownerOf` function, while it remains the same on the `bonds` mapping (address ownerAfterTransfer,,) = rdpxDecayingBonds.bonds(tokenId); assertEq(ownerAfterTransfer, address(this)); // @audit remains the same after the transfer assertEq(rdpxDecayingBonds.ownerOf(tokenId), address(420)); }
Manual Review
Either update the owner
value on each token transfer, or remove it, as the owner of the token is already tracked via the OZ ERC721 contract.
Token-Transfer
#0 - c4-pre-sort
2023-09-13T12:58:20Z
bytes032 marked the issue as high quality report
#1 - c4-pre-sort
2023-09-15T09:33:54Z
bytes032 marked the issue as primary issue
#2 - c4-sponsor
2023-09-25T16:47:15Z
psytama (sponsor) confirmed
#3 - GalloDaSballo
2023-10-12T09:57:09Z
The finding lacks a clear loss of funds, although that may be possible since ownerOf
is used for internal checks
#4 - GalloDaSballo
2023-10-12T09:57:53Z
The code breaks the implementation of EIP721, and may also cause losses + issues with integrations
I think Medium Severity to be appropriate
#5 - c4-judge
2023-10-18T12:19:16Z
GalloDaSballo marked the issue as selected for report
#6 - QiuhaoLi
2023-10-24T10:00:57Z
@GalloDaSballo Hi, could you explain why the code breaks the EIP721? As shown in the PoC, rdpxDecayingBonds.ownerOf(tokenId)
is correctly set to address(420)
. ownerAfterTransfer
(the owner
field in struct Bond) is unchanged. Basically, it's an unused struct field.
#7 - GalloDaSballo
2023-10-25T07:41:41Z
@GalloDaSballo Hi, could you explain why the code breaks the EIP721? As shown in the PoC,
rdpxDecayingBonds.ownerOf(tokenId)
is correctly set toaddress(420)
.ownerAfterTransfer
(theowner
field in struct Bond) is unchanged. Basically, it's an unused struct field.
Thank you for flagging, you're right that EIP721 is not broken
I believe the code to be written incorrectly and while I have considered downgrading I think Med is appropriate in this situation
🌟 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
182.2713 USDC - $182.27
RdpxV2Core::calculateBondCost()
does not account for precission loss and allows users to bond an amount of 1
, without providing any WETH.
Users can bond without 1 wei providing any WETH. This may break any assumptions regarding accountability of the Core contract, or may be combined with other issues to achieve a more critical one. This step may also be used in a for
loop to increase the amount without providing WETH.
Add this test to tests/rdpxV2-core/Unit.t.sol
and run forge test --mt "testBondNoEthCost"
:
function testBondNoEthCost() public { uint256 initialWethBalance = weth.balanceOf(address(this)); rdpxV2Core.bond(1, 0, address(this)); uint256 finalWethBalance = weth.balanceOf(address(this)); assertEq(initialWethBalance, finalWethBalance); }
Validate that the returned wethRequired
of the calculateBondCost()
is greater than 0.
decreaseAmount
function name is misleadingRdpxDecayingBonds::decreaseAmount()
does not decrease the bonds amount, but replaces its value. This is misleading and can lead to integration errors if they follow the Natspec: amount: amount to decrease
.
/// @param amount amount to decrease // @audit function decreaseAmount( uint256 bondId, uint256 amount ) public onlyRole(RDPXV2CORE_ROLE) { _whenNotPaused(); bonds[bondId].rdpxAmount = amount; // @audit }
The only call to the function in the current scope is performed on the RdpxV2Core
contract.
In this case the amount
passed to the decreaseAmount()
function is the expected decreased value, so the whole system works as expected.
IRdpxDecayingBonds(addresses.rdpxDecayingBonds).decreaseAmount( _bondId, amount - _rdpxAmount );
Nevertheless, this can lead to future errors.
Change the name of the function to a better name like updateAmount()
, or refactor the function and its calls to match its description.
addAssetTotokenReserves()
doesn't check that there is no token with the same symbolThe function checks that the token address is not used, but doesn't check for the symbol.
Different tokens may have the same symbol, which can be troublesome, as certain operations rely on the symbol.
removeAssetFromtokenReserves()
removes tokens by symbol instead of per addressThe removeAssetFromtokenReserves()
function removes the first token it finds with a specified symbol, which may lead to an error, as multiple tokens can be added with the same symbol.
Remove tokens by their address instead of by their symbol.
The approveContractToSpend()
function requires that the approved amount is > 0. This means that the function is not capable of revoke token approvals by setting them to 0.
This is also troublesome, as it is the expected behavior if a token approval wants to be removed. It can still be mitigated by sending a value of 1.
Remove the unnecessary requirement of amount > 0
.
provideFunding()
may be victim to a DOS because of strict equalityPerpetualAtlanticVault::payFunding()
has an strict equality, which will revert if totalActiveOptions
is different than fundingPaymentsAccountedFor[latestFundingPaymentPointer]
.
function payFunding() external onlyRole(RDPXV2CORE_ROLE) returns (uint256) { _whenNotPaused(); _isEligibleSender(); _validate( @> totalActiveOptions == // @audit fundingPaymentsAccountedFor[latestFundingPaymentPointer], 6 );
PerpetualAtlanticVault.sol#L372-L380
fundingPaymentsAccountedFor[]
is increased in the calculateFunding() function.totalActiveOptions
is decreased in the settle()
function.payFunding()
is called by RdpxV2Core::provideFunding()
, which will make it revert if than strict equality may be broken.
Change the strict equality to a comparison operator.
Functions as addAssetTotokenReserves()
are not correctly formatted as camelCase presumably from the fact that they modify the tokenReserves
variable.
Nevertheless they should use camelCase for consistency with the rest of the codebase
Replace the function names with the camelCase version as addAssetToTokenReserves()
for example.
Role is checked both on the modifier and the function body. Consider removing one:
function mint( address to, uint256 expiry, uint256 rdpxAmount ) external onlyRole(MINTER_ROLE) { // @audit _whenNotPaused(); require(hasRole(MINTER_ROLE, msg.sender), "Caller is not a minter"); // @audit
RdpxDecayingBonds.sol#L114-L120
RDPXV2CORE_ROLE
is not assigned in PerpetualAtlanticVault
While the DEFAULT_ADMIN_ROLE
, and MANAGER_ROLE
are assigned during the constructor()
, the RDPXV2CORE_ROLE
is not.
Any call from the Core contract to the vault will revert until this value is set, so it would be advised to assign this role in the constructor as well.
PerpetualAtlanticVault.sol#L113-L128
TokenAInfo
attributes are all prefixed with tokenA
. This is unnecessary, as they are known to be from a TokenAInfo
struct. Consider removing the prefix:
struct TokenAInfo { // tokenA reserves uint256 tokenAReserve; // rdpx price uint256 tokenAPrice; // tokenA LP reserves uint256 tokenALpReserve; }
Long expression that are repeated can lead to errors if refactored, and are harder to read.
Consider creating a variable to store the expression (currentFundingRate * (nextFundingPaymentTimestamp() - startTime)) / 1e18
, which is repeated 3 times:
collateralToken.safeTransfer( addresses.perpetualAtlanticVaultLP, (currentFundingRate * (nextFundingPaymentTimestamp() - startTime)) / // @audit 1e18 ); IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP) .addProceeds( (currentFundingRate * (nextFundingPaymentTimestamp() - startTime)) / // @audit 1e18 ); emit FundingPaid( msg.sender, ((currentFundingRate * (nextFundingPaymentTimestamp() - startTime)) / // @audit 1e18), latestFundingPaymentPointer );
#0 - GalloDaSballo
2023-10-10T11:43:35Z
Users can bond without providing WETH L decreaseAmount function name is misleading L addAssetTotokenReserves() doesn't check that there is no token with the same symbol L The removeAssetFromtokenReserves() removes tokens by symbol instead of per address L Approvals can't be completely revoked L provideFunding() may be victim to a DOS because of strict equality I, unclear Use camelCase for function names -3 Unnecessary role check L RDPXV2CORE_ROLE is not assigned in PerpetualAtlanticVault L Unnecessary struct attribute prefix R Repeated expression R
#1 - c4-judge
2023-10-20T10:21:36Z
GalloDaSballo marked the issue as grade-a
#2 - c4-judge
2023-10-20T10:22:03Z
GalloDaSballo marked the issue as selected for report
#3 - GalloDaSballo
2023-10-26T15:24:54Z
Maintaining best after looking at dups