Platform: Code4rena
Start Date: 14/04/2022
Pot Size: $75,000 USDC
Total HM: 8
Participants: 72
Period: 7 days
Judge: Jack the Pug
Total Solo HM: 2
Id: 110
League: ETH
Rank: 3/72
Findings: 4
Award: $5,807.18
π Selected for report: 0
π Solo Findings: 0
2782.1219 USDC - $2,782.12
The StakedCitadel
's deposit function (see _depositFor
) mints initial shares
equal to the deposited amount.
The _mintSharesFor
/ _withdraw
functions also use the balance()
function, which includes the contract balance token.balanceOf(address(this))
, to compute the shares.
It's possible to increase the share price to very high amounts, such that a future depositor mints zero shares for their deposit. They lose all funds and they can then be stolen by the attacker when they redeem their fair share of the strategy TVL.
Assume there is no whitelist and anyone can deposit. Assume the attacker is the first minter and balance() == 0
and token == USDC
. Some victim V wants to deposit 1000.0 USDC = 1e9 USDC
.
The attacker frontruns the transaction and does the following:
deposit(amount = 1)
. This amount is transferred and the attacker receives shares = _amount = 1
share. Then balance() == 1
and totalSuplpy() == 1
.token
balance by directly transfering 1e9 USDC
to the controller such that balance() = token.balanceOf(this) = 1e9 + 1
._amount = 1e9
. The shares computation is now shares = (_amount * totalSupply()) / _pool = 1e9 * 1 / (1e9 + 1) = 0
. They contributed 1e9 USDC
to the strategy but receive zero shares.withdraw(1)
to burn their single share and receive the entire balance()
as they redeem 100% of the share total supply: r = (balance() * _shares) / totalSupply() = balance() * 1 / 1 = 2e9 + 1
totalSupply()
is back to zero and they can repeat this attack for the next depositor.The way UniswapV2 prevents this is by requiring a minimum deposit amount and sending 1000
initial shares to the zero address to make this attack more expensive.
The same mitigation can be done here.
Alternatively, scale the initial amount by a large value like 1e18
: if (totalSupply() == 0) shares = _amount * 1e18
.
#0 - GalloDaSballo
2022-04-23T01:56:52Z
Agree with premise and conclusion that we'd have to mint some tokens the first time, that said I have to disagree with severity, if anyone where to do that we could just redeploy as you basically need to be first (or pay up a ton) to perform this griefing attack which ultimately just rebases the shares to their denomination, it technically doesn't even break the accounting of the vault
#1 - jack-the-pug
2022-05-30T08:52:11Z
Dup #217
2289.8123 USDC - $2,289.81
The tokenOutPrice
can be changed at any time, even while the sale is still running.
Users who want to buy a certain amount of tokenOut
using buy
can (even accidentally) be frontrun by a setTokenOutPrice
function call, which reduces the tokenOut
amounts they receive.
It could be that they didn't want to buy the tokens at the new price.
Only allow changes to the token price if the sale has not started or add a min out amount parameter to the buy
function and revert if the amountOut
is less than this parameter.
#0 - GalloDaSballo
2022-04-23T01:59:59Z
I believe the finding to have validity in that you're locking in a certain price when calling buy
and that price can change.
I'd like to emphasize that your purchase is not being changed, you're locking in that specific prize at that specific time.
So I have to agree that the admin can change the prize, and we probably should add a slippage check
#1 - jack-the-pug
2022-06-05T05:54:37Z
Dup #73
643.8625 USDC - $643.86
When withdrawing in StakedCitadel
, the vesting is reset in StakedCitadel.setupVest
. (There was a bad commit, the vest
function is supposed to be called setupVesting
.)
Users that withdrew shares a month ago and did not claim will have their entire vesting reset again. Even if they claimed, the remaining vesting balance will now vest according to the new terms which is a worse vesting schedule than before. Users don't receive their tokens according to the vesting schedule, breaking core functionality of the vesting contract.
Furthermore, the note is incorrect:
note that a given address can only have one active vest at a time.
You can't get correct vesting by having a single vesting schedule for an account and overwriting the single existing vesting schedule all the time.
Each vest should be non-fungible as it has its own (recipient, amount, vestStart, vestEnd)
parameters.
#0 - GalloDaSballo
2022-04-23T01:54:10Z
@dapp-whisperer wdyt?
#1 - GalloDaSballo
2022-05-12T15:51:18Z
Will confirm, would like @dapp-whisperer to also confirm
#2 - jack-the-pug
2022-06-05T06:02:24Z
Dup #158
π Selected for report: IllIllI
Also found by: 0v3rf10w, 0x1f8b, 0xDjango, 0xkatana, AmitN, CertoraInc, Dravee, Funen, Hawkeye, Jujic, MaratCerby, Picodes, Ruhum, SolidityScan, TerrierLover, TomFrenchBlockchain, TrungOre, VAD37, Yiko, berndartmueller, cmichel, csanuragjain, danb, defsec, delfin454000, dipp, ellahi, fatherOfBlocks, georgypetrov, gs8nrv, gzeon, horsefacts, hubble, hyh, ilan, jah, joestakey, kebabsec, kenta, kyliek, m9800, minhquanym, oyc_109, p_crypt0, peritoflores, rayn, reassor, remora, rfa, robee, scaraven, securerodd, shenwilly, sorrynotsorry, tchkvsky, teryanarmen, z3s
91.3943 USDC - $91.39
The withdraw
function burns _shares
amounts and the caller is supposed to receive their fair share of r = (balance() * _shares) / totalSupply()
tokens.
However, this r
value can be reduced if not enough tokens are in the contract and the strategy's withdrawal return fewer tokens.
if (_diff < _toWithdraw) { // @audit burns more shares than the fair share for b + _diff r = b + _diff; }
When this code is executed, the user receives fewer tokens than the _shares
amount they burned.
The received amount is not fair anymore.
If the strategy can currently not withdraw the entire requires _toWithdraw
amount, should the _shares
to burn be readjusted to match r = b + _diff
?
// pseudo-code: burning of _shares needs to happen later if (_diff < _toWithdraw) { r = b + _diff; + // @audit important to round up here so user's can't abuse + _shares = r * totalSupply() / balance() + 1; }
#0 - GalloDaSballo
2022-04-23T01:58:02Z
Disagree that shares need to be re-computed as if the math previously required x to be withdrawn and then we just got y (with y << x) then we incurred in slippage, and you would never want to have the vault pay the slippage as that's how you actually get rekt
#1 - jack-the-pug
2022-06-05T06:00:40Z
Downgrading to QA
as per the sponsor's comment on #210