Platform: Code4rena
Start Date: 17/06/2021
Pot Size: $60,000 USDC
Total HM: 12
Participants: 12
Period: 7 days
Judge: LSDan
Total Solo HM: 8
Id: 14
League: ETH
Rank: 1/12
Findings: 8
Award: $19,268.70
π Selected for report: 10
π Solo Findings: 3
2622.1545 USDC - $2,622.15
cmichel
YearnV2YieldSource._withdrawFromVault
uses a wrong subtraction.
When withdrawing from the vault
one redeems yTokens
for token
s, thus the token
balance of the contract should increase after withdrawal.
But the contract subtracts the currentBalance
from the previousBalance
:
uint256 yShares = _tokenToYShares(amount); uint256 previousBalance = token.balanceOf(address(this)); // we accept losses to avoid being locked in the Vault (if losses happened for some reason) if(maxLosses != 0) { vault.withdraw(yShares, address(this), maxLosses); } else { vault.withdraw(yShares); } uint256 currentBalance = token.balanceOf(address(this)); // @audit-issue this seems wrong return previousBalance.sub(currentBalance);
All vault withdrawals fail due to the integer underflow as the previousBalance
is less than currentBalance
. Users won't be able to get back their investment.
It should return currentBalance > previousBalance ? currentBalance - previousBalance : 0
π Selected for report: cmichel
5827.0101 USDC - $5,827.01
cmichel
When suppling to the BadgerYieldSource
, some amount
of badger
is deposited to badgerSett
and one receives badgerSett
share tokens in return which are stored in the balances
mapping of the user. So far this is correct.
The balanceOfToken
function should then return the redeemable balance in badger
for the user's badgerSett
balance.
It computes it as the pro-rata share of the user balance (compared to the total-supply of badgerSett
) on the badger
in the vault:
balances[addr].mul( badger.balanceOf(address(badgerSett)) ).div( badgerSett.totalSupply() )
However, badger.balanceOf(address(badgerSett))
is only a small amount of badger that is deployed in the vault ("Sett") due to most of the capital being deployed to the strategies. Therefore, it under-reports the actual balance:
Typically, a Sett will keep a small portion of deposited funds in reserve to handle small withdrawals cheaply. Badger Docs
Any contract or user calling the balanceOf
function will receive a value that is far lower than the actual balance.
Using this value as a basis for computations will lead to further errors in the integrations.
It should use badgerSett.balance()
instead of badger.balanceOf(address(badgerSett))
to also account for "the balance in the Sett, the Controller, and the Strategy".
π Selected for report: cmichel
5827.0101 USDC - $5,827.01
cmichel
One can withdraw the entire PrizePool
deposit by circumventing the timelock.
Assume the user has no credits for ease of computation:
withdrawWithTimelockFrom(user, amount=userBalance)
with their entire balance. This "mints" an equivalent amount
of timelock
and resets _unlockTimestamps[user] = timestamp = blockTime + lockDuration
.withdrawWithTimelockFrom(user, amount=0)
again but this time withdrawing 0
amount. This will return a lockDuration
of 0
and thus unlockTimestamp = blockTime
. The inner _mintTimelock
now resets _unlockTimestamps[user] = unlockTimestamp
if (timestamp <= _currentTime())
is true, the full users amount is now transferred out to the user in the _sweepTimelockBalances
call.Users don't need to wait for their deposit to contribute their fair share to the prize pool. They can join before the awards and leave right after without a penalty which leads to significant issues for the protocol. It's the superior strategy but it leads to no investments in the strategy to earn the actual interest.
The unlock timestamp should be increased by duration each time, instead of being reset to the duration.
#0 - asselstine
2021-06-25T00:15:07Z
Mitigation:
If a user's timelock balance is non-zero, the prize strategy rejects the ticket burn.
229.3861 USDC - $229.39
cmichel
The ERC20.transfer()
and ERC20.transferFrom()
functions return 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.
It is not checked in BadgerYieldSource.supplyTokenTo
.
Tokens that don't actually perform the transfer and return false
are still counted as a correct transfer.
As the badger
is merely a parameter to the yield source it is not known which token & Sett will end up actually being used.
We recommend using OpenZeppelinβs SafeERC20
versions with the safeTransfer
and safeTransferFrom
functions that handle the return value check as well as non-standard-compliant tokens.
#0 - asselstine
2021-06-24T23:27:48Z
See https://github.com/code-423n4/2021-06-pooltogether-findings/issues/112
It's more of a 1 (Low Risk)
because the subsequent deposit calls will fail. There is no advantage to be gained; the logic is simply poor.
#1 - dmvt
2021-07-31T21:07:46Z
duplicate of #112
cmichel
BadgerYieldSource.redeemToken
: no usage of SafeMath
can lead to overflows here as the amount
parameter is chosen by the attacker.
amount.mul(totalShares) + totalShares
It does most likely not have an impact, we still recommend using SafeMath.
Use SafeMath.
#0 - asselstine
2021-06-24T23:21:24Z
#1 - dmvt
2021-08-24T13:32:20Z
duplicate of #114
π Selected for report: cmichel
1748.103 USDC - $1,748.10
cmichel
The idea of YieldSourcePrizePool_canAwardExternal
seems to be to disallow awarding the interest-bearing token of the yield source, like aTokens, cTokens, yTokens.
"@dev Different yield sources will hold the deposits as another kind of token: such a Compound's cToken. The prize strategy should not be allowed to move those tokens."
However, the code checks _externalToken != address(yieldSource)
where yieldSource
is the actual yield strategy contract and not the strategy's interest-bearing token.
Note that the yieldSource
is usually not even a token contract except for ATokenYieldSource
and YearnV2YieldSource
.
The _canAwardExternal
does not work as expected.
It might be possible to award the interest-bearing token which would lead to errors and loss of funds when trying to redeem underlying.
There doesn't seem to be a function to return the interest-bearing token. It needs to be added, similar to depositToken()
which retrieves the underlying token.
#0 - asselstine
2021-06-25T22:13:53Z
This is an interesting one:
Since yield sources are audited and analyzed, I think this is a pretty low risk. Additionally, not all of the yield sources are tokenized (Badger and Sushi are not), so it isn't a risk for them.
We could have canAwardExternal
on the yield source itself, but it would add gas overhead.
#1 - aodhgan
2021-07-02T16:07:57Z
Could we add an check -
function _canAwardExternal(address _externalToken) internal override view returns (bool) { return _externalToken != address(yieldSource) && _externalToken != address(yieldSource.depositToken()) }
#2 - asselstine
2021-07-05T21:47:00Z
We could add another check, but it's still arbitrary. The point is that the yield source knows what token the prize pool may or may not hold, so without asking the yield source it's just a guess.
Let's leave it as-is
π Selected for report: cmichel
582.701 USDC - $582.70
cmichel
Some parameters of functions are not checked for invalid values:
StakePrizePool.initialize
: address _stakeToken
not checked for non-zero or contractControlledToken.initialize
: address controller
not checked for non-zero or contractPrizePool.withdrawReserve
: address to
not checked for non-zero, funds will be lost when sending to zero addressATokenYieldSource.initialize
: address _aToken, _lendingPoolAddressesProviderRegistry
not checked for non-zero or contractBadgerYieldSource.initialize
: address badgerSettAddr, badgerAddr
not checked for non-zero or contractSushiYieldSource.constructor
: address _sushiBar, _sushiAddr
not checked for non-zero or contractWrong 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 - PierrickGT
2021-06-28T17:51:28Z
ATokenYieldSource PR: https://github.com/pooltogether/aave-yield-source/pull/19
#1 - PierrickGT
2021-07-01T14:50:44Z
BadgerYieldSource PR: https://github.com/pooltogether/badger-yield-source/pull/6
#2 - PierrickGT
2021-07-01T16:10:25Z
SushiYieldSource PR: https://github.com/pooltogether/sushi-pooltogether/pull/16
#3 - PierrickGT
2021-07-01T16:35:15Z
ControlledToken PR: https://github.com/pooltogether/pooltogether-pool-contracts/pull/306
#4 - PierrickGT
2021-07-01T18:08:32Z
StakePrizePool PR: https://github.com/pooltogether/pooltogether-pool-contracts/pull/314
#5 - PierrickGT
2021-07-02T07:53:19Z
@asselstine I'm not sure we want to check for non zero address in the PrizePool withdrawReserve
function since this function is only callable by the Reserve and the owner of the Reserve contract.
https://github.com/pooltogether/pooltogether-pool-contracts/blob/192429c808ad9714e9e05821386eb926150a009f/contracts/reserve/Reserve.sol#L32
https://github.com/pooltogether/pooltogether-pool-contracts/blob/4449bb2e4216511b7187b1ab420118c30af39eb7/contracts/prize-pool/PrizePool.sol#L473
#6 - asselstine
2021-07-05T23:07:40Z
Yeah @PierrickGT I don't think the withdrawReserve
needs to do the check. Many tokens reject on transfer to zero anyway.
#7 - kamescg
2021-07-08T22:43:17Z
LGTM
π Selected for report: cmichel
582.701 USDC - $582.70
cmichel
The ATokenYieldSource.redeemToken
function burns aTokens
and sends out underlying, however, it's used in a reverse way in the code:
The balanceDiff
is used as the depositToken
that is transferred out but it's computed on the aTokens that were burned instead of on the depositToken
received.
It should not directly lead to issues as aTokens are 1-to-1 with their underlying but we still recommend doing it correctly to make the code more robust against any possible rounding issues.
Compute balanceDiff
on the underyling balance (depositToken), not on the aToken.
Subtract the actual burned aTokens from the user shares.
#0 - PierrickGT
2021-06-28T12:24:58Z
I agree that we should compute balanceDiff
on the underlying balance.
Regarding the burn of the user's shares, we should keep it as is to verify first that the user has enough shares. This way if he doesn't, the code execution will revert before funds are withdrawn from Aave.
#1 - PierrickGT
2021-06-28T12:37:16Z
54.8467 USDC - $54.85
cmichel
In BadgerYieldSource.supplyTokenTo
, the badgerSett
variable is accessed four times. If each of these accesses corresponds to an SLOAD
, it'll be better to cache the value once.
#0 - asselstine
2021-06-25T21:43:28Z
135.4239 USDC - $135.42
cmichel
SushiYieldSource
should approve the SushiBar once during initialization with the max value.
This saves gas on every supplyTokenTo
call as the approval can be removed from there.
#0 - asselstine
2021-06-25T21:58:01Z
We considered this, but it's possible for a malicious user to "drain" the approval of the contract, so there would need to be checks to see if approval dropped below a certain level. We opted to leave out the complexity.
#1 - asselstine
2021-06-26T17:54:37Z
Actually, we'll tackle this. We will:
π Selected for report: cmichel
300.9419 USDC - $300.94
cmichel
ATokenYieldSource
should approve the lending contract once during initialization with the max value.
This saves gas on every supplyTokenTo/_depositToAave
call as the approval can be removed from there.
#0 - asselstine
2021-06-25T21:31:43Z
We considered this, but it's possible for a malicious user to "drain" the approval of the contract, so there would need to be checks to see if approval dropped below a certain level. We opted to leave out the complexity.
#1 - asselstine
2021-06-26T17:54:55Z
Actually, we'll tackle this. We will:
#2 - PierrickGT
2021-06-29T16:24:02Z
Add the function to approve max again to the Idle yield source: https://github.com/code-423n4/2021-06-pooltogether/blob/85f8d044e7e46b7a3c64465dcd5dffa9d70e4a3e/contracts/yield-source/IdleYieldSource.sol#L65
π Selected for report: cmichel
300.9419 USDC - $300.94
cmichel
The credit is accrued twice in award
.
The first accrual happens implicitly when calling _mint
through the ControlledToken(controlledToken).controllerMint
call which then performs the PrizePool.beforeTokenTransfer
hook which accrues credit.
Then the explicit accrual is done again. It should be enough to only add the extraCredit
without doing another accrual (calling _updateCreditBalance(..., newBalance= _applyCreditLimit(controlledToken, controlledTokenBalance, uint256(creditBalance.balance).add(credit).add(extra)))
instead).
#0 - asselstine
2021-06-25T21:57:12Z
We could:
beforeTokenTransfer
for minting_accrueCredit
everywhere that _mint
is called, and for award
ensure that we call it before mint and with the extra credit135.4239 USDC - $135.42
cmichel
In PrizePool._updateCreditBalance
the CreditBurned
event is emitted even if nothing was burned.
Not emitting this event when nothing happened can save gas and also seems better semantically.
#0 - aodhgan
2021-06-29T20:57:56Z
135.4239 USDC - $135.42
cmichel
The YieldSourcePrizePool.initializeYieldSourcePrizePool
should use EIP-165 to detect valid yield sources instead of the "hack" with the depositToken
function.
// A hack to determine whether it's an actual yield source (bool succeeded,) = address(_yieldSource).staticcall(abi.encode(_yieldSource.depositToken.selector)); require(succeeded, "YieldSourcePrizePool/invalid-yield-source");
It's better to detect and check for the entire yield source interface instead of just the depositToken
function as many contracts have a similar function.
Use EIP-165.
#0 - asselstine
2021-06-25T22:19:43Z
Severity is 0 (Non-critical)
, as in https://github.com/code-423n4/2021-06-pooltogether-findings/issues/104.
#1 - dmvt
2021-08-24T13:29:14Z
duplicate of #104