Platform: Code4rena
Start Date: 20/05/2021
Pot Size: $55,000 USDC
Total HM: 19
Participants: 8
Period: 7 days
Judge: cemozer
Total Solo HM: 11
Id: 11
League: ETH
Rank: 1/8
Findings: 10
Award: $18,416.00
🌟 Selected for report: 4
🚀 Solo Findings: 4
cmichel
In tokenizeConviction
when locked > 0
the amount is first transferred from the user using an internal call to _transfer(msg.sender, address(fairSideConviction), locked);
.
It is then transferred a second time from the user in the fairSideConviction.createConvictionNFT
call:
function createConvictionNFT( address user, uint256 score, uint256 locked, bool isGovernance ) external override returns (uint256) { if (locked > 0) { cs.locked = locked; // steals a second time FSD.safeTransferFrom(user, address(this), locked); } }
The locked balance is transferred twice from the user instead of once, stealing their balance.
Remove the transfer in createConvictionNFT
.
#0 - fairside-core
2021-05-30T13:02:16Z
Duplicate of #74
#1 - cemozerr
2021-06-07T19:42:45Z
Duplicate of #29
🌟 Selected for report: cmichel
4803.7452 USDC - $4,803.75
cmichel
The TOTAL_GOVERNANCE_SCORE
is supposed to track the sum of the credit scores of all governors.
In ERC20ConvictionScore._updateConvictionScore
, when the user does not fulfill the governance criteria anymore and is therefore removed, the governanceDelta
should be negative but it's positive.
isGovernance[user] = false; governanceDelta = getPriorConvictionScore( user, block.number - 1 );
It then gets added to the new total:
uint224 totalGCSNew = add224( totalGCSOld, governanceDelta, "ERC20ConvictionScore::_updateConvictionTotals: conviction score amount overflows" );
The TOTAL_GOVERNANCE_SCORE
tracks wrong data leading to issues throughout all contracts like wrong FairSideDAO.totalVotes
data which can then be used for anyone to pass proposals in the worst case.
Or totalVotes
can be arbitrarily inflated and break the voting mechanism as no proposals can reach the quorum (percentage of totalVotes
) anymore.
Return a negative, signed integer for this case and add it to the new total.
#0 - fairside-core
2021-06-01T14:23:22Z
Fixed in PR#14.
cmichel
The name pendingWithdrawals
indicates that this storage variable tracks the withdrawals that need yet to be paid out which also matches the behavior in _increaseWithdrawal
.
So it should be decreased when withdrawing in withdraw
but it is not.
The getReserveBalance
consistently under-reports the actual reserve balance which leads to wrong mint amounts being used in the FSD.mint
calculation.
Decrease pendingWithdrawals
by the withdrawn amount.
#0 - fairside-core
2021-05-30T13:06:24Z
Duplicate of #72
cmichel
When _reserveDelta
is negative in ABC._calculateDeltaOfFSD
the following branch is executed:
if (_reserveDelta < 0) { uint256 capitalPostWithdrawal = capitalPool.sub(uint256(_reserveDelta));
The type cast to uint256
is purely a reinterpretation of the underlying bytes, it does not compute the absolute value.
Which means uint256(_reserveDelta)
will be a huge value (2^256 - abs(_reserveDelta)
) due to two's complement encoding. This is always greater than the capitalPool
and the transaction will revert because of the underflow in SafeMath.sub
.
The FSD.burn
function always calls it with a negative value which would break the burn
function among other functions. (Why is this not caught in a test though?)
Multiply _reserveDelta
by -1
to get the absolute value and then the type-cast to uint256
will be safe.
#0 - fairside-core
2021-05-30T12:42:54Z
Duplicate of #77
🌟 Selected for report: cmichel
4803.7452 USDC - $4,803.75
cmichel
In ERC20ConvictionScore._updateConvictionScore
, when the user does not fulfill the governance criteria anymore, the governanceDelta
is the old conviction score of the previous block.
isGovernance[user] = false; governanceDelta = getPriorConvictionScore( user, block.number - 1 );
The user could increase their conviction / governance score first in the same block and then lose their status in a second transaction, and the total governance conviction score would only be reduced by the previous score.
Example:
Block n - 10000: User is a governor and has a credit score of 1000 which was also contributed to the TOTAL_GOVERNANCE_SCORE
Block n:
updateConvictionScore
function which increases the credit score by 5000 based on the accumulated time. The total governance credit score increased by 5000, making the user contribute 6000 credit score to governance in total.governanceMinimumBalance
and user is not a governor anymore. The governanceDelta
update of the transfer should be 6000 (user's whole credit score) but it's only 1000
because it takes the snapshot of block n - 1.The TOTAL_GOVERNANCE_SCORE
score can be inflated this way and break the voting mechanism in the worst case as no proposals can reach the quorum (percentage of totalVotes
) anymore.
Use the current conviction store which should be governanceDelta = checkpoints[user][userCheckpointsLength - 1].convictionScore
#0 - fairside-core
2021-05-30T15:02:28Z
As with the other governance related issues, this would once again cause dilution of all users and would not really be a viable attack vector. As such, I believe it is better suited for a medium severity (2) label.
#1 - fairside-core
2021-06-01T13:25:35Z
This issue is actually quite deeper. When a transaction occurs in the same block, the logic paths within the if
block will not execute (due to time elapsed being 0) meaning that conviction score will not be properly accounted for if I have a single normal transaction where I am still governance and consequently lose my governance in a second transaction. As such, the code needs to be adjusted to check governance eligibility outside of the if block as well (if no time has passed -> same block transaction).
The code highlighted in the finding is actually correct. The conviction score should be reduced by the previous block's as the newly accrued conviction score was never accounted for in governance and the solution proposed would actually lead to more conviction being reduced than it should. However, the finding did point out something that was wrong so not sure whether it should be nullified or not.
I believe it should be awarded as it was on the right track to find the underlying issue!
#2 - fairside-core
2021-06-01T13:33:26Z
Fixed in PR#13.
#3 - cemozerr
2021-06-08T19:19:10Z
Labeling this issue as valid, because although it wasn't 100% right on suggesting where the code was problematic, it did point out that the users could wrongfully transfer their whole balance and update their conviction score in the same block to keep their conviction score high, and then potentially do harmful things to the protocol by using their wrong conviction scores.
cmichel
In tokenizeConviction
, when locked == 0
the _updateConvictionScore(msg.sender, 0)
function is called to update the user's conviction, however the delta is not added to the total credit / governance score.
The TOTAL_CONVICTION_SCORE
and TOTAL_GOVERNANCE_SCORE
track wrong data leading to issues throughout all contracts, especially with on-chain governance voting.
Call updateConvictionScore
instead of _updateConvictionScore
.
#0 - fairside-core
2021-05-30T14:57:08Z
Duplicate of #28
🌟 Selected for report: cmichel
1441.1236 USDC - $1,441.12
cmichel
The credit score of the special address(type(uint160).max)
is supposed to represent the sum of the credit scores of all users that are governors.
But any user can directly transfer to this address increasing its balance and accumulating a credit score in _updateConvictionScore(to=address(uint160.max), amount)
.
It'll first write a snapshot of this address' balance which should be very low:
// in _updateConvictionScore _writeCheckpoint(user, userNum, userNew) = _writeCheckpoint(TOTAL_GOVERNANCE_SCORE, userNum, checkpoints[user][userNum - 1].convictionScore + convictionDelta);
This address then accumulates a score based on its balance which can be updated using updateConvictionScore(uint160.max)
and breaks the invariant.
Increasing it might be useful for non-governors that don't pass the voting threshold and want to grief the proposal voting system by increasing the quorumVotes
threshold required for proposals to pass. (by manipulating FairSideDAO.totalVotes
). totalVotes
can be arbitrarily inflated and break the voting mechanism as no proposals can reach the quorum (percentage of totalVotes
) anymore.
Disallow transfers from/to this address. Or better, track the total governance credit score in a separate variable, not in an address.
#0 - fairside-core
2021-05-30T14:59:04Z
This is actually what #61 is meant to be used for. I would label this a medium level finding as it would simply dilute the voting rights of users at the expense of permanently losing FSD which should not be a viable vector.
#1 - fairside-core
2021-06-01T13:04:39Z
Indirectly fixed by PR#10.
#2 - cemozerr
2021-06-08T19:21:50Z
Labeling this as medium risk as it would not pose threat to user funds yet stall the governance process.
262.6448 USDC - $262.64
cmichel
There is no check in FSDNetwork.getEtherPrice
if the return values indicate stale data. This could lead to stale prices according to the Chainlink documentation:
Stale prices that do not reflect the current market price anymore could be used which would influence the membership and cost share pricing.
Add the recommended checks:
( uint80 roundID, int256 price, , uint256 timeStamp, uint80 answeredInRound ) = ETH_CHAINLINK.latestRoundData(); require( timeStamp != 0, "ChainlinkOracle::getLatestAnswer: round is not complete" ); require( answeredInRound >= roundID, "ChainlinkOracle::getLatestAnswer: stale data" ); require(price != 0, "FSDNetwork::getEtherPrice: Chainlink Malfunction");
#0 - fairside-core
2021-05-30T13:19:36Z
I believe it should be reduced in severity. The second bullet point only applies to historical rounds, not the latest round meaning that invalidity will never occur. Staleness will occur, however, the attack vector has a low likelihood given Chainlink's reputation. Still, definitely a 1 (minor) issue!
#1 - fairside-core
2021-05-30T13:20:01Z
Duplicate of #70
🌟 Selected for report: cmichel
1441.1236 USDC - $1,441.12
cmichel
There are two issues with the governance checks when acquiring them from an NFT:
The governance checks in _updateConvictionScore
are:
!isGovernance[user] && userConvictionScore >= governanceThreshold && balanceOf(user) >= governanceMinimumBalance;
Whereas in acquireConviction
, only userConvictionScore >= governanceThreshold
is checked but not && balanceOf(user) >= governanceMinimumBalance
.
else if ( !isGovernance[msg.sender] && userNew >= governanceThreshold ) { isGovernance[msg.sender] = true; }
wasGovernance
might be outdatedThe second issue is that at the time of NFT creation, the governanceThreshold
or governanceMinimumBalance
was different and would not qualify for a governor now.
The NFT's governance state is blindly appplied to the new user:
if (wasGovernance && !isGovernance[msg.sender]) { isGovernance[msg.sender] = true; }
This allows a user to circumvent any governance parameter changes by front-running the change with an NFT creation.
It's easy to circumvent the balance check to become a governor by minting and redeeming your own NFT. One can also circumvent any governance parameter increases by front-running these actions with an NFT creation and backrunning with a redemption.
Add the missing balance check in acquireConviction
.
Remove the wasGovernance
governance transfer from the NFT and solely recompute it based on the current governanceThreshold
/ governanceMinimumBalance
settings.
#0 - fairside-core
2021-05-30T14:29:34Z
The latter of the two issue "types" is actually desired behaviour. If a user was historically a governance member, the NFT should boast the exact same rights and new thresholds should not retro-actively apply. The former, however, is a valid issue as it allows circumventing the balance check!
#1 - fairside-core
2021-06-01T13:17:56Z
Fixed in PR#12.
cmichel
Some parameters of functions are not checked:
FairSideConviction.constructor: _fsd
FairSideDAO.constructor: _timelock, _FSD, _guardian
FSDNetwork.constructor: _fsd, fundingPool, governance, timelock
FSD.constructor: _fundingPool, whitelistSigner, _timelock
A wrong user input or wallets defaulting to the zero addresses for a missing input can lead to the contract needing to redeploy or wasted gas.
Validate the parameters.
#0 - fairside-core
2021-05-30T13:59:29Z
Duplicate of #56