Platform: Code4rena
Start Date: 01/07/2021
Pot Size: $100,000 USDC
Total HM: 10
Participants: 7
Period: 7 days
Judge: ghoulsol
Total Solo HM: 4
Id: 17
League: ETH
Rank: 1/7
Findings: 7
Award: $26,071.30
🌟 Selected for report: 9
🚀 Solo Findings: 2
5885.9975 USDC - $5,886.00
cmichel
The safetyCheck
function performs an unsafe subtraction on two uint256 before casting them to int256
.
The subtraction can underflow and the cast to int256
can either fail and revert the transaction (if greater than type(int256).max
), or, fit into an int256
and corrupt the safetyCheck
making it always return false
.
// _ratio - lastRatio[i] are uint256s and underflows _ratio = abs(int256(_ratio - lastRatio[i]));
If the lastRatio[i]
is even just 1 "wei" less than _ratio
, the result will be type(uint256).max
and the cast to int256
will fail due to the size limit of signed integers.
All functions implementing the safetyCheck
will revert and the protocol can become stuck and unusable.
As only the absolute value is relevant the following code should work without having to cast to int256
:
uint256 ratioDiff = _ratio > lastRatio[i] ? _ratio - lastRatio[i] : lastRatio[i] - _ratio;
#0 - kitty-the-kat
2021-07-14T15:32:47Z
low severity Its not a problem in 0.6.12. No plan to change to 0.8.x
#1 - flabble-gro
2021-07-14T20:25:53Z
Duplicate of #6
#2 - ghoul-sol
2021-07-25T21:33:34Z
Duplicate of #6 ergo it's a high risk.
5885.9975 USDC - $5,886.00
cmichel
The safetyCheck
function has several issues that impact how precise the checks are:
only checks if the a/b
and a/c
ratios are within BASIS_POINTS
.
By transitivity b/c
is only within 2 * BASIS_POINTS
if a/b
and a/c
are in range.
For a more precise check whether both USDC and USDT are within range, b/c
must be checked as well.
If a/b
is within range, this does not imply that b/a
is within range.
"inverted ratios, a/b bs b/a, while producing different results should both reflect the same change in any one of the two underlying assets, but in opposite directions"
Example: lastRatio = 1.0
ratio: a = 1.0, b = 0.8
=> a/b = 1.25
, b/a = 0.8
If a/b
was used with a 20% range, it'd be out of range, but b/a
is in range.
_ratio
and lastRatio
are only from Curve. Only _updateRatios
checks the oracle.In addition, check if b/c
is within BASIS_POINTS
.
#0 - kitty-the-kat
2021-07-14T20:37:22Z
Makes strong assumption about the range of possible values - small differences between a and b will result in small differences between a/b and b/a - Extreme cases are handled by emergency. Agree on b/c check
#1 - kitty-the-kat
2021-07-14T20:38:39Z
medium severity - will only cause stop of deposits/withdrawals against curve, work around to put in emergency mode
#2 - ghoul-sol
2021-07-26T03:35:15Z
A possibility of stopping deposits or withdrawals deserves high risk.
1059.4796 USDC - $1,059.48
cmichel
The Chainlink API (latestAnswer
) used in the Buoy3Pool
oracle wrappers is deprecated:
This API is deprecated. Please see API Reference for the latest Price Feed API. Chainlink Docs
It seems like the old API can return stale data. Checks similar to that of the new API using latestTimestamp
and latestRoundare
are needed.
This could lead to stale prices according to the Chainlink documentation:
Add the recommended checks:
( uint80 roundID, int256 price, , uint256 timeStamp, uint80 answeredInRound ) = chainlink.latestRoundData(); require( timeStamp != 0, “ChainlinkOracle::getLatestAnswer: round is not complete” ); require( answeredInRound >= roundID, “ChainlinkOracle::getLatestAnswer: stale data” ); require(price != 0, "Chainlink Malfunction”);
🌟 Selected for report: cmichel
3923.9983 USDC - $3,924.00
cmichel
The two BaseVaultAdaptor.calculateShare
functions computes share = amount.mul(uint256(10)**decimals).div(sharePrice)
uint256 sharePrice = _getVaultSharePrice(); // amount is in "token" decimals, share should be in "vault" decimals share = amount.mul(uint256(10)**decimals).div(sharePrice);
This assumes that the sharePrice
is always in token decimals and that token decimals is the same as vault decimals.
This both happens to be the case for Yearn vaults, but will not necessarily be the case for other protocols.
As this functionality is in the BaseVaultAdaptor
and not in the specific VaultAdaptorYearnV2_032
, consider generalizing the conversion.
Integrating a token where the token or price is reported in a different precision will lead to potential losses as more shares are computed.
The conversion seems highly protocol specific, calculateShare
should be an abstract function like _getVaultSharePrice
, that is implemented in the specific adaptors.
🌟 Selected for report: cmichel
3923.9983 USDC - $3,924.00
cmichel
The protocol computes a factor
when minting (and burning) tokens which is the exchange rate of rebase to base tokens (base supply / total assets value), see GToken.factor()
.
The first user can manipulate this factor such that it always returns 0
.
Example:
DepositHandler.depositGToken
with dollarAmount = 100.0 = 100 * 1e18
, then ctrl.mintGToken(pwrd, msg.sender, 1e18)
calls gt.mint(account, gt.factor(), amount=1e18)
where gt.factor()
returns getInitialBase() = 1e18
because the person is the first minter and it mints amount * factor / _BASE = 1e18
ctrl.mintGToken
call also increases total assets: pnl.increaseGTokenLastAmount(...)
WithdrawHandler
. Because of the withdrawal fee the total assets are only decreased by the post-fee amount (IPnL(pnl).decreaseGTokenLastAmount(pwrd, amount=userBalance - 1, bonus=fee);
), i.e., with a 2% withdrawal fee the total assets stay at 2% of 100$ = 2 * 1e18.GToken.factor()
always returns totalSupplyBase().mul(BASE).div(totalAssets) = 1 * 1e18 / (2 * 1e18) = 0
The resulting factor
is 0 and thus any user deposits by depositGToken
will mint 0 base tokens to the depositor.
This means all deposits and future value accrues to the attacker who holds the only base tokens.
An attacker could even frontrun the first minter to steal their deposit this way.
Uniswap solves a similar problem by sending the first 1000 tokens to the zero address which makes the attack 1000x more expensive. The same should work here, i.e., on first mint (total base supply == 0), lock some of the first minter's tokens by minting ~1% of the initial amount to the zero address instead of to the first minter.
#0 - kitty-the-kat
2021-07-14T15:29:37Z
Known issue which will be handled by ops - low risk as gro protocol is the first depositor
#1 - ghoul-sol
2021-07-26T16:02:32Z
Even though it's a known issue its consequences are significant. Only because it can be mitigated by ops quite easily, I'll degrade it to medium level.
🌟 Selected for report: cmichel
cmichel
The function performs type conversions and subtraction without over-/underflow checks:
uint256 check = abs(int256(_ratio) - int256(chainRatios[i].div(CHAIN_FACTOR)));
We recommend checking if the values fit within the type range first, otherwise revert with a meaningful error message, as well as checking for underflows.
#0 - kitty-the-kat
2021-07-14T20:53:34Z
#6
#1 - ghoul-sol
2021-07-25T21:35:26Z
This is partially a duplicate of #6 but it focuses on low risk issue so I'll record is as a separate (low risk) issue.
588.5998 USDC - $588.60
cmichel
The Insurance.setUnderlyingTokenPercent
function sets the target allocations per vault which is used throughout the code ("delta").
It does not validate that the sum of the underlyingTokensPercents
is 100%, it could be higher or lower.
If the sum is less than 100% it leads to severe issues in parts of the code, for example, it's used in getVaultDeltaForDeposit
to get the sorted vaultIndexes
through this call exposure.sortVaultsByDelta
.
However, exposure.sortVaultsByDelta
has a bug with tracking min and max indexes if they don't add up to 100%, because the else
branch of minDelta
must never be taken.
Example bug:
Assume: unifiedTotalAssets = 100k
, unifiedAssets = [40k, 30k, 30k]
, targetPercents = [30%, 30%, 30%]
.
Then for i = 0
:
delta = 40k - 100k * 30% = 10k > 0
=> sets maxDelta = 10k, maxIndex = 0
Then for i = 1
:
delta = 30k - 100k * 30% = 0 > 0
is false but else
branch with 0 < 0
is false as well => nothing is set
Then for i = 2
:
delta = 30k - 100k * 30% = 0 > 0
is false but else
branch with 0 < 0
is false as well => nothing is set
Resulting in both maxIndex = 0
, minIndex = 0
and thus vaultIndexes = [0, 3, 0]
depositing into the first vault twice and completely skipping the others.
Validate that it adds up to 100%.
Alternatively, fix the minDelta
and maxDelta
starting values (0
is bad) and computation.
#0 - kitty-the-kat
2021-07-14T19:54:51Z
low risk: Insurance.setUnderlyingTokenPercent is set by governance and will be behind a timelock similar issue in #11, which is set to low risk, which I agree with
#1 - ghoul-sol
2021-07-25T21:43:25Z
This requires human error to be an issue. While I agree that best practices recommend data validation, I'd imagine that governance TX would be checked 10 times before sending. I'm agreeing with sponsor, this is low risk.
#2 - ghoul-sol
2021-07-25T21:43:58Z
Duplicate of #11
cmichel
The LogAdaptorToken
and LogAdaptorVault
events of BaseVaultAdaptor
are not used anywhere, also not in the derived classes.
Unused code can hint at programming or architectural errors.
Use it or remove it.
#0 - kitty-the-kat
2021-07-14T20:49:19Z
#47
#1 - ghoul-sol
2021-07-25T21:58:27Z
Duplicate of #47
588.5998 USDC - $588.60
cmichel
Some parameters of functions are not checked for invalid values:
BaseVaultAdaptor.constructor
: The addresses should be checked for non-zero valuesLifeGuard3Pool.constructor
: The addresses should be checked for non-zero valuesBuoy3Pool.constructor
: The addresses should be checked for non-zero valuesPnL.constructor
: The addresses should be checked for non-zero valuesControllable.setController
: Does not check that newController != controller
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 - kitty-the-kat
2021-07-15T11:43:21Z
Low risk/Non critical - Deployment script handles these cases, but good practice to have 0x checks to stop wasting gas and having to redeploy.
cmichel
On single deposits, the getVaultDeltaForDeposit
function is supposed to return 100% allocation for the desired vault to invest in.
However, it always sets 100% allocation to the first vault (index 0) because of using the wrong variable:
uint256[N_COINS] memory vaultIndexes; // ... // uses uninitialized vaultIndexes! // same as investDelta[0] = 10000; investDelta[vaultIndexes[0]] = 10000;
The investDelta
also indicates that one would invest 100% in the first vault even if vaultIndexes
suggests a different priority.
Use _vaultIndexes
(return value of sortVaultsByDelta
) instead of the uninitialized vaultIndexes
.
#0 - kitty-the-kat
2021-07-14T15:48:42Z
investDelta isnt used, variable has been removed to not cause confusion
#1 - flabble-gro
2021-07-14T20:25:34Z
Duplicate of #65
#2 - ghoul-sol
2021-07-26T03:03:25Z
Indeed, investDelta
is returned but not used. Duplicate of #65 so low risk.
🌟 Selected for report: cmichel
1307.9994 USDC - $1,308.00
cmichel
When depositing, a referral can be chosen and the only check is:
account != address(0) && referral != address(0) && referrals[account] == address(0)
One can refer themselves
(From the contracts that are part of this repo, it's not immediately clear what the referrals are used for.) If they are used for anything, rational actors will always refer themselves to maximize profits making the referral system useless.
Whitelist big influencers that are allowed to be used as referrals to avoid everyone referring themselves or another account they control.
#0 - kitty-the-kat
2021-07-14T15:27:19Z
not an issue/non-critical Makes no difference, referrals are calculated offchain and not used for anything on chain
#1 - ghoul-sol
2021-07-26T15:50:39Z
Even if this is calculated off-chain, technically being able to refer ourselves is an issue. Even offchain this needs to be filtered out which is extra work. I'm keeping this as low risk.
🌟 Selected for report: cmichel
210.7126 USDC - $210.71
cmichel
The RebasingGToken.transfer
function logs LogTransfer(msg.sender, recipient, amount)
but the previous super._transfer
call already logs the exact same arguments Transfer(sender, recipient, amount)
.
The LogTransfer
event is not needed for the rebasing token as it is the same as the Transfer
event of the base class.
Removing and not emitting LogTransfer
saves gas on every transfer.
🌟 Selected for report: cmichel
210.7126 USDC - $210.71
cmichel
Allocaiton.calcProtocolExposureDelta
should break out of the loop to save gas after protocolExposedDeltaUsd
is set.
if (protocolExposedDeltaUsd == 0 && protocolExposure[i] > sysState.rebalanceThreshold) { // ...Calculate the delta between exposure and target uint256 target = sysState.rebalanceThreshold.sub(sysState.targetBuffer); protocolExposedDeltaUsd = protocolExposure[i].sub(target).mul(sysState.totalCurrentAssetsUsd).div( PERCENTAGE_DECIMAL_FACTOR ); protocolExposedIndex = i; // @audit break here }