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: 1/99
Findings: 9
Award: $6,358.48
🌟 Selected for report: 3
🚀 Solo Findings: 2
🌟 Selected for report: MiloTruck
Also found by: CertoraInc, PP1004, SmartSek, VAD37, WatchPug, berndartmueller, cccz, minhquanym, oyc_109, sorrynotsorry, unforgiven
77.7947 USDC - $77.79
function _deposit(uint256 assets, address receiver) internal returns (uint256 shares) { uint256 _pool = underlyingBalance(); uint256 _before = underlyingToken.balanceOf(address(this)); // **Assume caller is depositor** underlyingToken.transferFrom(msg.sender, address(this), assets); uint256 _after = underlyingToken.balanceOf(address(this)); assets = _after.sub(_before); // Additional check for deflationary tokens (totalSupply == 0) ? shares = assets : shares = ( assets.mul(totalSupply) ).div(_pool); // Send shares to designated target _mint(receiver, shares); ...
A malicious early user can deposit()with
1 weiof
assettoken as the first depositor of the
BathToken, and get
1 wei` of shares token.
This can be done by calling BathHouse.sol#openBathTokenSpawnAndSignal()
with initialLiquidityNew: 1 wei
:
Then the attacker can send 10000e18 - 1
of asset
tokens and inflate the price per share from 1.0000 to an extreme value of 1.0000e22 ( from (1 + 10000e18 - 1) / 1
) .
As a result, the future user who deposits 19999e18
will only receive 1 wei
(from 19999e18 * 1 / 10000e18
) of shares token.
They will immediately lose 9999e18
or half of their deposits if they redeem()
right after the deposit.
Consider requiring a minimal amount of share tokens to be minted for the first minter, and send a port of the initial mints as a reserve to the DAO so that the pricePerShare can be more resistant to manipulation.
function _deposit(uint256 assets, address receiver) internal returns (uint256 shares) { uint256 _pool = underlyingBalance(); uint256 _before = underlyingToken.balanceOf(address(this)); // **Assume caller is depositor** underlyingToken.transferFrom(msg.sender, address(this), assets); uint256 _after = underlyingToken.balanceOf(address(this)); assets = _after.sub(_before); // Additional check for deflationary tokens (totalSupply == 0) ? shares = assets : shares = ( assets.mul(totalSupply) ).div(_pool); // for the first mint, we require the mint amount > (10 ** decimals) / 100 // and send (10 ** decimals) / 1_000_000 of the initial supply as a reserve to DAO if (totalSupply == 0 && decimals >= 6) { require(shares > 10 ** (decimals - 2)); uint256 reserveShares = 10 ** (decimals - 6); _mint(DAO, reserveShares); shares -= reserveShares; } // Send shares to designated target _mint(receiver, shares); ...
#0 - bghughes
2022-06-03T20:20:41Z
I believe this issue is incorrect. In your example,
#1 - bghughes
2022-06-03T20:20:57Z
Adding help wanted to seek an extra opinion
#2 - KenzoAgada
2022-06-05T09:38:08Z
@bghughes I think that the problem is that the granularity becomes very coarse, and because of Solidity's precision issues, some tokens are not accounted for.
In the warden's example, before the regular user's deposit: totalSupply = 1 underlyingBalance = 10000e18 then the normal user deposits 19999e18. The shares given to him will be: deposit*totalSupply/underlyingBalance = 19999e18 * 1 / 10000e18. Solidity will round this down to 1 although it is almost 2. Therefore, there is now a total of 2 shares minted, representing a total underlying of 29999e18. If the malicious user now withdraws his 1 share, he gets 14999e18 tokens, although he only deposited 10000e18.
#3 - HickupHH3
2022-06-15T14:58:00Z
Kindly take a look at the contest finding referenced in #138, written by yours truly =p
I think Kenzo also gave a decent explanation! Thanks @KenzoAgada!
In general, it's like this:
#4 - HickupHH3
2022-06-15T15:02:47Z
Because #397 took the effort to provide a test script to explain the problem, I'll use that as the primary issue instead.
🌟 Selected for report: WatchPug
2974.8405 USDC - $2,974.84
function underlyingBalance() public view returns (uint256) { uint256 _pool = IERC20(underlyingToken).balanceOf(address(this)); return _pool.add(outstandingAmount); }
function removeFilledTradeAmount(uint256 amt) external onlyPair { outstandingAmount = outstandingAmount.sub(amt); emit LogRemoveFilledTradeAmount( IERC20(underlyingToken), amt, underlyingBalance(), outstandingAmount, totalSupply ); }
For BathToken
, there will be non-underlyingToken assets sitting on the contract that have filled to the contract and are awaiting rebalancing by strategists.
We assume the rebalance will happen periodically, between one rebalance to the next rebalance, underlyingBalance()
will decrease over time as the orders get filled, so that the price per share will get lower while the actual equity remain relatively stable. This kind of price deviation will later be corrected by rebalancing.
Every time a BathPair.sol#rebalancePair()
get called, there will be a surge of price per share for the BathToken
, as a certain amount of underlyingToken
will be transferred into the contract.
This enables a well known attack vector, which allows the pending yields to be stolen by front run the strategist's BathPair.sol#rebalancePair()
transaction, deposit and take a large share of the vault, and withdraw()
right after the rebalancePair()
transaction for instant profit.
Given:
underlyingBalance()
is 100,000 USDC
;1000 USDC
;strategist
calls rebalancePair()
;100,000 USDC
, take 50% share of the pool;withdraw()
and retireve 100,500 USDC
.As a result, the attacker has stolen half of the pending yields in about 1 block of time.
Consider adding a new variable to track rebalancingAmount on BathToken
.
BathToken
should be notified for any pending rebalancing amount changes via BathPair
in order to avoid sudden surge of pricePerShare over rebalancePair()
.
rebalancingAmount
should be considered as part of underlyingBalance()
.
#0 - bghughes
2022-06-03T22:37:18Z
Bad issue due to #344 #43 #74
#1 - HickupHH3
2022-06-23T01:19:08Z
It's kinda like the flip side to #341, where an incoming deposit benefits by frontrunning.
#221 briefly mentions it: "Similar problem also affect the deposit function since it relies on the proper accounting of the underlying balance or outstanding amount too. The amount of BathToken (e.g. BathWETH) that depositer received might affected."
In this case, a depositor can execute the frontrun attack vector exists even if the strategist is actively rebalancing. Hence, the high severity rating is justified.
🌟 Selected for report: WatchPug
2974.8405 USDC - $2,974.84
BathToken.sol#_deposit()
calculates the actual transferred amount by comparing the before and after balance, however, since there is no reentrancy guard on this function, there is a risk of re-entrancy attack to mint more shares.
Some token standards, such as ERC777, allow a callback to the source of the funds (the from
address) before the balances are updated in transferFrom()
. This callback could be used to re-enter the function and inflate the amount.
function _deposit(uint256 assets, address receiver) internal returns (uint256 shares) { uint256 _pool = underlyingBalance(); uint256 _before = underlyingToken.balanceOf(address(this)); // **Assume caller is depositor** underlyingToken.transferFrom(msg.sender, address(this), assets); uint256 _after = underlyingToken.balanceOf(address(this)); assets = _after.sub(_before); // Additional check for deflationary tokens ...
With a ERC777 token by using the ERC777TokensSender tokensToSend
hook to re-enter the deposit()
function.
Given:
underlyingBalance()
: 100_000e18 XYZ
.totalSupply
: 1e18
The attacker can create a contracts with tokensToSend()
function, then:
deposit(1)
- preBalance = 100_000e18
;
- underlyingToken.transferFrom(msg.sender, address(this), 1)
tokensToSend
hook for the 2nd call: deposit(1_000e18)
100_000e18
;underlyingToken.transferFrom(msg.sender, address(this), 1_000e18)
101_000e18
;101_000e18 - 100_000e18 = 1_000e18
;1000
shares;deposit()
call:
underlyingToken.transferFrom(msg.sender, address(this), 1)
101_000e18 + 1
;(101_000e18 + 1) - 100_000e18 = 1_000e18 + 1
;1000
shares;As a result, with only 1 + 1_000e18
transferred to the contract, the attacker minted 2_000e18 XYZ
worth of shares.
Consider adding nonReentrant
modifier from OZ's ReentrancyGuard
.
#0 - bghughes
2022-06-04T00:26:38Z
Duplicate of #283 #410 Note that no ERC777 tokens will be created and this will be patched, making it a non-issue in practice
#1 - HickupHH3
2022-06-23T01:23:23Z
Not sure what is meant by "no ERC777 tokens will be created", since it's transferring the underlying token which is an arbitrary ERC20, and by extension, ERC777.
The best practice is to break the CEI pattern for deposits and perform the interaction first. Or simply add reentrancy guards.
🌟 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
if (to != address(this)) { ERC20(route[route.length - 1]).transfer(to, currentAmount); }
underlyingToken.transfer(receiver, amountWithdrawn);
There some tokens do not revert on failure, but instead return false (e.g. ZRX).
It is usually good to add a require-statement that checks the return value or to use something like safeTransfer; unless one is sure the given token reverts in case of a failure.
Consider adding a require-statement or using safeTransfer
.
#0 - bghughes
2022-06-04T20:52:36Z
Duplicate of #316
🌟 Selected for report: xiaoming90
Also found by: GimelSec, IllIllI, MaratCerby, PP1004, WatchPug, berndartmueller, blockdev, ilan
42.6857 USDC - $42.69
There are ERC20 tokens that charge fee for every transfer()
or transferFrom()
.
In the current implementation, RubiconMarket.sol#buy()
and RubiconMarket.sol#offer()
assumes that the received amount is the same as the transfer amount, and uses it to fulfill the offers.
offers[id].pay_amt = sub(_offer.pay_amt, quantity); offers[id].buy_amt = sub(_offer.buy_amt, spend); require( _offer.buy_gem.transferFrom(msg.sender, _offer.owner, spend), "_offer.buy_gem.transferFrom(msg.sender, _offer.owner, spend) failed - check that you can pay the fee" ); require( _offer.pay_gem.transfer(msg.sender, quantity), "_offer.pay_gem.transfer(msg.sender, quantity) failed" );
Consider comparing the before and after balanceOf to get the actual transferred amount.
#0 - bghughes
2022-06-04T01:19:52Z
Fee on Transfer again, Duplicate of #112
🌟 Selected for report: xiaoming90
Also found by: WatchPug, shenwilly, unforgiven
function removeFilledTradeAmount(uint256 amt) external onlyPair { outstandingAmount = outstandingAmount.sub(amt); emit LogRemoveFilledTradeAmount( IERC20(underlyingToken), amt, underlyingBalance(), outstandingAmount, totalSupply ); }
In the current implementation, underlyingBalance()
is IERC20(underlyingToken).balanceOf(address(this))
+ outstandingAmount
.
However, when an order is fulfilled or partially fulfilled, the buy_gem
(non-underlyingToken assets) will send to this BathToken contract.
When the strategist calls BathPair.sol#scrubStrategistTrades()
-> BathToken.sol#removeFilledTradeAmount()
, the outstandingAmount
will be reduced, thus suddenly decreases the price per share for this BathToken.
If a user redeems the shares now, they will suffer an unfair loss of value.
Given:
BathTokenA
= DAIBathTokenA.totalSupply
= 10,000 * 1e18BathTokenA
= 10,000 DAIBathTokenA
Pool, got 10,000 * 1e18
sharesBathTokenA
= 20,000 DAIplaceMarketMakingTrades
on Pair, placed a offer: pay_amt
= 1,000 * 1e18, buy_gem
= USDC, buy_amt
= 1,000 * 1e6BathTokenA
= 19,000 DAIoutstandingAmount
= 1,000 * 1e181,000 * 1e6
USDC to BathTokenA
contract:BathTokenA
= 19,000 DAIBathTokenA
= 1,000 USDCoutstandingAmount
= 1,000 * 1e18scrubStrategistTrades()
on PairBathTokenA
= 19,000 DAIBathTokenA
= 1,000 USDCoutstandingAmount
= 0Consider adding a new variable to track rebalancingAmount on BathToken.
BathToken should be notified for any pending rebalancing amount changes via BathPair in order to avoid sudden surge of pricePerShare over rebalancePair().
rebalancingAmount should be considered as part of underlyingBalance().
#0 - bghughes
2022-06-03T22:38:16Z
Duplicate of #337 #113 #221 #210
🌟 Selected for report: WatchPug
Also found by: Chom, Dravee, Hawkeye, MaratCerby, Ruhum, csanuragjain, fatherOfBlocks, minhquanym
42.6857 USDC - $42.69
function isClosed() public pure returns (bool closed) { return false; }
After close, no new buys are allowed.
Based on context and comments, when the market is closed, offers can only be cancelled (offer and buy will throw).
However, in the current implementation, isClosed()
always returns false
, so the checks on whether the market is closed will always pass. (E.g: can_offer()
, can_buy()
, etc)
And there is a storage variable called stopped
, but it's never been used, which seems should be used for isClosed
.
Change to:
function isClosed() public pure returns (bool closed) { return stopped; }
#0 - bghughes
2022-06-04T20:30:09Z
Duplicate of #148
#1 - bghughes
2022-07-25T13:44:35Z
Intended functionality - confirmed
🌟 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
52.0365 USDC - $52.04
uint256 r = (underlyingBalance().mul(_shares)).div(_initialTotalSupply); _burn(msg.sender, _shares); uint256 _fee = r.mul(feeBPS).div(10000); // If FeeTo == address(0) then the fee is effectively accrued by the pool if (feeTo != address(0)) { underlyingToken.transfer(feeTo, _fee); } amountWithdrawn = r.sub(_fee); underlyingToken.transfer(receiver, amountWithdrawn);
function underlyingBalance() public view returns (uint256) { uint256 _pool = IERC20(underlyingToken).balanceOf(address(this)); return _pool.add(outstandingAmount); }
In the current implementation, totalAssets
is IERC20(underlyingToken).balanceOf(address(this))
+ outstandingAmount
.
However, when there are some outstandingAmount
, the actual redeemable amount of underlyingToken is less than underlyingBalance()
.
As a result, when a user tries to redeem a larger amounts of shares, the transaction can revert due to insufficient balance.
Given:
BathTokenA
= USDTBathTokenA
Pool, got 10,000 * 1e18
sharesBathTokenA
= 10,000 USDTplaceMarketMakingTrades
on Pair, place a offer: pay_amt
= 1,000 * 1e18, buy_gem
= USDC, buy_amt
= 1,000 * 1e6BathTokenA
= 9,000 USDToutstandingAmount
= 1,000 * 1e189,500 * 1e18
shares, the transaction will revert.Consider allowing the caller to trigger order cancelling when the balance in the contract is not enough for the withdrawal.
Furthermore, consider find a way to make the rebalancing permissionless so that the stake holders can always get back what they are owned without any centrialized roles.
#0 - bghughes
2022-06-07T22:37:52Z
I would argue this is intended functionality. See EIP 4266 which tries to formalize this some. It is known that only the Reserve Ratio (%) of the pool will be available.
#1 - HickupHH3
2022-06-23T15:55:57Z
Agree with sponsor in this case. The user can at least redeem some shares if he wanted to. Would be a bank run scenario. Downgrading to QA.
#2 - HickupHH3
2022-06-23T15:58:29Z
Warden doesn't have QA report.
🌟 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
30.8421 USDC - $30.84
[S]: Suggested optimation, save a decent amount of gas without compromising readability;
[M]: Minor optimation, the amount of gas saved is minor, change when you see fit;
[N]: Non-preferred, the amount of gas saved is at cost of readability, only apply when gas saving is a top priority.
Every reason string takes at least 32 bytes.
Use short reason strings that fits in 32 bytes or it will become more expensive.
Instances include:
require( _offer.buy_gem.transferFrom(msg.sender, _offer.owner, spend), "_offer.buy_gem.transferFrom(msg.sender, _offer.owner, spend) failed - check that you can pay the fee" );
require( isClosed() || msg.sender == getOwner(id) || id == dustId, "Offer can not be cancelled because user is not owner, and market is open, and offer sells required amount of tokens." );
require( _offer.pay_gem.transfer(msg.sender, quantity), "_offer.pay_gem.transfer(msg.sender, quantity) failed" );
require(isActive(id), "Offer was deleted or taken, or never existed.");
return
for named returnsRedundant code increase contract size and gas usage at deployment.
function isClosed() public pure returns (bool closed) { return false; }
function isClosed() public pure returns (bool) { return false; }
uint256
variables to 0
is redundantuint256 old_top = 0;
Setting uint256
variables to 0
is redundant as they default to 0
.
uint128(pay_amt) == pay_amt
function offer( uint256 pay_amt, ERC20 pay_gem, uint256 buy_amt, ERC20 buy_gem ) public virtual can_offer synchronized returns (uint256 id) { require(uint128(pay_amt) == pay_amt); require(uint128(buy_amt) == buy_amt);
function buy(uint256 id, uint256 quantity) public virtual can_buy(id) synchronized returns (bool) { OfferInfo memory _offer = offers[id]; uint256 spend = mul(quantity, _offer.buy_amt) / _offer.pay_amt; require(uint128(spend) == spend, "spend is not an int"); require(uint128(quantity) == quantity, "quantity is not an int");
function offer( uint128 pay_amt, ERC20 pay_gem, uint128 buy_amt, ERC20 buy_gem ) public virtual can_offer synchronized returns (uint256 id) {
function buy(uint256 id, uint128 quantity) public virtual can_buy(id) synchronized returns (bool) { OfferInfo memory _offer = offers[id]; uint256 spend = mul(quantity, _offer.buy_amt) / _offer.pay_amt; require(uint128(spend) == spend, "spend is not an int");