Platform: Code4rena
Start Date: 27/01/2022
Pot Size: $90,000 USDC
Total HM: 21
Participants: 33
Period: 7 days
Judge: Jack the Pug
Total Solo HM: 14
Id: 78
League: ETH
Rank: 2/33
Findings: 7
Award: $14,906.80
🌟 Selected for report: 5
🚀 Solo Findings: 3
🌟 Selected for report: shw
Also found by: kirk-baird, pauliax
kirk-baird
The function assertGovernanceApproved(address sender, address target, bool emergency) has public
visibility and may be called by any user.
Since the user who creates the transaction is able to specify the sender
address they are able to force users who have approved FlashGovernanceArbiter
to transfer their tokens to the FlashGovernanceArbiter
.
The result is that the address which is the sender
field will have their tokens locked as if they've made a flash governance decision and won't be able to withdraw them until unlockTime
passes. They also will not be able to make any flash decisions while they wait for unlockTime
to pass.
The function assertGovernanceApproved() does not validate either the caller of the function of the field sender
.
function assertGovernanceApproved( address sender, address target, bool emergency ) public { if ( IERC20(flashGovernanceConfig.asset).transferFrom(sender, address(this), flashGovernanceConfig.amount) && pendingFlashDecision[target][sender].unlockTime < block.timestamp ) { require( emergency || (block.timestamp - security.lastFlashGovernanceAct > security.epochSize), "Limbo: flash governance disabled for rest of epoch" ); pendingFlashDecision[target][sender] = flashGovernanceConfig; pendingFlashDecision[target][sender].unlockTime += block.timestamp; security.lastFlashGovernanceAct = block.timestamp; emit flashDecision(sender, flashGovernanceConfig.asset, flashGovernanceConfig.amount, target); } else { revert("LIMBO: governance decision rejected."); } }
Consider whitelisting a set of addresses who may call assertGovernanceApproved(). These whitelisted addresses will be those who have implemented Governable.sol.
#0 - gititGoro
2022-02-10T00:38:32Z
duplicate of 268
#1 - jack-the-pug
2022-02-22T15:10:17Z
Dup #300
🌟 Selected for report: kirk-baird
6626.1986 USDC - $6,626.20
kirk-baird
Users who have not called withdrawGovernanceAsset() after they have locked their tokens from a previous proposal (i.e. assertGovernanceApproved), will lose their tokens if assertGovernanceApproved() is called again with the same target
and sender
.
The sender
will lose pendingFlashDecision[target][sender].amount
tokens and the tokens will become unaccounted for and locked in the contract. Since the new amount is not added to the previous amount, instead the previous amount is overwritten with the new amount.
The impact of this is worsened by another vulnerability, that is assertGovernanceApproved() is a public
function and may be called by any arbitrary user so long as the sender
field has called approve()
for FlashGovernanceArbiter
on the ERC20 token. This would allow an attacker to make these tokens inaccessible for any arbitrary sender
.
In assertGovernanceApproved() as seen below, the linependingFlashDecision[target][sender] = flashGovernanceConfig
will overwrite the previous contents. Thereby, making any previous rewards unaccounted for and inaccessible to anyone.
Note that we must wait pendingFlashDecision[target][sender].unlockTime
between calls.
function assertGovernanceApproved( address sender, address target, bool emergency ) public { if ( IERC20(flashGovernanceConfig.asset).transferFrom(sender, address(this), flashGovernanceConfig.amount) && pendingFlashDecision[target][sender].unlockTime < block.timestamp ) { require( emergency || (block.timestamp - security.lastFlashGovernanceAct > security.epochSize), "Limbo: flash governance disabled for rest of epoch" ); pendingFlashDecision[target][sender] = flashGovernanceConfig; pendingFlashDecision[target][sender].unlockTime += block.timestamp; security.lastFlashGovernanceAct = block.timestamp; emit flashDecision(sender, flashGovernanceConfig.asset, flashGovernanceConfig.amount, target); } else { revert("LIMBO: governance decision rejected."); } }
Consider updating the initial if statement to ensure the pendingFlashDecision
for that target
and sender
is empty, that is:
function assertGovernanceApproved( address sender, address target, bool emergency ) public { if ( IERC20(flashGovernanceConfig.asset).transferFrom(sender, address(this), flashGovernanceConfig.amount) && pendingFlashDecision[target][sender].unlockTime == 0 ) { ...
Note we cannot simply add the new amount
to the previous amount
incase the underlying asset
has been changed.
#0 - gititGoro
2022-02-03T22:13:58Z
Excellent find! Thank you.
🌟 Selected for report: kirk-baird
1987.8596 USDC - $1,987.86
kirk-baird
The function withdrawGovernanceAsset() is vulnerable to reentrancy, which would allow the attacker to drain the balance of the flashGoverananceConfig.asset
.
Note: this attack assumes the attacker may gain control of the execution flow in asset.tranfer()
which is the case for many ERC20 tokens such as those that implement ERC777 but will depend on which asset is chosen in the configuration.
withdrawGovernanceAsset() does not follow the check-effects-interactions pattern as seen from the following code snippet, where an external call is made before state modifications.
function withdrawGovernanceAsset(address targetContract, address asset) public virtual { require( pendingFlashDecision[targetContract][msg.sender].asset == asset && pendingFlashDecision[targetContract][msg.sender].amount > 0 && pendingFlashDecision[targetContract][msg.sender].unlockTime < block.timestamp, "Limbo: Flashgovernance decision pending." ); IERC20(pendingFlashDecision[targetContract][msg.sender].asset).transfer( msg.sender, pendingFlashDecision[targetContract][msg.sender].amount ); delete pendingFlashDecision[targetContract][msg.sender]; }
The attacker can exploit this vulnerability through the following steps:
assertGovernanceApproved(userA, target, false)
unlockTime
seconds to passwithdrawGovernanceAsset(target, asset)
and gain control of the execution during asset.transfer()
FlashGovernanceArbiter
is less than pendingFlashDecision[target][msg.sender].amount
There are two possible mitigations, the first is to implement the check-effects-interactions patter. This involves doing as checks and state changes before making external calls. To implement this in the current context delete the pendingFlashDecision
before making the external call as follows.
function withdrawGovernanceAsset(address targetContract, address asset) public virtual { require( pendingFlashDecision[targetContract][msg.sender].asset == asset && pendingFlashDecision[targetContract][msg.sender].amount > 0 && pendingFlashDecision[targetContract][msg.sender].unlockTime < block.timestamp, "Limbo: Flashgovernance decision pending." ); uint256 amount = pendingFlashDecision[targetContract][msg.sender].amount; IERC20 asset = IERC20(pendingFlashDecision[targetContract][msg.sender].asset); delete pendingFlashDecision[targetContract][msg.sender]; asset.transfer(msg.sender, amount); }
#0 - gititGoro
2022-02-03T22:10:36Z
I do mention in the documentation that the only eligible assets are EYE and EYE LPs but that rule isn't enforced on a contract level which is why I'm acknowledging this rather than disputing it. Nonetheless it's not relevant to the context of Limbo
🌟 Selected for report: kirk-baird
1987.8596 USDC - $1,987.86
kirk-baird
The proposal to burn a user's tokens for a flash governance proposal does not result in the user losing any funds and may in fact unlock their funds sooner.
The function burnFlashGovernanceAsset() will simply overwrite the user's state with pendingFlashDecision[targetContract][user] = flashGovernanceConfig;
as seen below.
function burnFlashGovernanceAsset( address targetContract, address user, address asset, uint256 amount ) public virtual onlySuccessfulProposal { if (pendingFlashDecision[targetContract][user].assetBurnable) { Burnable(asset).burn(amount); } pendingFlashDecision[targetContract][user] = flashGovernanceConfig; }
Since flashGovernanceConfig
is not modified in BurnFlashStakeDeposit.execute() the user will have amount
set to the current config amount which is likely what they originally transferred in {assertGovernanceApproved()](https://github.com/code-423n4/2022-01-behodler/blob/main/contracts/DAO/FlashGovernanceArbiter.sol#L60).
Furthermore, unlockTime
will be set to the config unlock time. The config unlock time is the length of time in seconds that proposal should lock tokens for not the future timestamp. That is unlock time may be say 7 days
rather than now + 7 days
. As a result the check in withdrawGovernanceAsset() pendingFlashDecision[targetContract][msg.sender].unlockTime < block.timestamp,
will always pass unless there is a significant misconfiguration.
Consider deleting the user's data (i.e. delete pendingFlashDecision[targetContract][user]
) rather than setting it to the config. This would ensure the user cannot withraw any funds afterwards.
Alternatively, only update pendingFlashDecision[targetContract][user].amount
to subtract the amount sent as a function parameter and leave the remaining fields untouched.
#0 - gititGoro
2022-06-25T22:42:33Z
🌟 Selected for report: camden
Also found by: GeekyLumberjack, kirk-baird, shw
362.2874 USDC - $362.29
kirk-baird
There is a denial of service (DoS) attack on UniswapHelper.sol
which will cause the ensurePriceStability
modifier to revert whenever it is called by regularly calling generateFLNQuote().
By calling generateFLNQuote at intervals of less than VARS.minQuoteWaitDuration
we will update the blockProduced
time of each element in latestFlanQuotes
.
As a result the final require
statement in _ensurePriceStability() will revert.
The impact is that we could prevent users from calling stabilizeFlan() and minAPY_to_FPS(), which would prevent Limbo.sol
from calling migrate() and attemptToTargetAPY() respectively.
generateFLNQuote is public and will set latestFlanQuotes[0].blockProduced
and latestFlanQuotes[1].blockProduced
respectively.
function generateFLNQuote() public override { latestFlanQuotes[1] = latestFlanQuotes[0]; ( latestFlanQuotes[0].DaiScxSpotPrice, latestFlanQuotes[0].DaiBalanceOnBehodler ) = getLatestFLNQuote(); latestFlanQuotes[0].blockProduced = block.number; }
_ensurePriceStability() will revert is VARS.minQuoteWaitDuration
amount of time has not passed between the last two calls to generateFLNQuote()
.
require( localFlanQuotes[0].blockProduced - localFlanQuotes[1].blockProduced > VARS.minQuoteWaitDuration && localFlanQuotes[1].blockProduced > 0, "EH" );
Consider preventing generateFLNQuote()
from being called unless at least VARS.minQuoteWaitDuration
seconds have passed.
#0 - gititGoro
2022-02-12T03:00:02Z
Initially I didn't add this to allow people to resample if the first sample was taken at a strange time in the market but since migrations are not time sensitive, this suggestion is worth more than the inconvenience it creates.
#1 - jack-the-pug
2022-02-27T09:21:37Z
Dup #102
🌟 Selected for report: camden
Also found by: kirk-baird
894.5368 USDC - $894.54
kirk-baird
It is possible to bypass the additional 2hrs added to the length of voting when the vote() flips from positive to negative or vice versa.
This can be done by breaking the vote()
into two steps first sending enough fate to make the proposal zero. Then sending fate to change the proposal to the opposite direction.
The impact is that we will bypass the protections that stop an attacker from swinging the vote in the final seconds with a large sum of fate. Thus, an attacker may pre-calculate the cost of swinging the proposal in the last seconds and weight it against the benefit of swinging the proposal. For example a proposal like withdrawERC20 may allow an attacker to withdraw ERC20 tokens and thus they may easily decide the cost vs benefit of taking these tokens.
The following lines can be found in vote() and represent the flipping logic.
} else if ( //The following if statement checks if the vote is flipped by fate fate * currentFate < 0 && //sign different (fate + currentFate) * fate > 0 //fate flipped current fate onto the same side of zero as fate ) { //extend voting duration when vote flips decision. Suggestion made by community member currentProposalState.start = currentProposalState.start + 2 hours; }
If transfer exactly enough fate that currentFate + fate = 0
then we will never pass (fate + currentFate) * fate > 0
since 0 * fate = 0
. Then when we move from zero to the value we desire (either positive or negative) and this will aways pass due to the first condition fate * currentFate < 0
as currentFate = 0
.
Steps:
vote()
with fate = -currentProposalState.fate
vote()
with fate
set to the desired amountConsider handling the case where the vote moves from a non-zero integer to zero. Note the first edge case where initial state where currentProposalState.fate = 0
will need to be handled. Potentially modify the code such that zero is always considered positive.
#0 - jack-the-pug
2022-02-27T09:31:10Z
Dup #106
🌟 Selected for report: kirk-baird
Also found by: shw
298.1789 USDC - $298.18
kirk-baird
The proposal contract WithdrawERC20Proposal
allows a the DAO to withdraw ERC20 tokens to a destination the the function withdrawERC20().
However, this function is not implemented in Limbo.sol and thus the execution can never succeed.
Consider implementing this functionality in Limbo.sol
or deleting the proposal.
#0 - gititGoro
2022-02-04T01:18:47Z
This is just an outdated proposal. A previous version of Limbo implemented this.
#1 - gititGoro
2022-02-11T03:01:31Z
I'm changing this to confirmed as it is important for us to not deploy broken proposals.
🌟 Selected for report: kirk-baird
662.6199 USDC - $662.62
kirk-baird
There is insufficient validation of burnFlashGovernanceAsset() parameters.
These parameters are set in a proposal that must be approved by the DAO.
However, there are no guarantees any of the parameters match the user's details.
The impacts of misconfigured parameters are listed below:
amount
does not match pendingFlashDecision[target][user].amount
. This would leave a shortage of assets in the contract to which some other user(s) may not be able to withdraw their assets.asset
does not match pendingFlashDecision[target][user].asset
function burnFlashGovernanceAsset( address targetContract, address user, address asset, uint256 amount ) public virtual onlySuccessfulProposal { if (pendingFlashDecision[targetContract][user].assetBurnable) { Burnable(asset).burn(amount); } pendingFlashDecision[targetContract][user] = flashGovernanceConfig; }
Consider adding the require statements to burnFlashGovernanceAsset() or something similar related to amount
.
require(pendingFlashDecision[target][user].amount >= amount) require(pendingFlashDecision[target][user].asset >= asset)
#0 - gititGoro
2022-02-03T22:20:32Z
This is more a problem for the DAO. They may either choose to add the fix you suggest, make a tight proposal or whitelist a better version of this contract. It's not required that any of these proposals be included. They're just examples I created.
#1 - jack-the-pug
2022-02-22T15:30:09Z
Input validations can be added. Downgrading to low
.
🌟 Selected for report: Ruhum
Also found by: kirk-baird
298.1789 USDC - $298.18
kirk-baird
During configuration for contracts inheriting Governable.sol such as Limbo.sol and Flan.sol, there are no access controls on the configuration changes.
The lack of access control is to reduce the need for lengthy DAO proposals during configuration.
This may be exploited by attackers to sneak in configuration changes that may or may not go unnoticed by the protocol deployer.
One example of an attack would be for a user to call Flan.whitelistMinting() which would allow the attacker to mint an infinite number of Flan tokens in the future.
The functions _governanaceApproval() and assetSuccessfulProposal() will always pass if configured = false
.
function _governanceApproved(bool emergency) internal { bool successfulProposal = LimboDAOLike(DAO).successfulProposal(msg.sender); if (successfulProposal) { flashGoverner.setEnforcement(false); } else if (configured) flashGoverner.assertGovernanceApproved(msg.sender, address(this), emergency); }
function assertSuccessfulProposal(address sender) internal view { require(!configured || LimboDAOLike(DAO).successfulProposal(sender), "EJ"); }
Thus the two modifiers governanceApproved() and onlySuccessfulProposal will always pass.
Then the attacker may call whitelistMinting() to give their address infinite minting. Since it uses onlySuccessfulProposal
modifier.
There many other attack vectors through other configuration functions though this is the simplest.
Consider passing a configurator
address to the constructor. Then adding access controls while in configuration mode that only allow the configurator
to make changes rather than any address.
For example _governanceApproved()
may look like the following
function _governanceApproved(bool emergency) internal { bool successfulProposal = LimboDAOLike(DAO).successfulProposal(msg.sender); if (successfulProposal) { flashGoverner.setEnforcement(false); } else if (configurator != msg.sender) { flashGoverner.assertGovernanceApproved(msg.sender, address(this), emergency); } }
#0 - gititGoro
2022-02-01T04:36:21Z
This is a problem only if either an attacker knows the addresses of the contracts or knows the address of the deploying account and monitors it for contract creation events and then figures out what those contracts are and pounces. So an easier fix would be to deploy from a fresh address? Is there some way an attacker can lie in wait, no matter deployment address I use?
#1 - jack-the-pug
2022-02-27T10:25:42Z
Dup #78