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: 9/72
Findings: 4
Award: $3,471.60
🌟 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
https://github.com/code-423n4/2022-04-badger-citadel/tree/main/src/StakedCitadel.sol#L830
Contract StakedCitadelVester
inherits from interface IVesting
(in fact it does not as it is missing the necessary is IVesting
statement, but it's assumed to inherit from IVesting
) but wrongly implements the interface. The contract is expected to implement the function setupVesting()
but it's wrongly called vest()
.
DoS withdrawing shares for tokens and therefore funds are stuck in contract and can not be withdrawn.
Following test will revert on xCitadel.withdraw(1e18);
due to missing StakedCitadelVester.setupVesting()
function:
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.12; import {BaseFixture} from "./BaseFixture.sol"; import {GlobalAccessControl} from "../GlobalAccessControl.sol"; contract StakedCitadelVesterTest is BaseFixture { function setUp() public override { BaseFixture.setUp(); } function testWithdraw() public { vm.startPrank(governance); uint256 initialSupply = 10 * 1e18; citadel.mint(governance, initialSupply); citadel.approve(address(xCitadel), 1e18); xCitadel.deposit(1e18); assertEq(citadel.balanceOf(address(xCitadelVester)), 0); xCitadel.withdraw(1e18); (uint256 unlockBegin,,,) = xCitadelVester.vesting(address(governance)); assertGt(unlockBegin, 0); assertGt(citadel.balanceOf(address(xCitadelVester)), 0); vm.stopPrank(); } }
Manual review
Either rename StakedCitadelVester.vest()
to StakedCitadelVester.setupVesting()
or vice-versa.
#0 - GalloDaSballo
2022-04-23T01:22:01Z
@dapp-whisperer wdyt?
#1 - jack-the-pug
2022-05-29T07:25:23Z
Dup #9
2782.1219 USDC - $2,782.12
https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/StakedCitadel.sol#L888
The first depositor into StakedCitadel
is able to maliciously manipulate the share price by depositing the lowest possible amount (1 wei
) and then artificially blowing up the StakedCitadel
Citadel
token balance. Following depositors will loose their deposited funds due to precisions issues.
This is a well known issue, Uniswap and other protocols had similar issues when supply == 0
.
1 wei
via StakedCitadel.deposit()1 wei
StakedCitadel
shares (due to shares = _amount
on StakedCitadel.sol#L888)1 ether
(1e18) tokens via citadel.transfer()
to StakedCitadel
to artificially blow up balance without minting new shares. StakedCitadel
citadel token balance is now 1 ether + 1 wei
-> share price is now very high (= 1000000000000000000001
wei ~ 1000 * 1e18
)100 ether
via StakedCitadel.deposit()0
StakedCitadel
sharesShrimp receives 0
shares due to precision issue. Deposited funds are lost.
The formula shares = (_amount * totalSupply()) / _pool;
see here calculates in case of such a high share price the following:
shares = (500000000000000000000 * 1) / 1000000000000000000001
=> shares = 0
This is due to _pool
>
(_amount * totalSupply())
.
Following test verifies this issue:
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.12; import {BaseFixture} from "./BaseFixture.sol"; contract StakedCitadelTest is BaseFixture { function setUp() public override { BaseFixture.setUp(); } function testDeposit() public { vm.startPrank(governance); assertEq(xCitadel.balance(), 0); citadel.mint(shark, 1001 * 1e18); citadel.mint(shrimp, 1001 * 1e18); vm.stopPrank(); vm.prank(address(shark)); citadel.approve(address(xCitadel), type(uint256).max); vm.prank(address(shrimp)); citadel.approve(address(xCitadel), type(uint256).max); vm.prank(address(shark)); xCitadel.deposit(1); assertEq(xCitadel.balance(), 1); assertEq(xCitadel.balanceOf(address(shark)), 1); vm.prank(address(shark)); citadel.transfer(address(xCitadel), 1000 * 1e18); // attacker transfers citadel token to vault assertEq(xCitadel.balance(), 1000 * 1e18 + 1); vm.prank(address(shrimp)); xCitadel.deposit(500 * 1e18); // user deposits citadel token to vault assertGt(xCitadel.balanceOf(address(shrimp)), 0); // shrimp should receive shares but does _not_. Citadel tokens deposited are now lost. } }
Fails as shrimp
did receive 0
shares.
Manual review
For the first deposit, mint fixed amount of shares
, e.g. ONE_ETH
function _mintSharesFor( address recipient, uint256 _amount, uint256 _pool ) internal { uint256 shares; if (totalSupply() == 0) { shares = ONE_ETH; // @audit-info mint fixed amount of shares if supply is 0 } else { shares = (_amount * totalSupply()) / _pool; } _mint(recipient, shares); }
Following test verifies this fix:
function testCorrectDeposit() public { vm.startPrank(governance); assertEq(xCitadel.balance(), 0); citadel.mint(shark, 1001 * 1e18); citadel.mint(shrimp, 1001 * 1e18); vm.stopPrank(); vm.prank(address(shark)); citadel.approve(address(xCitadel), type(uint256).max); vm.prank(address(shrimp)); citadel.approve(address(xCitadel), type(uint256).max); vm.prank(address(shark)); xCitadel.deposit(1); assertEq(xCitadel.balance(), 1); assertEq(xCitadel.balanceOf(address(shark)), 1e18); // @audit-info shark received minimum amount of `1e18` shares vm.prank(address(shark)); citadel.transfer(address(xCitadel), 1000 * 1e18); // attacker transfers citadel token to vault assertEq(xCitadel.balance(), 1000 * 1e18 + 1); vm.prank(address(shrimp)); xCitadel.deposit(500 * 1e18); // user deposits citadel token to vault assertGt(xCitadel.balanceOf(address(shrimp)), 0); // @audit-info shrimp now receives shares > 0 }
Fix inspired by Uniswap V2 and Harvest Finance
Extra caution has to be put on initial deployment and on the first deposit into StakedCitadel
. It's even recommended for the protocol owner to provide the initial deposit.
#0 - GalloDaSballo
2022-04-23T01:21:25Z
Dup of some other finding, personally I must disagree with the severity as well as the "resolution"
The linked "Harvest Finance" solution literally performs the same math we do: https://github.com/harvest-finance/harvest/blob/14420a4444c6aaa7bf0d2303a5888feb812a0521/contracts/Vault.sol#L148
If totalSupply is zero, return 1e18 (or decimal or w/e) If is non zero do the math based on pool.
I don't believe anyone has come up with a valid solution beside that of minting at least 1 ** decimals tokens on first deposit to mitigate this.
This is caused by integer division causing a loss of precision.
For those reasons I believe Low Severity should be more appropriate.
Also notice that the share can get more expensive and the cost of 1 share can be made to grow to BigDeposit - 1 but you can still make the entire system work, it's just that you're forced to perform a bigger deposit
#1 - jack-the-pug
2022-05-30T08:50:18Z
Dup #217
🌟 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
206.1723 USDC - $206.17
It's recommended to have a very high test code coverage to spot critical issues. For this protocol many tests are missing for critical functions in the protocol (e.g. depositing Citadel
into StakedCitadel
to receive xCitadel
).
transferFromDisabled
storage variable is not implemented as comment impliesFunding.sol#citadelPriceInAssetOracle
deposit()
function of Funding.sol
@TODOs
left in the codedays
to specify units of timeSuffixes like seconds
, minutes
, hours
, days
and weeks after literal numbers can be used to specify units of time where seconds are the base unit.
It's easier to read and helps to prevent issues.
Use unit of time suffix:
Instead of
uint256 public constant INITIAL_VESTING_DURATION = 86400 * 21;
use
uint256 public constant INITIAL_VESTING_DURATION = 1 days * 21;
It's best practice for a contract to inherit from it's interface. This improves the contract's clarity and makes sure the contract implementation complies with the defined interface.
StakedCitadel.sol#L64
StakedCitadelVester.sol#L17
SupplySchedule.sol#L21
CitadelToken.sol#L8
GlobalAccessControl.sol#L19
Inherit from the missing interface or contract.
token.decimals()
of underlying token instead of constant ONE_ETH
Instead of defining the constant ONE_ETH
with 18 decimals, use the actual token decimals of the underlying token.
Use underlying token decimals. e.g.
Instead of
if (totalSupply() == 0) { return ONE_ETH; }
use
if (totalSupply() == 0) { return 10**token.decimals(); // @audit-info use `token.decimals()` to make sure to really use same decimals as underlying token }
StakedCitadel.balance()
consistentlyIn StakedCitadel.sol
the public view function StakedCitadel.balance()
returns the total balance of the underlying token. As within the contract this balance is used multiple times across multiple functions, it's recommended to use this function to access the balance instead of using token.balanceOf(address(this))
.
StakedCitadel.sol#L300
StakedCitadel.sol#L773
StakedCitadel.sol#L775
StakedCitadel.sol#L815
StakedCitadel.sol#L819
Use function StakedCitadel.balance()
instead of token.balanceOf(address(this))
.
Contracts should implement the interface and adhere to it's specifications to prevent any issues using the contract.
Missing implementation for interface IGac
defined functions enableTransferFrom
and disableTransferFrom
Either adapt interface to have same methods as contract implementation or adapt contract implementation to interface.
transferFromDisabled
storage variable is not implemented as comment impliesThe comment implies that the storage variable transferFromDisabled
is used and initialized in the constructor, but it's not.
// Should the function transferFrom be disabled // NOTE: This is enforced at the contract level, the contract just allows the toggling of the bool bool public transferFromDisabled; // Set to true in initialize
Consider removing the unused variable or actually use it.
Zero-address checks are a best-practice for input validation of critical address parameters. While the codebase applies this to most most cases, there are many places where this is missing in constructors and setters.
Impact: Accidental use of zero-addresses may result in exceptions, burn fees/tokens or force redeployment of contracts.
lib/GlobalAccessControlManaged.sol#L32
lib/SettAccessControl.sol#L53
Funding.sol#L125
Funding.sol#L126
Funding.sol#L127
GlobalAccessControl.sol#L61
Add zero-address checks.
Funding.sol#citadelPriceInAssetOracle
Currently there is no way to update the oracle citadelPriceInAssetOracle
. To have the option to later update the storage variable, add a setter function to do so.
Add setter function or change storage variable to immutable
if it's not supposed to be updated later (for gas saving).
deposit()
function of Funding.sol
Use of accurate comments helps read, audit and maintain code. Otherwise, it can be misleading and miss the flagging of or cause the introduction of vulnerabilities.
* @param _minCitadelOut ID of DAO to vote for // @audit-info wrong param description
Update parameter documentation for _minCitadelOut
@TODOs
left in the codeThere are several open TODOs left in the code.
SupplySchedule.sol#L159
Funding.sol#L15
Funding.sol#L61
Funding.sol#L183
KnightingRound.sol#L14
GlobalAccessControl.sol#L106
Check, fix and remove the todos before it is deployed in production
It's a best practice to use a two-step process for changes of critical settings like setGovernance()
.
lib/SettAccessControl.sol.sol#L51
Consider implementing a two-step process where the owner nominates an account and the nominated account needs to call an acceptOwnership() function for the transfer of ownership to fully succeed. This ensures the nominated EOA account is a valid and active account.
🌟 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.1707 USDC - $52.17
unchecked {}
primitive within for loops> 0
is less efficient than != 0
for unsigned integersunchecked {}
primitive within for loopsGiven the use of Solidity compiler >= 0.8.0, there are default arithmetic checks for mathematical operations which consume additional gas for such checks internally. In expressions where we are absolutely sure of no overflows/underflows, one can use the unchecked
primitive to wrap such expressions to avoid checks and save gas.
Adapt for
loops to increase index variable within unchecked
block. e.g
for (uint256 i = 0; i < length;) { // ... unchecked { ++i; } }
SupplySchedule.sol#L208
CitadelMinter.sol#L152
CitadelMinter.sol#L344
Following functions should be declared external
, as functions that are never called by the contract internally should be declared external to save gas.
StakedCitadel.sol#L284
SupplySchedule.sol#L79
lib/SettAccessControl.sol#L51
Funding.sol#L223
Use the external
attribute for functions never called from the contract.
> 0
is less efficient than != 0
for unsigned integers!= 0 is a cheaper (less gas) operation for unsigned integers within require
statements compared to > 0
.
StakedCitadel.sol#L835
StakedCitadel.sol#L908
StakedCitadelVester.sol#L138
SupplySchedule.sol#L61
SupplySchedule.sol#L91
SupplySchedule.sol#L180
lib/SafeERC20.sol#L96
Funding.sol#L170
Funding.sol#L209
Funding.sol#L322
Funding.sol#L340
Funding.sol#L424
Funding.sol#L452
CitadelMinter.sol#L270
CitadelMinter.sol#L343
KnightingRound.sol#L125
KnightingRound.sol#L129
KnightingRound.sol#L172
KnightingRound.sol#L184
KnightingRound.sol#L215
KnightingRound.sol#L313
KnightingRound.sol#L332
KnightingRound.sol#L411
interfaces/convex/BoringMath.sol#L20
interfaces/convex/BoringMath.sol#L102
interfaces/convex/BoringMath.sol#L122
interfaces/convex/BoringMath.sol#L142\
Change > 0
to != 0
.
Checking if governanceRewardsFee > 0
, strategistRewardsFee > 0
before making the safeTransfer
call can save gas by avoiding this token transfer in such situations.
StakedCitadel.sol#L461
StakedCitadel.sol#L462\
Add check for governanceRewardsFee > 0
. E.g
if (governanceRewardsFee > 0) { IERC20Upgradeable(_token).safeTransfer(treasury, governanceRewardsFee); }
Add check for strategistRewardsFee > 0
. E.g
if (strategistRewardsFee > 0) { IERC20Upgradeable(_token).safeTransfer( strategist, strategistRewardsFee ); }