Platform: Code4rena
Start Date: 15/07/2021
Pot Size: $80,000 USDC
Total HM: 28
Participants: 18
Period: 7 days
Judge: ghoulsol
Total Solo HM: 18
Id: 20
League: ETH
Rank: 1/18
Findings: 15
Award: $24,581.02
π Selected for report: 13
π Solo Findings: 8
π Selected for report: cmichel
3942.0057 USDC - $3,942.01
cmichel
The SynthVault.withdraw
function does not claim the user's rewards.
It decreases the user's weight and therefore they are forfeiting their accumulated rewards.
The synthReward
variable in _processWithdraw
is also never used - it was probably intended that this variable captures the claimed rewards.
Usually, withdrawal functions claim rewards first but this one does not. A user that withdraws loses all their accumulated rewards.
Claim the rewards with the user's deposited balance first in withdraw
.
#0 - verifyfirst
2021-07-22T04:04:42Z
We understand there is a risk of losing unclaimed rewards if a user directly interacts with the synth-vault and not the DAPP. This is a design choice to protect the withdrawal function. We affirm the synthReward variable to be culled.
cmichel
The Pool._approve
function performs a no-op if the allowance is currently set to type(uint256).max
.
This leads to the issue that approvals cannot be changed anymore once they are set to the max value.
Imagine someone approving an operator with the max value but their key is compromised.
The approval cannot be revoked anymore. This is especially bad as any approveAndCall
call sets the max approval.
The correct check to save gas should be if (_allowances[owner][spender] != amount) { update }
.
#0 - SamusElderg
2021-07-22T03:26:30Z
Duplicate of #29
cmichel
Some tokens (like USDT) don't correctly implement the EIP20 standard and their transfer
/transferFrom
function return void
instead of a success boolean. Calling these functions with the correct EIP20 function signatures will always revert.
This is generally not an issue when the verified, deployed, reverting Pool token is used, but it can be for other tokens.
transferFrom
occurences where arbitrary tokens are used:
Router._handleTransferIn
(iBEP20(_token).transferFrom
)DAO._handleTransferIn
poolFactory._handleTransferIn
Synth._handleTransferIn
transfer
occurences where arbitrary tokens are used:
Router._handleTransferOut
(iBEP20(_token).transfer
)Router.zapLiquidity
Router.removeLiquiditySingle
Pool.removeForMember
Pool.swapTo
Tokens that don't correctly implement the latest EIP20 spec, like USDT, 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 safeTransfer
and safeTransferFrom
functions that handles the return value check as well as non-standard-compliant tokens.
#0 - SamusElderg
2021-07-22T02:57:24Z
Duplicate of #8
cmichel
The Pool.removeLiquiditySingle
function redeems liquidity tokens for underlying to the router contract.
If toBase == true
, it then tries to convert these to base tokens.
However, only the swapped token -> BASE amount is sent to the user, the redeemed part is still in the router and can be stolen by any other user.
Send the entire BASE
amount of the router to the member
instead of only the swapped amount.
#0 - SamusElderg
2021-07-22T03:45:52Z
Duplicate of #133
π Selected for report: cmichel
3942.0057 USDC - $3,942.01
cmichel
The SynthVault._deposit
function adds weight
for the user that depends on the spot value of the deposit synth amount in BASE
.
This spot price can be manipulated and the cost of manipulation is relative to the pool's liquidity.
However, the reward (see calcReward
) is measured in BASE tokens unrelated to the pool.
Therefore, if the pool's liquidity is low and the reward reserve is high, the attack can be profitable:
iSYNTH(_synth).LayerONE()
pool by dripping a lot of BASE
into it repeatedly (sending lots of smaller trades is less costly due to the path-independence of the continuous liquidity model). This increases the BASE
per token
price.SynthVault.depositForMember
and deposit a small amount of synth token. The iUTILS(_DAO().UTILS()).calcSpotValueInBase(iSYNTH(_synth).LayerONE(), _amount)
will return an inflated weight due to the price.BASE
into the pool and repeat the depositstoken
to the pool to rebalance itThe user's weight
is now inflated compared to the deposited / locked-up amount and they can claim a large share of the rewards.
The cost of the attack depends on the pool's liquidity and the profit depends on the reserve. It could therefore be profitable under certain circumstances.
Track a TWAP price of the synth instead, store the deposited synths instead, and compute the weight & total weight on the fly based on the TWAP * deposit amount instead of at the time of deposit.
#0 - verifyfirst
2021-07-22T03:59:51Z
There is already a discussion in place to change spot rate to swap rate calculation for weights.
cmichel
The Router
(and Pool
) does not implement any slippage checks with comparing the swap / liquidity results with a minimum swap / liquidity value.
Users can be frontrun and receive a worse price than expected when they initially submitted the transaction. There's no protection at all, no minimum return amount or deadline for the trade transaction to be valid which means the trade can be delayed by miners or users congesting the network, as well as being sandwich attacked - ultimately leading to loss of user funds.
Add some sort of protection for the user such that they receive their desired amounts. Add a minimum return amount for all swap and liquidity provisions/removals to all Router
functions.
#0 - verifyfirst
2021-07-22T04:07:37Z
Duplicate of #85
π Selected for report: cmichel
3942.0057 USDC - $3,942.01
cmichel
The Router.addDividend
function tells the reserve to send dividends to the pool depending on the fees.
normalAverageFee
variable that determines the pool dividends can be set to zero by the attacker by trading a single wei in the pool arrayFeeSize
(20) times (use buyTo
). The fees of the single wei trades will be zero and thus the normalAverageFee
will also be zero as, see addTradeFee
.normalAverageFee
to this trade's fee. The feeDividend
is then computed as _fees * dailyAllocation / (_fees + normalAverageFee) = _fees * dailyAllocation / (2 * _fees) = dailyAllocation / 2
. Half of the dailyAllocation
is sent to the pool.dailyAllocation
gets smaller but it's still possible to withdraw almost all of it.The reserve can be emptied by the attacker.
Counting only the last 20 trades as a baseline for the dividends does not work. It should probably average over a timespan but even that can be gamed if it is too short. I think a better idea is to compute the dividends based on volume traded over a timespan instead of looking at individual trades.
#0 - verifyfirst
2021-07-23T05:39:59Z
Only very deep pools will be curated for dividends. Variables can be changed reactively to alter the dividends. Whilst we were aware of this and feel the attack is limited its sparked some discussion for some new ideas to solve this.
#1 - ghoul-sol
2021-08-08T22:23:51Z
Keeping high risk as the report is valid
116.3857 USDC - $116.39
cmichel
Several contracts implement an onlyDAO
modifier which, as the name suggests, should only authorize the function to be executed by the DAO.
However, some implementations are wrong and either allow the DAO or the deployer to execute, or even only the deployer:
Incorrect implementations:
BondVault.onlyDAO
: allows deployer + DAODAO.onlyDAO
: allows deployerDAOVault.onlyDAO
: allows deployer + DAOpoolFactory.onlyDAO
: allows deployer + DAORouter.onlyDAO
: allows deployer + DAOSynth.onlyDAO
: allows deployersynthFactory.onlyDAO
: allows deployersynthVault.onlyDAO
: allows deployer + DAOIn all of these functions, the deployer may execute the function as well which is a centralization risk.
The deployer can only sometimes be purged, as in synthFactory
, in which case nobody can execute these functions anymore.
Rename it to onlyDeployer
or onlyDeployerOrDAO
depending on who has access.
#0 - verifyfirst
2021-07-23T13:52:33Z
This is by design a choice. However, there are current discussions around renaming the high level access modifiers to be more descriptive in their purpose.
#1 - ghoul-sol
2021-08-08T19:22:34Z
This is a non-critical issue because there's no in-code bugs, it's rather error-prone naming.
#2 - ghoul-sol
2021-08-08T19:29:11Z
On second look, I'll keep it a medium risk as deployer cannot be purged in all contracts which introduces systemic risk.
π Selected for report: cmichel
1182.6017 USDC - $1,182.60
cmichel
The protocol differentiates between public pool creations and private ones (starting without liquidity).
However, this is not effective as anyone can just flashloan the required initial pool liquidity, call PoolFactory.createPoolADD
, receive the LP tokens in addForMember
and withdraw liquidity again.
Consider burning some initial LP tokens or taking a pool creation fee instead.
#0 - SamusElderg
2021-07-26T02:33:21Z
Whilst we were aware of this (more of a deterrent than prevention) contributors have discussed some methods of locking this liquidity in and making it at least flash loan resistant. For instance, a withdraw-lock (global by pool) for 7 days after the pool's genesis so that no user can withdraw liquidity until 7 days have passed. There are other ideas floating around too; but regardless this issue will be addressed in some way prior to launch
π Selected for report: cmichel
1182.6017 USDC - $1,182.60
cmichel
The Pool.approveAndCall
function approves the recipient
contract with the max value instead of only the required amount
.
For safety, the approval should not be set to the max value, especially if the amount that the contract may use is already known in this call, like this is the case for approveAndCall
.
Only approve amount
.
π Selected for report: cmichel
1182.6017 USDC - $1,182.60
cmichel
The Synth.approveAndCall
function approves the recipient
contract with the max value instead of only the required amount
.
For safety, the approval should not be set to the max value, especially if the amount that the contract may use is already known in this call, like this is the case for approveAndCall
.
Only approve amount
.
π Selected for report: cmichel
1182.6017 USDC - $1,182.60
cmichel
The SynthVault.harvestSingle
function can be used to mint & deposit synths without using a lockup.
An attacker sends BASE
tokens to the pool and then calls harvestSingle
.
The inner iPOOL(_poolOUT).mintSynth(synth, address(this));
call will mint synth tokens to the vault based on the total BASE
balance sent to the pool, including the attacker's previous transfer.
They are then credited the entire amount to their weight
.
This essentially acts as a (mint +) deposit without a lock-up period.
Sync the pool before sending BASE
to it through iRESERVE(_DAO().RESERVE()).grantFunds(reward, _poolOUT);
such that any previous BASE
transfer is wasted.
This way only the actual reward's weight is increased.
#0 - verifyfirst
2021-07-23T13:45:16Z
Although this is true, the attacker is not benefiting from any gain. They are only minting extra synths into the synthVault into their weight. It is no different to - minting and then staking into the vault.
#1 - SamusElderg
2021-07-28T09:14:57Z
@verifyfirst in my opinion this one should be confirmed and the recommended mitigation also makes sense; any attempt to send in BASE by a bad actor can be attributed to the existing LPers instead
cmichel
The PoolFactory.curatedPoolCount
iterates over all arrayPools
.
Anyone can push to this array by creating a pool making this attack easy to execute for an attacker.
The transactions can fail if the arrays get too big and the transaction would consume more gas than the block limit. No pool can then be curated anymore.
Count the pool in a separate variable poolCount
increasing/decreasing in add/removeCuratedPool
.
This also saves lots of gas for users creating pools.
#0 - SamusElderg
2021-07-22T02:42:22Z
Duplicate of #6
#1 - ghoul-sol
2021-08-08T21:46:26Z
medium risk per #6
π Selected for report: cmichel
1182.6017 USDC - $1,182.60
cmichel
BondVault
deposits match any deposited token
amount with the BASE
amount to provide liquidity, see Docs and DAO.handleTransferIn
.
The matched BASE
amount is the swap amount of the token
trade in the pool.
An attacker can manipulate the pool and have the DAO
commit BASE
at bad prices which they then later buys back to receive a profit on BASE
. This is essentially a sandwich attack abusing the fact that one can trigger the DAO
to provide BASE
liquidity at bad prices:
BASE
into it repeatedly (sending lots of smaller trades is less costly due to the path-independence of the continuous liquidity model). This increases the token
per BASE
price.DAO.bond(amount)
to drip tokens
into the DAO
and get matched with BASE
tokens to provide liquidity. (Again, sending lots of smaller trades is less costly.) As the pool contains low token
but high BASE
reserves, the spartaAllocation = _UTILS.calcSwapValueInBase(_token, _amount)
swap value will be high. The contract sends even more BASE to the pool to provide this liquidity.tokens
from 1. As a lot more BASE
tokens are in the reserve now due to the DAO sending it, the attacker will receive more BASE
as in 1. as well, making a profitThe DAO's Bond allocation can be stolen.
The cost of the attack is the trade fees in 1. + 3. as well as the tokens used in 2. to match the BASE
, but the profit is a share on the BASE
supplied to the pool by the DAO in 2.
Track a TWAP spot price of the TOKEN <> BASE
pair and check if the BASE
incentive is within a range of the TWAP. This circumvents that the DAO
commits BASE
at bad prices.
#0 - verifyfirst
2021-07-23T05:24:38Z
Implementing a TWAP needs more discussion and ideas to help with price manipulation. Attacking BOND is limited by its allocation, time and the fact that it's locked over 6months.
#1 - ghoul-sol
2021-08-08T22:22:07Z
Per sponsor comment making this medium risk
cmichel
poolFactory.addCuratedPool
does not fail if the pool is already curated.
It also emits the AddCuratePool
event again
The AddCuratePool
should not be fired if the pool is already curated.
Fail if the pool is already curated.
#0 - verifyfirst
2021-07-26T01:34:49Z
Check needs to be added for this issue
#1 - verifyfirst
2021-07-26T01:53:55Z
Duplicate of #137
cmichel
The Synth.burnSynth
function can be called by anyone but it should only be called from a pool, like mintSynth
.
An attacker can attempt to burn tokens in the synth contract, but it should fail as the LP debt / balance is always zero for non-pools.
Add the onlyPool
modifier to burnSynth
.
#0 - SamusElderg
2021-07-26T01:37:20Z
Duplicate of #70
cmichel
The DAO.claimAllForMember
function iterates over all listedBondAssets
.
The transactions can fail if the arrays get too big and the transaction would consume more gas than the block limit.
Keep the number of listed bond arrays small.
#0 - SamusElderg
2021-07-25T12:02:07Z
Duplicate of #37
177.3903 USDC - $177.39
cmichel
Some parameters of functions are not checked for invalid values:
PoolFactory.constructor
: Validate _base
and _wbnb
to be contracts or at least non-zeroA 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 - ghoul-sol
2021-08-08T19:56:50Z
Keeping this a low risk. Not sure why sponsor disputed.
π Selected for report: cmichel
394.2006 USDC - $394.20
cmichel
The Pool.removeLiquiditySingle
function redeems liquidity tokens for underlying to the router contract in case of the token
being the zero address.
This works if the underlying token is actually WBNB
but if the pool token is different and the user accidentally inserted 0
as the token
address, it tries to swap a zero-balance WBNB to BASE
and the redeemed tokens are stuck.
If token == 0
add a check for pool.token == WBNB
such that it is ensured that the pool's token is actually WBNB
.
#0 - verifyfirst
2021-07-22T03:44:32Z
In theory this is correct, however, solidity validates function parameters to be legitimate and in this instance, 0 or "0" is not a valid address.
#1 - ghoul-sol
2021-08-08T20:03:22Z
I'll keep the issue as it's technically correct.
π Selected for report: cmichel
394.2006 USDC - $394.20
cmichel
The Utils.calcAsymmetricValueToken
function is not used.
Unused code can hint at programming or architectural errors.
Use it or remove it.
cmichel
The PoolFactory.createPoolADD
function allows creating a pool with BNB (token = 0
) which it'll map to WBNB
(_token
).
However, the call will fail when trying to transfer WBNB
(or BNB) from the user as _handleTransferIn(token, inputToken, pool)
is called with the zero address.
Call it with _token
instead and try to transfer WBNB
from the user, or check in _handleTransferIn
if _token == 0
, that msg.value == _amount
and then do WBNB.deposit
to pool.
#0 - SamusElderg
2021-07-22T03:13:52Z
Duplicate of #7
π Selected for report: cmichel
394.2006 USDC - $394.20
cmichel
The vote weight is determined by the DAOVault
and BondVault
weight (voteWeight = _DAOVAULT.getMemberWeight(msg.sender) + _BONDVAULT.getMemberWeight(msg.sender)
).
The weight in these vaults is the deposited LP token.
The BondVault
however pays for the BASE
part itself (see DAO.handleTransferIn
), therefore one only needs to deposit tokens
and the DAO
matches the swap value.
Therefore, it's possible to manipulate the pool, deposit only a small amount of tokens
(receiving a large amount of matching BASE
by the DAO) and receive a large amount of LP tokens this way.
attack can be profitable:
BASE
into it repeatedly (sending lots of smaller trades is less costly due to the path-independence of the continuous liquidity model). This increases the BASE
per token
price.DAO.bond(amount)
to drip tokens
into the DAO
and get matched with BASE
tokens to provide liquidity. (Again, sending lots of smaller trades is less costly.) As the LP minting is relative to the manipulated low token
reserve, a lot of LP units are minted for a low amount of tokens
, leading to receiving large weight.grantFunds
tokens
from 1. This might incur a loss.The cost of the attack is the swap fees from the manipulation of 1. and 4. plus the (small due to manipulation) amount of tokens required to send in 2.
The profit can be the entire reserve amount which is unrelated to the pools (plus reclaiming lots of LP units over the span of the BondVault
era).
The attack can be profitable under certain circumstances of:
I don't think the attack would be feasible if we couldn't get the DAO
to commit the lion's share of the BASE
required to acquire LP units through the BondVault
incentives.
#0 - verifyfirst
2021-07-22T04:31:09Z
Warden must understand the bond program is extremely limited in time and amount of sparta allocated through the DAO. If the attacker was able to obtain the entire bond allocation and weight is in sparta terms, the opportunity to attack would scale along with the pool depth and therefor total weight scales up along with the bond. Grant funds will be capped at a % of the reserve.
#1 - ghoul-sol
2021-08-08T22:20:01Z
Per sponsor comment, making this low risk