Platform: Code4rena
Start Date: 09/11/2021
Pot Size: $30,000 ETH
Total HM: 6
Participants: 17
Period: 3 days
Judge: pauliax
Total Solo HM: 3
Id: 50
League: ETH
Rank: 1/17
Findings: 5
Award: $9,818.99
🌟 Selected for report: 3
🚀 Solo Findings: 1
cmichel
The FSDVesting.updateVestedTokens
function is supposed to be called by the FDS contract only (which also mints tokens to the contract).
However, it does not have any access restrictions which leads to circumventing the vesting and further griefing attacks.
The vesting can be circumvented by the user calling updateVestedTokens(user, amount)
with a high amount which increases the amount
storage variable, for example by 20
.
The user can then claim all tokens already after the cliff period because the vested amount in calculateVestingClaim
uses this modified amount
value:
// amount has been inflated such that amount = oldAmount * 20 vestedAmount = amount.mul(5) / 100;
A malicious user can break other investors vestings by calling updateVestedTokens(victim, newAmount = type(uint256).max - amount)
such that the new amount
storage field is set close to the maximum possible integer value.
When the victim tries to claimVestedTokens
, it will either overflow and revert in calculateVestingClaim
or the vested amount will be much larger than the balance of the vesting contract and revert there.
Vesting periods can be shortened to the cliff and vestings for other investors can be modified such that they are unable to claim anything. Both actions are economically useful as they either acquire more tokens for the attacker faster or prevent a high circulating supply (increasing the token price) by denying other investors to claim.
Add access restrictions to this function. It should only be callable from FSD.sol
.
#0 - YunChe404
2021-11-14T11:28:12Z
#32
#1 - pauliax
2021-11-17T12:07:58Z
A duplicate of #101
cmichel
The FSDVesting.claimVestedTokens
function tokenizes the conviction only if the current claimed amount (tokenClaim
) equals the total vested amount (amount
).
// tokenClaim is vestedAmount - totalClaimed uint256 tokenClaim = calculateVestingClaim(); if (amount == tokenClaim) { uint256 tokenId = fsd.tokenizeConviction(0); // @audit-info locked = 0 fairSideConviction.transferFrom(address(this), msg.sender, tokenId); }
This is only the case if no partial claims have been performed and the user claims once at the end of the vesting period.
I assume the idea to tokenize the conviction was that the vesting contract accrues conviction itself as the tokens vest inside of it.
It should always be possible to receive the tokenized conviction at the end of the vesting period, even if a user performed a partial claim before. Otherwise, users lose vested conviction.
Change the if (amount == tokenClaim)
to if (amount <= totalClaimed)
such that the accrued conviction is tokenized for all users at the end of their vesting schedule.
#0 - YunChe404
2021-11-14T11:26:23Z
#62
#1 - pauliax
2021-11-19T12:11:48Z
A duplicate of #62
🌟 Selected for report: cmichel
1.3101 ETH - $6,199.45
cmichel
In ERC20ConvictionScore._writeCheckpoint
, when the checkpoint is overwritten (checkpoint.fromBlock == blockNumber
), the new value is set to the memory checkpoint
structure and never written to storage.
// @audit this is MEMORY, setting new convictionScore doesn't write to storage Checkpoint memory checkpoint = checkpoints[user][nCheckpoints - 1]; if (nCheckpoints > 0 && checkpoint.fromBlock == blockNumber) { checkpoint.convictionScore = newCS; }
Users that have their conviction score updated several times in the same block will only have their first score persisted.
updateConvictionScore(user)
acquireConviction(id)
. This calls _increaseConvictionScore(user, amount)
which calls _writeCheckpoint(..., prevConvictionScore + amount)
. The updated checkpoint is not written to storage, and the user lost their conviction NFT. (The conviction/governance totals might still be updated though, leading to a discrepancy.)Users that have their conviction score updated several times in the same block will only have their first score persisted.
This also applies to the total conviction scores TOTAL_CONVICTION_SCORE
and TOTAL_GOVERNANCE_SCORE
(see _updateConvictionTotals
) which is a big issue as these are updated a lot of times each block.
It can also be used for inflating a user's conviction by first calling updateConvictionScore
and then creating conviction tokens with tokenizeConviction
. The _resetConviction
will not actually reset the user's conviction.
Define the checkpoint
variable as a storage
pointer:
Checkpoint storage checkpoint = checkpoints[user][nCheckpoints - 1];
#0 - pauliax
2021-11-19T12:43:06Z
Great find, checkpoint should be persisted in storage for sure.
cmichel
The FSDNetwork.purchaseMembership
sets all variables on the user already through the storage pointer user
:
// using user.xzy = abc already writes to storage Membership storage user = membership[msg.sender]; // this is not needed membership[msg.sender] = user;
The last assignment membership[msg.sender] = user;
is not required.
#0 - YunChe404
2021-11-14T11:28:46Z
#34
#1 - pauliax
2021-11-16T23:16:12Z
A duplicate of #34
0.0239 ETH - $112.98
cmichel
Some calculations don't use SafeMath
or do unsafe casts:
Examples:
ERC20ConvictionScore._updateConvictionScore
: bool hasMinimumGovernanceBalance = (int256(balance) + amount) >= governanceMinimumBalance;
FSDNetwork.openCostShareRequest
: user.availableCostShareBenefits - user.openCostShareBenefits
FSD.mint
: getReserveBalance() - bonded
FSD.mint
: int256(bonded)
FSD.burn
: -int256(capitalDesired)
Not using SafeMath could lead to under-/overflows in certain cases. It also makes it hard to reason about the code, as the reverts only happen at a place that is far from the original overflow/unsafe cast. Updating to a newer Solidity version will break a lot of the code.
Use SafeMath by default. Even if it seems like it's not needed in some places, it could still be that there is some code path that leads to issues that one didn't think of, or gets implemented in the future.
#0 - YunChe404
2021-11-14T11:25:31Z
#58
#1 - pauliax
2021-11-19T10:59:18Z
While many wardens submitted about potential overflow/underflow issues, none were able to provide a concrete attack path and examples of exploit. Nevertheless, I agree with the approach of better safe than sorry and SafeMath operations should be applied where possible or clearly documented why they are not needed, where there is a 100% certainty it will not cause any harm.
Grouping these issues together and assigning a severity of low. Marking this as a primary issue, as it contains the most general and concise description.
🌟 Selected for report: cmichel
0.0155 ETH - $73.58
cmichel
The FSD.claimGovernanceTribute
function first performs the expensive getPriorConvictionScore
instead of the cheap isGovernance[msg.sender]
check.
function claimGovernanceTribute(uint256 num) external { require( governanceThreshold <= getPriorConvictionScore( msg.sender, governanceTributes[num].blockNumber ) && // @audit gas: rearrange this to be first for short circuiting isGovernance[msg.sender], "FSD::claimGovernanceTribute: Not a governance member" ); _claimGovernanceTribute(num); }
Reordering the conditions to first do the cheap governance check would allow this function to short-circuit if the user is not a governor, which will save gas on average.
The last assignment membership[msg.sender] = user;
is not required.
#0 - pauliax
2021-11-16T23:18:45Z
Valid optimization.