Platform: Code4rena
Start Date: 25/11/2021
Pot Size: $80,000 USDC
Total HM: 35
Participants: 32
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 27
Id: 59
League: ETH
Rank: 4/32
Findings: 8
Award: $6,373.90
π Selected for report: 10
π Solo Findings: 4
π Selected for report: thank_you
Also found by: 0x0x0x, Koustre, Meta0xNull, WatchPug, cmichel, defsec, harleythedog, hyh, leastwood, pauliax, pmerkleplant, tabish, xYrYuYx
cmichel
The contracts are missing slippage checks which can lead to being vulnerable to sandwich attacks.
A common attack in DeFi is the sandwich attack. Upon observing a trade of asset X for asset Y, an attacker frontruns the victim trade by also buying asset Y, lets the victim execute the trade, and then backruns (executes after) the victim by trading back the amount gained in the first trade. Intuitively, one uses the knowledge that someoneβs going to buy an asset, and that this trade will increase its price, to make a profit. The attackerβs plan is to buy this asset cheap, let the victim buy at an increased price, and then sell the received amount again at a higher price afterwards.
See UniswapHandler.buyMalt
:
router.swapExactTokensForTokens( rewardBalance, // @audit allows any out amount, can trade at bad price 0, // amountOutMin path, address(this), now );
Trades can happen at a bad price and lead to receiving fewer Malt tokens than at a fair market price. The attacker's profit is the protocol's loss.
Add minimum return amount checks. Accept a function parameter that can be chosen by the transaction sender, then check that the actually received amount is above this parameter.
Alternatively, check if it's feasible to send these transactions directly to a miner (flashbots) such that they are not visible in the public mempool.
#0 - 0xScotch
2021-12-10T00:17:10Z
#219
#1 - GalloDaSballo
2022-01-09T22:13:55Z
Duplicate of #219
π Selected for report: cmichel
1103.7569 USDC - $1,103.76
cmichel
The MovingAverage.sol
contract defines several variables that in the end make the samples
array act as a ring buffer:
sampleMemory
: The total length (buffer size) of the samples
array. samples
is initialized with sampleMemory
zero observations.counter
: The pending sample index (modulo sampleMemory
)The _getFirstSample
function computes the first sample as (counter + 1) % sampleMemory
which returns the correct index only if the ring buffer is full, i.e., it wraps around. (in the counter + 1 >= sampleMemory
).
If the samples
array does not wrap around yet, the zero index should be returned instead.
Returning counter + 1
if counter + 1 < sampleMemory
returns a zero initialized samples
observation index.
This then leads to a wrong computation of the TWAP.
Add an additional check for if (counter + 1 < sampleMemory) return 0
in _getFirstSample
.
#0 - 0xScotch
2021-12-08T12:54:34Z
Funds aren't directly at risk. I believe this is medium severity
#1 - GalloDaSballo
2022-01-10T01:04:09Z
Personally am not sure the first sample after wrapping would be counter + 1
(why not counter % sampleMemory
)
That said, I do agree that before wrapping, the first item in the array is at the 0 index
I agree that the protocol is not behaving properly so I can understand the high severity, that said I also agree with the Sponsor's statement, the worst case would be a leak of value, so Medium severity seems the most appropriate
496.6906 USDC - $496.69
cmichel
The UniswapHandler.maltMarketPrice
function returns a tuple of the price
and the decimals
of the price.
However, the returned decimals
do not match the computed price
for the else if (rewardDecimals < maltDecimals)
branch:
else if (rewardDecimals < maltDecimals) { uint256 diff = maltDecimals - rewardDecimals; price = (rewardReserves.mul(10**diff)).mul(10**rewardDecimals).div(maltReserves); decimals = maltDecimals; }
Note that rewardReserves
are in reward token decimals, maltReserves
is a malt balance amount (18 decimals).
Then, the returned amount is in rewardDecimals + diffDecimals + rewardDecimals - maltDecimals = maltDecimals + rewardDecimals - maltDecimals = rewardDecimals
.
However decimals = maltDecimals
is wrongly returned.
Callers to this function will receive a price in unexpected decimals and might inflate or deflate the actual amount.
Luckily, the AuctionEscapeHatch
decides to completely ignore the returned decimals
and as all prices are effectively in rewardDecimals
, even if stated in maltDecimals
, it currently does not seem to lead to an issue.
Fix the function by returning rewardDecimals
instead of maltDecimals
in the rewardDecimals < maltDecimals
branch.
#0 - GalloDaSballo
2022-01-11T23:31:37Z
Finding is valid, because the returned value is unused, I agree with the medium severity
π Selected for report: cmichel
1103.7569 USDC - $1,103.76
cmichel
The Permissions.reassignGlobalAdmin
function is supposed to only be run with the TIMELOCK_ROLE
role, see onlyRole(TIMELOCK_ROLE, "Only timelock can assign roles")
.
However, the TIMELOCK_ROLE
is not the admin of all the reassigned roles and the revokeRole(role, oldAccount)
calls will fail as it requires the ADMIN_ROLE
.
The idea might have been that only the TIMELOCK
should be able to call this function, and usually it is also an admin, but the function strictly does not work if the caller only has the TIMELOCK
roll and will revert in this case.
Maybe governance decided to remove the admin role from the Timelock, which makes it impossible to call reassignGlobalAdmin
anymore as both the timelock and admin are locked out.
#0 - GalloDaSballo
2022-01-23T01:50:38Z
The warden has identified a flaw in the roles implementation, while the system seems to work when the timelock has multiple roles, the name of the roles implies a different functionality than what can actually be done. The sponsor confirms. While I believe the impact in the demo setup to be fairly minor, because the finding shows a flow in the role setup, and the sponsor confirmed, I agree with medium severity
π Selected for report: cmichel
1103.7569 USDC - $1,103.76
cmichel
Certain ERC20 tokens make modifications to their ERC20's transfer
or balanceOf
functions.
One type of these tokens is deflationary tokens that charge a certain fee for every transfer()
or transferFrom()
.
The Bonding._bond()
function will revert in the _balanceCheck
when transferring a fee-on-transfer token as it assumes the entire amount
was received.
To support fee-on-transfer tokens, measure the asset change right before and after the asset-transferring calls and use the difference as the actual bonded amount.
#0 - GalloDaSballo
2022-01-23T01:54:06Z
Agree with the finding, the check will revert on a token that takes fees as the system assumes that amount
is the amount that will be received
π Selected for report: cmichel
1103.7569 USDC - $1,103.76
cmichel
When adding liquidity through UniswapHandler.addLiquidity
, the entire contract balances are used to add liquidity and the min amounts are set to 95% of these balances.
If the balances in this contract are unbalanced (the ratio is not similar to the current Uniswap pool reserve ratios) then this function will revert and no liquidity is added.
See UniswapHandler.buyMalt
:
(maltUsed, rewardUsed, liquidityCreated) = router.addLiquidity( address(malt), address(rewardToken), maltBalance, // @audit-info amountADesired rewardBalance, // @audit assumes that whatever is in this contract is already balanced. good assumption? maltBalance.mul(95).div(100), // @audit-info amountAMin rewardBalance.mul(95).div(100), msg.sender, // transfer LP tokens to sender now );
If the contract has unbalanced balances, then the router.addLiquidity
call will revert.
Note that an attacker could even send tokens to this contract to make them unbalanced and revert, resulting in a griefing attack.
It needs to be ensured that the balances in the contract are always balanced and match the current reserve ratio. It might be better to avoid directly using the balances which can be manipulated by transferring tokens to the contract and accepting parameters instead of how many tokens to provide liquidity with from the caller side.
#0 - GalloDaSballo
2022-01-26T13:34:09Z
Interestingly the warden highlights the other side of the "missing slippage check" argument. Slippage checks in general need to be calculated offChain (as you will get frontrun in the mempool, so the slippage check is a risk minimization tool more than anything else)
The warden also specified a griefing attack that can be used because of the hardcoded check. The sponsor confirms, I think medium severity is appropriate
cmichel
Some parameters of functions are not checked for invalid values:
AbstractRewardMine._initialSetup: _rewardToken, _miningService
are not verified to be non-zero or contracts.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 - 0xScotch
2021-12-10T01:14:36Z
#74
#1 - GalloDaSballo
2022-01-24T01:44:34Z
Duplicate of #74
π Selected for report: cmichel
Also found by: 0x1f8b, Meta0xNull, WatchPug, defsec, jayjonah8, leastwood, stonesandtrees
21.9968 USDC - $22.00
cmichel
The initialize
function that initializes important contract state can be called by anyone.
See:
ERC20VestedMine.initialize
AuctionPool.initialize
Permissions
The attacker can initialize the contract before the legitimate deployer, hoping that the victim continues to use the same contract. In the best case for the victim, they notice it and have to redeploy their contract costing gas.
Use the constructor to initialize non-proxied contracts.
For initializing proxy contracts deploy contracts using a factory contract that immediately calls initialize
after deployment or make sure to call it immediately after deployment and verify the transaction succeeded.
#0 - GalloDaSballo
2022-01-22T14:24:30Z
Knowing that the contracts are not upgradeable, I fully agree with the finding. Because of the lack of upgradeability I'd recommend the sponsor to have a constructor giving privileged initialization permissions the the deployer. Or as they already agreed to, just use a constructor (which will enable the use of immutable, saving gas)
cmichel
The StabilizerNode._shouldAdjustSupply
function divides the lowerThreshold
by the rewardToken
decimals leading to it having wrong decimals.
uint256 lowerThreshold = priceTarget.mul(lowerStabilityThreshold).div(10**decimals);
Note that the priceTarget
is in 18 decimals (otherwise, other functions fail) and lowerStabilityThreshold = 1e18 / 100 = 1e16
.
If rewardToken
has less than 18 decimals, for example 6 for USDC, then lowerThreshold = 1e18 * 1e16 / 10**rewardTokenDecimals = 1e34 / 1e6 = 1e28
.
The priceTarget.sub(lowerThreshold)
that follows will underflow and revert the transaction.
The stabilize
calls will always revert for rewardToken
that don't happen to have 18 decimals.
The developers probably wanted to divide by 1e18
here, the lowerStabilityThreshold
percentage baseline, to arrive at a fraction of priceTarget
.
#0 - 0xScotch
2021-12-08T12:53:03Z
#293
#1 - GalloDaSballo
2022-01-25T02:34:38Z
As demonstrated in #293 the issue can be sidestepped, finding is valid, but am downgrading to low severity as this is a duplicate of #293
99.3381 USDC - $99.34
cmichel
The ERC20.approve()
function returns a boolean value indicating success.
This parameter needs to be checked for success.
Some tokens do not revert if the transfer failed but return false
instead.
In addition, some tokens (like USDT L199) do not work when changing the allowance from an existing non-zero allowance value. They must first be approved by zero and then the actual allowance must be approved.
IERC20(token).safeApprove(address(operator), 0); IERC20(token).safeApprove(address(operator), amount);
This issue exists for example in AuctionParticipant.purchaseArbitrageTokens
:
auctionRewardToken.approve(address(auction), balance);
As well as in UniswapHandler.buyMalt
:
rewardToken.approve(address(router), rewardBalance);
Tokens that don't correctly implement the latest EIP20 spec, by either returning false
on failure or reverting if approved from a non-zero value, will be unusable in the protocol as they revert the transaction because of the missing return value.
We recommend using OpenZeppelinβs SafeERC20
versions with the safeApprove(0)
functions that handle the return value check as well as non-standard-compliant tokens.
#0 - GalloDaSballo
2022-01-24T01:21:27Z
Agree with the finding, not sure if this should be raised to medium severity, either way the finding is valid
π Selected for report: cmichel
367.919 USDC - $367.92
cmichel
There's code in UniswapHandler.removeLiquidity
that attempts to transfer left-over LP tokens after they all have been burned in the router.removeLiqudity
call:
(amountMalt, amountReward) = router.removeLiquidity(..., lpToken.balanceOf(address(this)), ...); if (amountMalt == 0 || amountReward == 0) { liquidityBalance = lpToken.balanceOf(address(this)); lpToken.safeTransfer(msg.sender, liquidityBalance); return (amountMalt, amountReward); }
A router.removeLiqudity
call transfers all specified LP tokens to the pool contract and then calls pool.burn
which burns all of them.
This code is not needed and might indicate that something else was intended by the developers.
#0 - 0xScotch
2021-12-04T20:39:02Z
This is correct. Taking another look at the uniswap router contract shows it reverts if anything goes wrong. So this code won't ever run. Keeping this doesn't hurt though in the case of using a different implementation of a uniswap style router contract
#1 - GalloDaSballo
2022-01-24T01:28:39Z
Agree with the finding and the severity given the "deadcode" scenario
π Selected for report: cmichel
367.919 USDC - $367.92
cmichel
The RewardReinvestor.splitReinvest
splits the reward token in half and trades half of it to malt, then provides liquidity in the same pool.
This is not optimal as the trade comes with fees and slippage and the reserve ratios don't exactly equal the reward / malt ratio used to provide LP.
For more info, see here.
#0 - 0xScotch
2021-12-04T21:00:53Z
This should be improved but will likely come in a future update unless we have the time before launch to implement this correctly. Not highest priority
#1 - GalloDaSballo
2022-01-24T01:31:36Z
Finding is valid and surprisingly deep
π Selected for report: cmichel
367.919 USDC - $367.92
cmichel
The AbstractRewardMine
contract states the following about the two variables:
totalReleasedReward and totalDeclaredReward will often be the same. However, in the case of vesting rewards they are different. In that case totalDeclaredReward is total reward, including unvested. totalReleasedReward is just the rewards that have completed the vesting schedule.
According to the description, it should be the case that totalDeclaredReward >= totalReleasedReward
at all times.
But it's not true as totalReleasedReward = totalDeclaredReward + _globalWithdrawn >= totalDeclaredReward
.
I don't think the implementation in the abstract contract makes sense, the derived contracts all correctly overwrite it.
Maybe it should be a virtual
function without an implementation that needs to be overridden.
#0 - 0xScotch
2021-12-06T13:35:01Z
The AbstractRewardMine
implementation is indeed incorrect. The solution is to add + _globalWithdrawn
to the totalDeclaredReward
method.
As stated in the issue above, the 2 places that inherit this contract correctly override the methods in question.
#1 - GalloDaSballo
2022-01-24T01:44:06Z
Will mark the finding valid as the logic detailed was incorrect, as for the inheritance discussion, probably best left for a different issue
15.2616 USDC - $15.26
cmichel
The UniswapHandler.addLiquidity
function performs a safe math computation as rewardBalance.sub(rewardUsed)
but the if
condition already verified that rewardUsed < rewardBalance
and therefore an unsafe subtraction can be used.
#0 - 0xScotch
2021-12-08T18:33:15Z
#307