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: 6/72
Findings: 6
Award: $4,184.80
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: cccz
Also found by: 0xBug, 0xDjango, CertoraInc, TrungOre, VAD37, berndartmueller, georgypetrov, horsefacts, m9800, pedroais, rayn, reassor, scaraven, wuwe1
431.1404 USDC - $431.14
In StakedCitadel.sol
, the withdraw()
function calls the vesting contract to create a vesting schedule. The StakedCitadel.sol
contract calls a function called setupVesting()
as defined by IVesting.sol
, however, the StakedCitadelVester.sol
contract contains a function named vest()
instead.
This bug removes all users' ability to withdraw from StakedCitadel.sol
.
StakedCitadel.sol
makes a call to the Vesting contract via:
IVesting(vesting).setupVesting(msg.sender, _amount, block.timestamp);
IVesting.sol
complies with this function definition via:
interface IVesting { function setupVesting( address recipient, uint256 _amount, uint256 _unlockBegin ) external; }
The vesting contract StakedCitadelVester.sol
DOES NOT comply with this function definition as the function is named vest()
, containing the same parameters.
function vest( address recipient, uint256 _amount, uint256 _unlockBegin ) external {
Manual review.
Reviewing BaseFixture.sol
, it appears the vesting functionality is not tested, probably due to the wait time required by the vesting schedule. The Interface and call to the interface from StakedCitadel.sol
need to be updated to use the vest()
function OR the StakedCitadelVester.sol
vest()
function needs to be renamed to setupVesting()
.
#0 - jack-the-pug
2022-05-29T06:45:22Z
Dup #9
2782.1219 USDC - $2,782.12
An early depositor to StakedCitadel.sol
, preferably the first to deposit, will have the ability to steal funds from subsequent user deposits. The malicious user is able to do this by directly transferring tokens to the StakedCitadel.sol
contract following their deposit. The direct token transfer will inflate the price for a single xCitadel share. If subsequent user deposits are less than the amount of token sent directly to the contracts, they will lose funds because their tokens will successfully transfer to the contract while they will be minted 0 shares
due to precision loss.
Steps to exploit:
1 wei
of token. User A receives 1 wei of share
since supply is currently 0 based on the following clause.if (totalSupply() == 0) { shares = _amount; }
StakedCitadel.sol
which will inflate the balance of the contract. For this example, User A sends 10,000 ether
of token to the contract.5,000 ether
of token. Based on the following formula, they will receive 0 shares
even though their funds transfer to the contract successfully.} else { shares = (_amount * totalSupply()) / _pool; } _mint(recipient, shares);
Where _pool
is equal to balance()
which is equal to token.balanceOf(address(this))
. The formula for the example looks like:
shares = (5,000 ether * 1 wei of share) / 10,000 ether = 0 shares
withdraw()
. Since the contracts hold 15,000 ether
of tokens and User A holds the only share, they receive all 15,000 ether.Manual review.
Instead of relying on the balance of tokens of the two contracts, use internal accounting variables to represent the current balance of tokens that have been properly deposited and withdrawn from the vault. This will remove the ability to directly transfer tokens to the contracts to alter the share distribution logic
#0 - GalloDaSballo
2022-04-23T01:39:16Z
Disagree with severity 1 million times over, would love to see someone solve instead of sending this findings
#1 - shuklaayush
2022-05-11T21:21:39Z
I'd characterize this as low
#2 - jack-the-pug
2022-05-30T08:48:36Z
Dup #217
184.248 USDC - $184.25
The deposit()
function of Funding.sol
calls getAmountOut()
to determine the amount of citadel the user receives in exchange for an asset. The getAmountOut()
function contains a bug that results in the return being 0 if funding.discount
is set to 0.
In the initializer, the FundingParams struct has funding.discount
set to 0 so it is my understanding that deposits while there is no discount should be allowed.
funding = FundingParams(0, 0, 0, address(0), 0, _assetCap);
Where the first parameter in the struct is the discount. The issue in getAmountOut()
can be seen below. If discount == 0
, then the final return statement will always be 0 because citadelAmount_
is not defined at that point.
function getAmountOut(uint256 _assetAmountIn) public view returns (uint256 citadelAmount_) { uint256 citadelAmountWithoutDiscount = _assetAmountIn * citadelPriceInAsset; if (funding.discount > 0) { citadelAmount_ = (citadelAmountWithoutDiscount * MAX_BPS) / (MAX_BPS - funding.discount); } citadelAmount_ = citadelAmount_ / assetDecimalsNormalizationValue; }
I have recreated the function and necessary variables in Remix and can verify that all deposits when discount equals 0 result in 0 citadel returned. It is important to note that the line xCitadel.depositFor(msg.sender, citadelAmount_);
will fail with a citadelAmount_ of 0, so this bug simply causes a lack of protocol functionality and not loss of user funds. For a quick POC of the return value when discount equals 0, please refer to the following code which can quickly be run in Remix:
// SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.12; contract GetAmountsOutTest { uint private citadelPriceInAsset = 2500; uint private MAX_BPS = 10000; uint private assetDecimalsNormalizationValue = 10**8; struct FundingParams { uint256 discount; uint256 minDiscount; uint256 maxDiscount; address discountManager; uint256 assetCumulativeFunded; /// persistent sum of asset amount in over lifetime of contract. uint256 assetCap; /// Max asset token that can be taken in by the contract (defines the cap for citadel sold) } FundingParams private funding; function setFunding(uint256 _discount) public { funding = FundingParams(_discount, 0, 0, address(0), 0, 10000); } function getAmountOut(uint256 _assetAmountIn) public view returns (uint256 citadelAmount_) { uint256 citadelAmountWithoutDiscount = _assetAmountIn * citadelPriceInAsset; if (funding.discount > 0) { citadelAmount_ = (citadelAmountWithoutDiscount * MAX_BPS) / (MAX_BPS - funding.discount); } citadelAmount_ = citadelAmount_ / assetDecimalsNormalizationValue; } }
Remix
Adding the following else block would fix the bug:
if (funding.discount > 0) { citadelAmount_ = (citadelAmountWithoutDiscount * MAX_BPS) / (MAX_BPS - funding.discount); } else { citadelAmount_ = citadelAmountWithoutDiscount; }
Therefore citadelAmount_
will now be non-zero both when there is a discount and when there isn't.
#0 - GalloDaSballo
2022-04-23T01:38:25Z
There was a mistake in the discount math for sure
#1 - jack-the-pug
2022-06-05T04:41:46Z
Dup #149
643.8625 USDC - $643.86
After attempting a withdrawal, StakedCitadelVester.sol
vest()
is called, creating a 21 day vesting schedule for the user to claim their withdrawed amount. This logic works perfectly for the first withdrawal, but will be incorrect for every subsequent withdrawal.
High level, the issue is because of the order of operations in claimableBalance()
. The vested amount that the user is able to claim is calculated via:
((locked * (block.timestamp - vesting[recipient].unlockBegin)) / (vesting[recipient].unlockEnd - vesting[recipient].unlockBegin)) - claimed;
It is important to note that locked
and claimed
are cumulative amounts for the entire history of the user's withdrawals. They do not reset after any calls to claim or vest.
Take a scenario where a user withdraws 100 ether
of token. They wait for their 21 day vesting period to end, then they claim it all. Later, they attempt to withdraw 5 ether
of token. In this example, locked = 105 ether
and claimed = 100 ether
. Therefore, the user won't be able to claim any of their newly-vested withdrawal until they have vested AT LEAST 100 ether
. Doing the math, the user wouldn't have any claimableBalance until the very last day of the vesting period.
A simple example:
21 ether
of token.locked = 21 ether
and claimed = 0 ether
locked = 21 ether
and claimed = 21 ether
Time passes...
21 ether
of token.locked = 42 ether
and claimed = 21 ether
While the proper linear vesting schedule would resemble an increase of 1 ether
every day, their true vesting schedule looks as follows (skipping some days for space). NOTE: a negative value would revert, so equivalates to 0.
Day | Claimable Amount 0 | -21 1 | -19 2 | -17 3 | -15 ............. 9 | -3 10 | -1 11 | 1 12 | 3 ............ 20 | 19 21 | 21
To summarize, it isn't until the 11th day of the vesting schedule that they have a positive claimable amount. By day 21, they are able to claim the entirety of the withdrawed amount. If a user attempts multiple withdraws over time, this vesting schedule will resemble more of a 21 day freeze of funds.
Manual review
The issue with the calculation is that the claimed
amount is always being subtracted at the end. If claimed
is larger than the amount vested by the day, the claimable amount is 0. Moving the subtraction of claimed
to the beginning would fix the issue:
(((locked - claimed) * (block.timestamp - vesting[recipient].unlockBegin)) / (vesting[recipient].unlockEnd - vesting[recipient].unlockBegin));
#0 - GalloDaSballo
2022-04-22T22:44:00Z
@dapp-whisperer wdyt?
#1 - shuklaayush
2022-04-26T21:41:51Z
#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 require statement should include the globalStartTimestamp
. > should be changed to >=
There are several initialize() functions that can be frontrun, requiring redeployment. Consider adding simple access control to these functions.
A single contract contains a floating pragma. It is recommended to deploy all contracts with a single, specific compiler version to reduce the risk of compiler-specific bugs and contracts deployed with different versions. In the case of the forked contacts, I recommend deploying with the exact version that the current live versions were deployed with.
In one contract, Days
is used. In another 86400
seconds is used. For consistency, consider switching to a single method.
The tokenOutAmount_
parameter is not used. Instead it is always set in the function.
Though no risk of reentrancy, it's a good practice to move all state changes to after the token is transferred.
_pool sounds like it's referring to the pool id.
🌟 Selected for report: Dravee
Also found by: 0v3rf10w, 0x1f8b, 0xAsm0d3us, 0xBug, 0xDjango, 0xNazgul, 0xkatana, CertoraInc, Cityscape, Funen, Hawkeye, IllIllI, MaratCerby, SolidityScan, TerrierLover, TomFrenchBlockchain, Tomio, TrungOre, bae11, berndartmueller, csanuragjain, defsec, delfin454000, ellahi, fatherOfBlocks, gs8nrv, gzeon, horsefacts, ilan, jah, joestakey, joshie, kebabsec, kenta, nahnah, oyc_109, rayn, rfa, robee, saian, securerodd, simon135, slywaters, sorrynotsorry, tchkvsky, teryanarmen, z3s
52.043 USDC - $52.04