Platform: Code4rena
Start Date: 16/09/2021
Pot Size: $50,000 USDC
Total HM: 26
Participants: 30
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 17
Id: 36
League: ETH
Rank: 2/30
Findings: 4
Award: $4,800.92
π Selected for report: 8
π Solo Findings: 1
π Selected for report: cmichel
3339.7729 USDC - $3,339.77
cmichel
Note that the Basket
contract approved the Auction
contract with all tokens and the settleAuction
function allows the auction bonder to transfer all funds out of the basket to themselves.
The only limiting factor is the check afterwards that needs to be abided by. It checks if enough tokens are still in the basket after settlement:
// this is the safety check if basket still has all the tokens after removing arbitrary amounts for (uint256 i = 0; i < pendingWeights.length; i++) { uint256 tokensNeeded = basketAsERC20.totalSupply() * pendingWeights[i] * newRatio / BASE / BASE; require(IERC20(pendingTokens[i]).balanceOf(address(basket)) >= tokensNeeded); }
The bonder can pass in any inputTokens
, even malicious ones they created.
This allows them to re-enter the settleAuction
multiple times for the same auction.
Calling this function at the correct time (such that bondTimestamp - auctionStart
makes newRatio < basket.ibRatio()
), the attacker can drain more funds each time, eventually draining the entire basket.
Assume that the current basket.ibRatio
is 1e18
(the initial value).
The basket publisher calls basket.publishNewIndex
with some tokens and weights.
For simplicity, assume that the pending tokens
are the same as tokens as before, only the weights are different, i.e., this would just rebalance the portfolio.
The function call then starts the auction.
The important step to note is that the tokensNeeded
value in settleAuction
determines how many tokens need to stay in the basket
.
If we can continuously lower this value, we can keep removing tokens from the basket
until it is empty.
The tokensNeeded
variable is computed as basketAsERC20.totalSupply() * pendingWeights[i] * newRatio / BASE / BASE
.
The only variable that changes in the computation when re-entering the function is newRatio
(no basket tokens are burned, and the pending weights are never cleared).
Thus if we can show that newRatio
decreases on each re-entrant call, we can move out more and more funds each time.
After some time, the attacker calls bondForRebalance
. This determines the bondTimestamp - auctionStart
value in settleAuction
.
The attack is possible as soon as newRatio < basket.ibRatio()
.
For example, using the standard parameters the calculation would be:
// a = 2 * ibRatio uint256 a = factory.auctionMultiplier() * basket.ibRatio(); // b = (bondTimestamp - auctionStart) * 1e14 uint256 b = (bondTimestamp - auctionStart) * BASE / factory.auctionDecrement(); // newRatio = a - b = 2 * ibRatio - (bondTimestamp - auctionStart) * 1e14 uint256 newRatio = a - b;
With our initial assumption of ibRatio = 1e18
and calling bondForRebalance
after 11,000 seconds (~3 hours) we will get our result that newRatio
is less than the initial ibRatio
:
newRatio = a - b = 2 * 1e18 - (11000) * 1e14 = 2e18 - 1.1e18 = 0.9e18 < 1e18 = basket.ibRatio
This seems to be a reasonable value (when the pending tokens and weights are equal in value to the previous ones) as no other bonder would want to call this earlier such when
newRatio > basket.ibRatio
as they would put in more total value in tokens as they can take out of the basket.
The attacker creates a custom token attackerToken
that re-enters the Auction.settleAuction
function on transferFrom
with parameters we will specify.
They call settleAuction
with inputTokens = [attackerToken]
to re-enter several times.
In the inner-most call where newRatio = 0.9e18
, they choose the inputTokens
/outputTokens
parameters in a way to pass the initial require(IERC20(pendingTokens[i]).balanceOf(address(basket)) >= tokensNeeded);
check - transferring out any other tokens of basket
with outputTokens
.
The function will continue to run and call basket.setNewWeights();
and basket.updateIBRatio(newRatio);
which will set the new weights (but not clear the pending ones) and set the new basket.ibRatio
.
Execution then jumps to the 2nd inner call after the IERC20(inputTokens[i]=attackerToken).safeTransferFrom(...)
and has the chance to transfer out tokens again.
It will compute newRatio
with the new lowered basket.ibRatio
of 0.9e18
: newRatio = a - b = 2 * 0.9e18 - 1.1e18 = 0.7e18
.
Therefore, tokensNeeded
is lowered as well and the attacker was allowed to transfer out more tokens having carefully chosen outputWeights
.
This repeats with newRatio = 0.3
.
The attack is quite complicated and requires carefully precomputing and then setting the parameters, as well as sending back the bondAmount
tokens to the auction
contract which are then each time transferred back in the function body.
But I believe this should work.
The basket funds can be stolen.
Add re-entrancy checks (for example, OpenZeppelin's "locks") to the settleAuction
function.
#0 - GalloDaSballo
2021-12-19T15:25:19Z
Let's dissect the finding to prove whether it's valid or not.
First of all, we do have the pre-conditions for re-entrancy:
check effect interaction
pattern)So in any case this is a report for reEntrancy (medium severity)
However, the warden is showing a specific attack vector that, if proven, allows to steal the majority of funds from the basket.
Let's investigate (with a single re-entrant call example):
The require checks at the top pass, as we're rebalancing the basket, we'll make sure to use an additional inputToken (malicious token), that will call settleAuction
again.
This second call (Call B), will execute as normal, extracting the correct amount of value in return for rebalancing, the new ibRatio is 0.9 as shown by the warden POC.
Call B ends by setting ibRatio to 0.9, and hasBonded
to false (which will cause a revert if you try to perform this without re-entrancy)
However, we have already entered and Call A can resume, it now has ibRatio set to 0.9 which allows it to further extract value (as tokensNeeded
decreases as ibRatio decreases)
This can be extended to have further re-entrant calls and can be effectively executed until the basket is hollowed out
This is a Miro Board to highlight the dynamics of the exploit: https://miro.com/app/board/uXjVOZk4gxw=/?invite_link_id=246345621880
Huge props to the warden, brilliant find!
#1 - GalloDaSballo
2021-12-19T15:28:11Z
For mitigation:
131.4735 USDC - $131.47
cmichel
The ERC20.approve()
function returns a boolean value indicating success. This parameter needs to be checked for success.
The Basket.approveUnderlying
function does not check the return value of the IERC20(tokens[i]).approve
call.
Neither does it work with tokens that don't return a boolean.
Tokens that don't actually grant the allowance and return false
won't work.
Also, tokens that don't correctly implement the latest EIP20 spec will not work as they revert the transaction because of the missing return value.
We recommend using OpenZeppelinβs SafeERC20
versions with the safeApprove
function that handles the return value check as well as non-standard-compliant tokens.
#0 - frank-beard
2021-09-30T18:44:41Z
duplicate of #260
#1 - GalloDaSballo
2021-12-19T15:53:37Z
Duplicate of #35
π Selected for report: cmichel
333.9773 USDC - $333.98
cmichel
As I understand it, the handleFees
should rebalance the ibRatio
such that the licenseFee
is taken on all deposits.
I.e., a yearly licenseFee
of 12% (0.12e18
) should result in an ibRatio
of 1.0 - 0.12 = 0.88
after one year.
Because with this value, burning the initial 1e18
basket tokens will call pushUnderlying(1e18)
and pay out 88%
of the initial token deposit.
This is correct when calling handleFees
once per year, but when it's called every week/month it will not be exact.
This is similar to simple interest vs compound interest.
The individual updates compound wrong.
For example, assume handleFees
is called once every 6 months (feePct = 6/12 * 12% = 0.06e18
).
# assume startSupply of 1e18 (see factory), and initial ibRatio of 1e18 (BASE) # and no other deposits in between licenseFee = 12% = 0.12e18; # mints "fee" in total, split into publisher and factory # call it after 6 months once => startSupply = 1e18 fee = startSupply * 0.06 / (1.0 - 0.06) = 1.0 * 0.063829787 newIbRatio = 1.0 * 1.0 / (1.063829787) = 0.94 => new startSupply = 1.063829787e18 # call it after another 6 months fee = startSupply * 0.06 / (1.0 - 0.06) = 1.063829787 * 0.063829787 = 0.067904029 newIbRatio = ibRatio * 1.063829787 / (1.063829787 + 0.067904029) = 0.94 * 1.063829787 / (1.063829787 + 0.067904029) = 0.8836 > 0.88
The fees are underestimated.
It's probably best to just leave it like this as a compounding formula would be too complex and gas-intensive.
#0 - GalloDaSballo
2021-12-05T16:27:07Z
Finding is valid, would recommend updating comments to reflect this property and consider if it's possible to refactor the code to properly account for time expired
π Selected for report: cmichel
333.9773 USDC - $333.98
cmichel
The Basket.changePublisher
function is used for both setting a new pending publisher as well as accepting the publisher transfer from the pending publisher.
Once a pending publisher has been set, no other publisher can be set and if the pending publisher does not accept it, the contract is locked out of setting any other publishers. Setting a wrong publisher can naturally occur.
Add an option to set a new pending publisher even if there already is a pending publisher.
#0 - GalloDaSballo
2021-12-05T16:31:18Z
In line with zero address check validation, given the fact that:
the lack of 2 step to claim an address can be viewed as a feature that is missing / should be added
Will set all similar issues to a low severity
π Selected for report: cmichel
333.9773 USDC - $333.98
cmichel
A basket creator can specify a custom token that allows them to re-enter in Factory.createBasket
.
As new auction and basket contracts are created every time, no cross-basket issues arise.
However, note that the official BasketCreated
event is emitted for all of them, but only the last basket is stored for the idNumber
.
This could lead to issues for some backend / frontend scripts that use the BasketCreated
event.
Set _proposals[idNumber].basket = address(newBasket);
immediately after the newBasket
contract clone has been created to avoid the re-entrancy.
#0 - GalloDaSballo
2021-12-05T16:35:31Z
Given the fact that the warden hasn't identified any high or mid risk exploit via re-entrancy, I'll keep the finding as low severity. (Lack of poc)
However notice that re-entrancy could be used for high severity exploits, particular attention should be put for the functions that handle transfers before updating state, see: https://github.com/code-423n4/2021-09-defiProtocol/blob/52b74824c42acbcd64248f68c40128fe3a82caf6/contracts/contracts/Auction.sol#L140
150.2898 USDC - $150.29
cmichel
The ONE_DAY
constant in Auction
estimates the blocks per day as 4 * 60 * 24
which would equal an average block time of 15 seconds.
The same issue exists for the Basket.TIMELOCK_DURATION
The auctions and timelocks are shorter than expected by the protocol developer, they are off by ~13%.
Use a better estimate for your network. At the moment, a more accurate block time for the Ethereum mainnet would be 13.2s, see blocktime.
Use 86400 / 13.2 = 6545 > 5760
#0 - GalloDaSballo
2021-12-05T16:37:50Z
In stark comparison to other wardens, this finding speaks about an inaccuracy in the amount of blocks per day.
I agree with the finding, from my math the block time should be around 13.5 but we agree that there's rounding here
π Selected for report: cmichel
57.691 USDC - $57.69
cmichel
The if
-branch of Basket.changeLicenseFee
function ensures that pendingLicenseFee.licenseFee == newLicenseFee
which means setting licenseFee = newLicenseFee
is equivalent to licenseFee = pendingLicenseFee.licenseFee
but the former saves an expensive storage load operation.
#0 - GalloDaSballo
2021-11-28T17:45:42Z
Great little finding!
π Selected for report: cmichel
57.691 USDC - $57.69
cmichel
The if
-branch of Basket.changePublisher
function ensures that pendingPublisher.publisher == newPublisher
which means setting publisher = newPublisher
is equivalent to publisher = pendingPublisher.publisher
but the former saves an expensive storage load operation.
#0 - GalloDaSballo
2021-11-28T17:45:59Z
Valid finding
π Selected for report: cmichel
57.691 USDC - $57.69
cmichel
The Auction.initialize
function accepts a factory_
parameter.
However, as this contract is always initialized directly from the factory, it can just use msg.sender
.
Removing the additional factory_
parameter and using msg.sender
instead will save gas.
This is already done for the other Basket
contract.
#0 - GalloDaSballo
2021-11-28T17:47:44Z
Valid finding
π Selected for report: leastwood
Also found by: cmichel, csanuragjain, itsmeSTYJ, joeysantoro, kenzo, pauliax
4.3799 USDC - $4.38
cmichel
The Basket.validateWeights
function checks the uniqueness of the tokens by iterating over all tokens for each token.
This runs in O(_tokens.length^2)
and is very inefficient as the number of tokens increases.
Sort the tokens off-chain and provide them already in a sorted (ascending) way.
Then the validateWeights
function only needs to verify that the tokens are indeed strictly sorted which runs in linear time:
for (uint i = 0; i < length; i++) { require(_tokens[i] != address(0)); require(_weights[i] > 0); // check sorted to ensure uniqueness if (i > 0) { require(_tokens[i] > _tokens[i - 1]); } }
#0 - GalloDaSballo
2021-11-26T01:24:40Z
Agree with the findings, but want to point that the suggested improvement requires full trust of the input, which may not be a good idea
#1 - GalloDaSballo
2021-11-26T16:43:14Z
Duplicate of #160