Rubicon contest - kenzo's results

An order book protocol for Ethereum, built on L2s.

General Information

Platform: Code4rena

Start Date: 23/05/2022

Pot Size: $50,000 USDC

Total HM: 44

Participants: 99

Period: 5 days

Judge: hickuphh3

Total Solo HM: 11

Id: 129

League: ETH

Rubicon

Findings Distribution

Researcher Performance

Rank: 12/99

Findings: 10

Award: $1,329.40

🌟 Selected for report: 4

πŸš€ Solo Findings: 0

Findings Information

🌟 Selected for report: cccz

Also found by: 0x1f8b, IllIllI, kenzo, pedroais

Labels

bug
duplicate
3 (High Risk)

Awards

390.3586 USDC - $390.36

External Links

Lines of code

https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L440

Vulnerability details

Anybody can cancel an offer made by [another user using RoubiconRouter].

Impact

If a user created an offer which wasn't fully filled, anybody (including bots) can swoop in, cancel it, and get the user's funds.

Proof of Concept

Anybody can create an offer using offerForETH. The router does not save the offer's originator. Then by calling 'cancelForETH', which does not check who called it, anybody can sweep the user's funds.

Few options are there -

  • Consider saving in the router the offer's originator - although that ruins the router's statelessness
  • Consider adding a parameter to RubiconMarket's offer function (\overload) that will allow passing who can cancel the offer
  • Consider removing the functionality altogether
  • Consider adding a comment warning the user of the risk of using this functionality
  • Consider the futility of constantly trying to chase happiness

#0 - bghughes

2022-06-04T22:43:46Z

Duplicate of #17

Findings Information

🌟 Selected for report: kenzo

Also found by: IllIllI, PP1004, blackscale, hansfriese

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

390.3586 USDC - $390.36

External Links

Lines of code

https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L229:#L244

Vulnerability details

When swapping amongst multiple pairs in RubiconRouter's _swap, the fee is wrongly accounted for.

Impact

Not all of the user's funds would be forwarded to RubinconMarket, therefore the user would lose funds.

Proof of Concept

The _swap function is calculating the pay amount to send to RubiconMarket.sellAllAmount to be:

currentAmount.sub(currentAmount.mul(expectedMarketFeeBPS).div(10000)

But this would lead to not all of the funds being pulled by RubiconMarket. I mathematically show this in this image. The correct parameter that needs to be sent to sellAllAmount is:

currentAmount.sub(currentAmount.mul(expectedMarketFeeBPS).div(10000+expectedMarketFeeBPS)

I mathematically prove this in this image.

Change the parameter to the abovementioned one.

#0 - HickupHH3

2022-06-16T10:34:27Z

For the benefit of readers who aren't as math savvy, let's work this out with a numerical example.

Let's assume a 1% fee: expectedMarketFeeBPS = 100. The RubinconMarket charges and pulls this fee separately, so if I have a trade amount of 100, what would be the actual amount to pass into the function?

The current implementation is 100 - 1% * 100 = 100 - 1 = 99. However, if that's the case, the market charges 1% of 99 instead, which is 0.99. Hence, the total amount used is 99 + 0.99 = 99.99, leaving a dust amount of 0.01.

Thus, as the warden has proven mathematically, the formula should be 100 - 100 * 100 / (10_000 + 100) ~= 99.0099. Then, the 1% fee charged is 0.990099..., making the total approximately equal to 100 (rounding errors).

Findings Information

Awards

8.2687 USDC - $8.27

Labels

bug
duplicate
2 (Med Risk)

External Links

Lines of code

https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L356 https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L374 https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L434 https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L451 https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L491 https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L548

Vulnerability details

Throughout RubiconRouter, ETH is being sent to the sender using .transfer. This only forwards 2300 gas back to the caller. This is generally not recommended.

Impact

This makes it problematic (workarounds are needed) to transfer funds to wallets such as Gnosis, and other flows which require doing actions (like invest the funds) upon receival will be broken.

Proof of Concept

See links above and Consensys article linked above.

Use .call.value(...)("") to send ETH.

#0 - bghughes

2022-06-04T21:40:36Z

Duplicate of #82

Lines of code

https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/rubiconPools/BathHouse.sol#L172

Vulnerability details

Some tokens, like USDT, do not return a value upon successful transfer. Since Rubicon is checking that the transfer succeeded by checking the return value, these tokens can not be used. This is why SafeERC20 is generally used for transfers.

Impact

Some tokens, like the popular USDT, can not be used.

Proof of Concept

We can see in USDT's code that it's transfer function does not return anything. When Rubicon creates a pair, it requires that the transfer will return true:

require(desiredPairedAsset.transferFrom(msg.sender, address(this), initialLiquidityExistingBathToken), "Couldn't transferFrom your initial liquidity - make sure to approve BathHouse.sol");

Therefore, the require will fail for certain tokens. There are more instances of simple transfer throughout the protocol - all of those need to be changed to use safeTransfer.

Add SafeERC20 to the protocol and change all transfers to safeTransfer.

#0 - bghughes

2022-06-03T20:43:19Z

Duplicate of #316

Findings Information

🌟 Selected for report: kenzo

Also found by: 0x1f8b, 0xsomeone, Dravee, IllIllI, MaratCerby, berndartmueller, cryptphi, xiaoming90

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

42.6857 USDC - $42.69

External Links

Lines of code

https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/rubiconPools/BathHouse.sol#L180 https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L157 https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/rubiconPools/BathToken.sol#L256

Vulnerability details

When using the approval mechanism in USDT, the approval must be set to 0 before it is updated. In Rubicon, when creating a pair, the paired asset's approval is not set to 0 before it is updated.

Impact

Can't create pairs with USDT, the most popular stablecoin, as as the approval will revert.

Proof of Concept

USDT reverts on approval if previous allowance is not 0:

require(!((_value != 0) && (allowed[msg.sender][_spender] != 0)));

When creating a pair, Rubicon approves the paired asset without first setting it to 0:

desiredPairedAsset.approve(pairedPool, initialLiquidityExistingBathToken);

Therefore, if desiredPairedAsset is USDT, the function will revert, and pairs with USDT can not be created.

This problem will also manifest in RubiconMarket's approval function and BathToken's approval function,

Set the allowance to 0 before setting it to the new value.

#0 - bghughes

2022-06-03T20:52:11Z

This doesn't sound right to me because it implies that USDT is non-compliant with the infinite approval pattern. I believe USDT can be infinitely approved, asking for an extra pair of eyes to verify

#1 - bghughes

2022-06-03T20:57:11Z

Just tested this on Rubicon trading and there is no need to re-approve USDT before using it so I believe this is a bad issue.

#2 - KenzoAgada

2022-06-05T11:17:57Z

@bghughes This is not related to infinite approval (which USDT allows); you're approving a specific amount. USDT on mainnet does not allow changing from a non-zero allowance to another non-zero allowance.

Did you test it on Optimism? USDT on optimism seems to use an updated version of USDT, unlike the one in mainnet. In Optimism you can change the approval from non-zero to another non-zero. But if you look at USDT on Ethereum mainnet, the code does not allow this (look at the second clause):

require(!((_value != 0) && (allowed[msg.sender][_spender] != 0)));

#3 - HickupHH3

2022-06-15T13:52:55Z

The issue is valid on approveAssetOnMarket() and approveMarket(), but not on the other lines. Even that, it's likely for these functions to be called just once. Nevertheless, I will let the issue stand.

The reason is because the allowance will be entirely consumed when the deposit function is called, so it will be set to zero always.

desiredPairedAsset.approve(
    pairedPool,
    initialLiquidityExistingBathToken
);

// Deposit assets and send Bath Token shares to msg.sender
// This will use up the entire allowance given
// see https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/rubiconPools/BathToken.sol#L565
IBathToken(pairedPool).deposit(
    initialLiquidityExistingBathToken,
    msg.sender
);

Hence, any issue that does not reference the approveAssetOnMarket() and approveMarket() functions will be marked as invalid.

#4 - bghughes

2022-07-01T21:08:41Z

@bghughes This is not related to infinite approval (which USDT allows); you're approving a specific amount. USDT on mainnet does not allow changing from a non-zero allowance to another non-zero allowance.

Did you test it on Optimism? USDT on optimism seems to use an updated version of USDT, unlike the one in mainnet. In Optimism you can change the approval from non-zero to another non-zero. But if you look at USDT on Ethereum mainnet, the code does not allow this (look at the second clause):

require(!((_value != 0) && (allowed[msg.sender][_spender] != 0)));

Thank you for the explanation and thank you @HickupHH3

Findings Information

🌟 Selected for report: kenzo

Also found by: CertoraInc, PP1004, pedroais

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

162.6494 USDC - $162.65

External Links

Lines of code

https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/rubiconPools/BathToken.sol#L499

Vulnerability details

The fee is wrongly accounted for in previewWithdraw.

Impact

Function returns wrong result; Additionally, withdraw(assets,to,from) will always revert. (The user can still withdraw his assets via other functions).

Proof of Concept

The previewWithdraw function returns less shares than the required assets (notice the substraction):

uint256 amountWithdrawn; uint256 _fee = assets.mul(feeBPS).div(10000); amountWithdrawn = assets.sub(_fee); shares = convertToShares(amountWithdrawn);

This won't work, because if the user wants to receive amount of assets, he needs to burn more shares than that to account for the fee. Not less. This will also make withdraw(assets,to,from) revert, because it takes the amount of shares from previewWithdraw, and then checks how much assets were really sent to the user, and verifies that it's at least how much he asked for:

uint256 expectedShares = previewWithdraw(assets); uint256 assetsReceived = _withdraw(expectedShares, receiver); require(assetsReceived >= assets, "You cannot withdraw the amount of assets you expected");

But since the expectedShares is smaller than the original amount, and since _withdraw deducts the fee from expectedShares, then always assets > assetsReceived, and the function will revert.

The amount of shares that previewWithdraw should return is: convertToShares(assets.add(assets.mul(feeBPS).div((10000.sub(feeBPS)))) I prove this mathematically in this image.

#0 - HickupHH3

2022-06-16T14:24:28Z

Keeping it as medium severity because while protocol functionality is impacted, users can withdraw through the redeem() function.

#1 - bghughes

2022-06-30T20:23:42Z

The new solution, thank you, Kenzo

/// @notice * EIP 4626 * function previewWithdraw(uint256 assets) public view returns (uint256 shares) { if (totalSupply == 0) { shares = 0; } else { shares = convertToShares( assets.add(assets.mul(feeBPS).div((uint(10000).sub(feeBPS)))) ); } }

#2 - bghughes

2022-06-30T20:30:44Z

Keeping it as medium severity because while protocol functionality is impacted, users can withdraw through the redeem() function.

Note that we use the withdraw that relies on msg.sender as the caller in production so were not affected in practice. It's interesting that various wardens have different answers to the solution for this issue. This one seems best and I'm going with it for now!

Awards

1.8534 USDC - $1.85

Labels

bug
duplicate
2 (Med Risk)
sponsor confirmed

External Links

Lines of code

https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/rubiconPools/BathToken.sol#L261

Vulnerability details

The fee BPS parameter is not capped.

Impact

Admin can set fee to be 10000 and thereby transfer all deposits to himself. While admin is a trusted role, some sanity guarantees should probably be in place.

Proof of Concept

Well, the fee BPS is not capped.

function setFeeBPS(uint256 _feeBPS) external onlyBathHouse { feeBPS = _feeBPS; }

Cap it to a reasonable amount such as 1000.

#0 - liveactionllama

2022-05-29T18:44:39Z

Warden reached out via C4 help request on May 27th at 15:55 UTC to ask that the following information be added to their submission as a comment:

The fee needs to be capped in RubiconMarket as well.

#1 - HickupHH3

2022-06-18T04:32:23Z

Duplicate of #21

Findings Information

🌟 Selected for report: hansfriese

Also found by: MiloTruck, kenzo, oyc_109, pauliax, unforgiven, xiaoming90

Labels

bug
duplicate
2 (Med Risk)
sponsor acknowledged

Awards

53.3571 USDC - $53.36

External Links

Lines of code

https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/rubiconPools/BathPair.sol#L591:#L625

Vulnerability details

When calling strategistBootyClaim, the strategist can pass same the token address twice. The function doesn't check for this, and uses normal subtraction instead of SafeMath.

Impact

Strategist can claim more rewards from BathPair than he deserves. Although the strategist is a trusted role, it is planned to become permisionless, and anyway such logic bugs should not exist in the system.

Proof of Concept

The function starts by locally saving the amount of rewards for each token:

function strategistBootyClaim(address asset, address quote) external onlyApprovedStrategist(msg.sender) { uint256 fillCountA = strategist2Fills[msg.sender][asset]; uint256 fillCountQ = strategist2Fills[msg.sender][quote];

Note that it does not check whether asset != quote. It then proceeds to send the rewards for the asset token:

if (fillCountA > 0) { uint256 booty = (fillCountA.mul(IERC20(asset).balanceOf(address(this)))).div(totalFillsPerAsset[asset]); IERC20(asset).transfer(msg.sender, booty); totalFillsPerAsset[asset] -= fillCountA; strategist2Fills[msg.sender][asset] -= fillCountA; }

And then does the same for the quote token:

if (fillCountQ > 0) { uint256 booty = (fillCountQ.mul(IERC20(quote).balanceOf(address(this)))).div(totalFillsPerAsset[quote]); IERC20(quote).transfer(msg.sender, booty); totalFillsPerAsset[quote] -= fillCountQ; strategist2Fills[msg.sender][quote] -= fillCountQ; }

So what would happen at this stage if asset == quote?

  • fillCountQ is still the original reward amount, as it was saved locally. So it is bigger than 0.
  • If there are rewards of other strategists in the contract, the contract balance and totalFillsPerAsset are bigger than 0, so booty would be sent.
  • Both the substractions at the end of the block are not using SafeMath - therefore, they would underflow and not revert like they should.

I believe changing the last lines to use SafeMath, meaning use .sub instead of -, would be enough to mitigate the bug. You can also add sanity checks like asset != quote but it might not be necessary.

#0 - bghughes

2022-06-03T21:46:00Z

Acknowledged centralization risk, right now all strategists are trusted #344

#1 - KenzoAgada

2022-06-04T15:04:14Z

Duplicate of #238.

#2 - HickupHH3

2022-06-17T02:59:54Z

Duplicate of #157 and #344

Findings Information

🌟 Selected for report: kenzo

Also found by: 0x1f8b, Ruhum, dirk_y, shenwilly

Labels

bug
2 (Med Risk)
disagree with severity
sponsor confirmed

Awards

117.1076 USDC - $117.11

External Links

Lines of code

https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/rubiconPools/BathHouse.sol#L264

Vulnerability details

There is no option to revoke strategist's privilege. As the strategist is a very strategic role which can effectively steal LP's funds, this is very dangerous.

Impact

A rogue / compromised / cancelled strategist can not be revoked of permissions.

Proof of Concept

There's a function to approve a strategist, but no option to revoke the access.

Add a function / change the function and allow setting strategist's access to false.

#0 - bghughes

2022-06-03T21:01:46Z

Low severity, we can add this anytime with an proxy upgrade but still a good function to add

#1 - HickupHH3

2022-06-22T16:20:04Z

As per the rulebook (no. 11), upgradeability should not be used as an excuse to reduce the severity of a finding.

Findings Information

🌟 Selected for report: kebabsec

Also found by: 0xsomeone, cryptphi, kenzo

Labels

bug
duplicate
2 (Med Risk)

Awards

162.6494 USDC - $162.65

External Links

Lines of code

https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/peripheral_contracts/BathBuddy.sol#L114:#L116

Vulnerability details

BathBuddy's release function is not protected from reentrancy.

Impact

User can withdraw more rewards than he deserves, in tokens with a receiver hook like ERC777.

Proof of Concept

  • User will withdraw half his assets, thereby initiating distributeBonusTokenRewards which will call rewardsVestingWallet.release.
  • (Here's the problem) The release function sends the tokens to the user before updating the contract state:
token.transfer(recipient, amountWithdrawn); _erc20Released[address(token)] += amount;
  • Upon receival of tokens, the user will call withdraw again with the rest of his assets.
  • When execution will arrive to BathBuddy's release again, it will deduct the amount already released from the releaseable (released == _erc20Released):
uint256 releasable = vestedAmount(address(token), uint64(block.timestamp)) - released(address(token));

Therefore, since _erc20Released was not updated yet, the releasable will be larger than it really is, and the user will receive more awards than deserved.

Conform with Checks-Effects-Interactions pattern and send the tokens only after updating the state.

#0 - bghughes

2022-06-03T19:42:35Z

Duplicate of #283

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter