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: 3/30
Findings: 7
Award: $4,768.64
🌟 Selected for report: 9
🚀 Solo Findings: 3
901.7387 USDC - $901.74
kenzo
The settleAuction() function calls withdrawBounty() before setting auctionOngoing = false, thereby allowing reentrancy.
A malicious publisher can bypass the index timelock mechanism and publish new index which the basket's users won't have time to respond to. At worst case, this means setting weights that allow the publisher to withdraw all the basket's underlying funds for himself, under the guise of a valid new index.
POC exploit: Password to both files: "exploit". AttackPublisher.sol , to be put under contracts/contracts/Exploit: https://pastebin.com/efHZjstS ExploitPublisher.test.js , to be put under contracts/test: https://pastebin.com/knBtcWkk
Manual analysis, hardhat.
In settleAuction(), move basketAsERC20.transfer() and withdrawBounty() to the end of the function, conforming with Checks Effects Interactions pattern.
#0 - GalloDaSballo
2021-12-19T15:40:06Z
This is a re-entrancy finding.
There is no denying that the code is vulnerable to re-entrancy
The warden identified the way to exploit re-entrancy by using a malicious bounty token.
I think the finding is valid and the warden has shown how to run re-entrnacy.
That said the POC the warden shows requires calling publishNewIndex
which is a onlyPublisher
function.
This exploit would be contingent on the publisher rugging the basket.
The code is:
Despite the fact that the POC is flawed, I believe this finding highlights a different vector for re-entrancy (bounty token transfers) as such I agree with a high severity
🌟 Selected for report: kenzo
1001.9319 USDC - $1,001.93
kenzo
If a user is minting small amount of shares (like 1 - amount depends on baskets weights), the calculated amount of tokens to pull from the user can be less than 1, and therefore no tokens will be pulled. However the shares would still be minted. If the user does this a few times, he could then withdraw the total minted shares and end up with more tokens than he started with - although a miniscule amount.
User can end up with more tokens than he started with. However, I didn't find a way for the user to get an amount to make this a feasible attack. He gets dust. However he can still get more than he deserves. If for some reason the basket weights grow in a substantial amount, this could give the user more tokens that he didn't pay for.
Add the following test to Basket.test.js. The user starts with 5e18 UNI, 1e18 COMP, 1e18 AAVE, and ends with 5e18+4, 1e18+4, 1e18+4.
it("should give to user more than he deserves", async () => { await UNI.connect(owner).mint(ethers.BigNumber.from(UNI_WEIGHT).mul(1000000)); await COMP.connect(owner).mint(ethers.BigNumber.from(COMP_WEIGHT).mul(1000000)); await AAVE.connect(owner).mint(ethers.BigNumber.from(AAVE_WEIGHT).mul(1000000)); await UNI.connect(owner).approve(basket.address, ethers.BigNumber.from(UNI_WEIGHT).mul(1000000)); await COMP.connect(owner).approve(basket.address, ethers.BigNumber.from(COMP_WEIGHT).mul(1000000)); await AAVE.connect(owner).approve(basket.address, ethers.BigNumber.from(AAVE_WEIGHT).mul(1000000)); console.log("User balance before minting:"); console.log("UNI balance: " + (await UNI.balanceOf(owner.address)).toString()); console.log("COMP balance: " + (await COMP.balanceOf(owner.address)).toString()); console.log("AAVE balance: " + (await AAVE.balanceOf(owner.address)).toString()); await basket.connect(owner).mint(ethers.BigNumber.from(1).div(1)); await basket.connect(owner).mint(ethers.BigNumber.from(1).div(1)); await basket.connect(owner).mint(ethers.BigNumber.from(1).div(1)); await basket.connect(owner).mint(ethers.BigNumber.from(1).div(1)); await basket.connect(owner).mint(ethers.BigNumber.from(1).div(1)); console.log("\nUser balance after minting 1 share 5 times:"); console.log("UNI balance: " + (await UNI.balanceOf(owner.address)).toString()); console.log("COMP balance: " + (await COMP.balanceOf(owner.address)).toString()); console.log("AAVE balance: " + (await AAVE.balanceOf(owner.address)).toString()); await basket.connect(owner).burn(await basket.balanceOf(owner.address)); console.log("\nUser balance after burning all shares:"); console.log("UNI balance: " + (await UNI.balanceOf(owner.address)).toString()); console.log("COMP balance: " + (await COMP.balanceOf(owner.address)).toString()); console.log("AAVE balance: " + (await AAVE.balanceOf(owner.address)).toString()); });
Manual analysis, hardhat.
Add a check to pullUnderlying
:
require(tokenAmount > 0);
I think it makes sense that if a user is trying to mint an amount so small that no tokens could be pulled from him, the mint request should be denied. Per my tests, for an initial ibRatio, this number (the minimal amount of shares that can be minted) is 2 for weights in magnitude of 1e18, and if the weights are eg. smaller by 100, this number will be 101.
#0 - GalloDaSballo
2021-12-18T14:01:48Z
Great find, because this finding shows a clear POC of how to extract value from the system, I agree with medium severity
450.8693 USDC - $450.87
kenzo
A malicious user can listen to the mempool and immediately bond when an auction starts, without aim of settling the auction. As no one can cancel his bond in less than 24h, this will freeze user funds and auction settlement for 24h until his bond is burned and the new index is deleted. The malicious user can then repeat this when a new auction starts.
Denial of service of the auction mechanism. The malicious user can hold the basket "hostage" and postpone or prevent implementing new index. The only way to mitigate it would be to try to front-run the malicious user, obviously not ideal.
publishAllIndex: https://github.com/code-423n4/2021-09-defiProtocol/blob/52b74824c42acbcd64248f68c40128fe3a82caf6/contracts/contracts/Basket.sol#L170
Manual analysis, hardhat.
If we only allow one user to bond, I see no real way to mitigate this attack, because the malicious user could always listen to the mempool and immediately bond when an auction starts and thus lock it. So we can change to a mechanism that allows many people to bond and only one to settle; but at that point, I see no point to the bond mechanism any more. So we might as well remove it and let anybody settle the auction.
With the bond mechanism, a potential settler would have 2 options:
I might be missing something but at the moment I see no detriment to removing the bonding mechanism.
#0 - frank-beard
2021-10-19T17:01:06Z
#1 - GalloDaSballo
2021-12-18T14:08:20Z
I think the fundamental issue here is that there can only be one bonder that rebalances, an elegant solution would be to move to a mapping of bonders that rebalance.
Allowing anyone to bond and rebalance while still allowing the same dynamic of burning their bonds
This would be more in line with the idea of offering bonds for discounts (Olympus Pro) to anyone as long as there's enough underlying
I agree with the finding and believe the severity is appropriate as this effectively slows down the rebalancing by at least 2 days
🌟 Selected for report: kenzo
1001.9319 USDC - $1,001.93
kenzo
While handling the fees, the contract calculates the new ibRatio by dividing by totalSupply. This can be 0 leading to a division by 0.
If everybody burns their shares, in the next mint, totalSupply will be 0, handleFees will revert, and so nobody will be able to use the basket anymore.
Vulnerable line: https://github.com/code-423n4/2021-09-defiProtocol/blob/52b74824c42acbcd64248f68c40128fe3a82caf6/contracts/contracts/Basket.sol#L124 You can add the following test to Basket.test.js and see that it reverts: it("should divide by 0", async () => { await basket.connect(addr1).burn(await basket.balanceOf(addr1.address)); await basket.connect(addr2).burn(await basket.balanceOf(addr2.address));
await UNI.connect(addr1).approve(basket.address, ethers.BigNumber.from(1)); await COMP.connect(addr1).approve(basket.address, ethers.BigNumber.from(1)); await AAVE.connect(addr1).approve(basket.address, ethers.BigNumber.from(1)); await basket.connect(addr1).mint(ethers.BigNumber.from(1));
});
Manual analysis, hardhat.
Add a check to handleFees: if totalSupply= 0, you can just return, no need to calculate new ibRatio / fees. You might want to reset ibRatio to BASE at this point.
#0 - GalloDaSballo
2021-12-18T14:30:50Z
I feel like this can also happen right after the contract was created
mintTo
will call handleFees
which will revert due to division by 0
Finding is valid
🌟 Selected for report: kenzo
1001.9319 USDC - $1,001.93
kenzo
After an auction has started, as time passes and according to the bondTimestamp, newRatio (which starts at 2*ibRatio) gets smaller and smaller and therefore less and less tokens need to remain in the basket. This is not capped, and after a while, newRatio can become smaller than current ibRatio.
If for some reason nobody has settled an auction and the publisher didn't stop it, a malicious user can wait until newRatio < ibRatio, or even until newRatio ~= 0 (for an initial ibRatio of ~1e18 this happens after less than 3.5 days after auction started), and then bond and settle and steal user funds.
These are the vulnerable lines: https://github.com/code-423n4/2021-09-defiProtocol/blob/52b74824c42acbcd64248f68c40128fe3a82caf6/contracts/contracts/Auction.sol#L89:#L99
uint256 a = factory.auctionMultiplier() * basket.ibRatio(); uint256 b = (bondTimestamp - auctionStart) * BASE / factory.auctionDecrement(); uint256 newRatio = a - b; 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 function verifies that pendingTokens[i].balanceOf(basket) >= basketAsERC20.totalSupply() * pendingWeights[i] * newRatio / BASE / BASE
. This is the formula that will be used later to mint/burn/withdraw user funds.
As bondTimestamp increases, newRatio will get smaller, and there is no check on this.
After a while we'll arrive at a point where newRatio ~= 0
, so tokensNeeded = newRatio*(...) ~= 0
, so the attacker could withdraw nearly all the tokens using outputTokens and outputWeights, and leave just scraps in the basket.
Manual analysis, hardhat.
Your needed condition/math might be different, and you might also choose to burn the bond while you're at it, but I think at the minimum you should add a sanity check in settleAuction:
require (newRatio > basket.ibRatio());
#0 - GalloDaSballo
2021-12-19T22:25:25Z
Would need confirmation from the sponsor here (this finding was also submitted on the more recent contest) As discount is part of the protocol (I think)
#1 - GalloDaSballo
2021-12-20T00:49:12Z
@frank-beard is the discount a feature or a bug?
#2 - GalloDaSballo
2021-12-27T00:35:50Z
As per this discussion: https://github.com/code-423n4/2021-10-defiprotocol-findings/issues/51
The finding is valid and of medium severity
🌟 Selected for report: kenzo
333.9773 USDC - $333.98
kenzo
The DeletedNewIndex log emits "publisher", but it might be the auction that called the function. Note: the event is defined as: event DeletedNewIndex(address _publisher); So if you wanted to anyway emit just the publisher, this is not a bug. However as this function call be called from both publisher and auction, I have a feeling you wanted to emit the msg.sender.
Inaccurate data supplied.
Manual analysis
Emit msg.sender instead of publisher.
#0 - GalloDaSballo
2021-12-02T01:12:25Z
The sponsor confirms, finding is valid, severity is proper
4.3799 USDC - $4.38
kenzo
By putting the bool variables declared in Auction one after the other, solidity will pack them and save gas in deployment.
Around 13000 gas can be saved.
Relevant lines: https://github.com/code-423n4/2021-09-defiProtocol/blob/52b74824c42acbcd64248f68c40128fe3a82caf6/contracts/contracts/Auction.sol#L16:#L21 Running hardhat with a test file, for deployment of Auction I see normal price of 1547504, and after packing together 1534543.
Manual analysis, hardhat, hardhat-gas-reporter.
Declare the bool variables together:
bool public override initialized; bool public override auctionOngoing; bool public override hasBonded;
#0 - GalloDaSballo
2021-11-26T17:11:41Z
Duplicate of #109
🌟 Selected for report: leastwood
Also found by: cmichel, csanuragjain, itsmeSTYJ, joeysantoro, kenzo, pauliax
4.3799 USDC - $4.38
kenzo
The function creates and populates a new array to check for duplicates, this is not necessary.
Some amount of gas unnecessarily spent.
The relevant area: https://github.com/code-423n4/2021-09-defiProtocol/blob/52b74824c42acbcd64248f68c40128fe3a82caf6/contracts/contracts/Basket.sol#L56:#L69
Manual analysis, hardhat gas estimator.
Change the check to the following:
for (uint i = 0; i < length; i++) { require(_tokens[i] != address(0)); require(_weights[i] > 0); for (uint256 x = 0; x < i; x++) { require(_tokens[i] != _tokens[x]); } }
#0 - GalloDaSballo
2021-11-26T17:12:23Z
Duplicate of #160
15.5766 USDC - $15.58
kenzo
When using few times an unchanging value from external contract call, the result can be saved and used without recalling the external contract.
Some gas can be saved.
In settleAuction, the basket's totalSupply stays constant through the loop's iterations.
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); }
Manual analysis, hardhat
Save basketAsERC20.totalSupply() to a local variable outside the loop, and use that variable inside the loop.
#0 - GalloDaSballo
2021-11-26T16:44:48Z
Agree with the finding, a STATICCALL will cost you 400 gas, while storing in memory costs only 3 + another 3 for reading the cached value
25.9609 USDC - $25.96
kenzo
In some places where data is discarded such as bondBurn
, part of the data is set to 0 (auctionBonder
), and other parts are not (bondTimestamp
).
Setting unnecessary data back to 0 will save gas.
Almost 2000 gas saved for each variable reset.
In some places, like createBasket
(which only needs to save the proposal's "basket" field after creating the basket), this can save almost 15000 gas.
Places where data is not reset: Factory's createBasket (set all _proposals[idNumber]'s fields to be 0 except basket) https://github.com/code-423n4/2021-09-defiProtocol/blob/52b74824c42acbcd64248f68c40128fe3a82caf6/contracts/contracts/Factory.sol#L112 Basket's changePublisher: (set pendingPublisher.block = 0) https://github.com/code-423n4/2021-09-defiProtocol/blob/52b74824c42acbcd64248f68c40128fe3a82caf6/contracts/contracts/Basket.sol#L141 Basket's changeLicenseFee: (set pendingLicenseFee.block = 0) https://github.com/code-423n4/2021-09-defiProtocol/blob/52b74824c42acbcd64248f68c40128fe3a82caf6/contracts/contracts/Basket.sol#L159 Basket's setNewWeights and deleteNewIndex: (set pendingWeights.tokens and pendingWeights.weights to empty arrays) https://github.com/code-423n4/2021-09-defiProtocol/blob/52b74824c42acbcd64248f68c40128fe3a82caf6/contracts/contracts/Basket.sol#L200 https://github.com/code-423n4/2021-09-defiProtocol/blob/52b74824c42acbcd64248f68c40128fe3a82caf6/contracts/contracts/Basket.sol#L212 Auction's killAuction: (set auctionStart = 0) https://github.com/code-423n4/2021-09-defiProtocol/blob/52b74824c42acbcd64248f68c40128fe3a82caf6/contracts/contracts/Auction.sol#L44 Auction's settleAuction: (set bondTimestamp, auctionBonder = 0) https://github.com/code-423n4/2021-09-defiProtocol/blob/52b74824c42acbcd64248f68c40128fe3a82caf6/contracts/contracts/Auction.sol#L107 Auction's bondBurn: (set bondTimestamp = 0) https://github.com/code-423n4/2021-09-defiProtocol/blob/52b74824c42acbcd64248f68c40128fe3a82caf6/contracts/contracts/Auction.sol#L120 Auction's withdrawBounty: (set bounty.token, bounty.token = 0) https://github.com/code-423n4/2021-09-defiProtocol/blob/52b74824c42acbcd64248f68c40128fe3a82caf6/contracts/contracts/Auction.sol#L148
Manual analysis, hardhat.
Detailed above.
#0 - GalloDaSballo
2021-11-26T17:07:04Z
Cannot confirm the amount refunded as there's no POC
However, in theory, setting back to 0 will produce a refund, as of the latest Ethereum Version the refund can be up to 1/5 of all gas used: https://eips.ethereum.org/EIPS/eip-3529
It's very likely that setting back a value in storage to 0 will produce the full refund
25.9609 USDC - $25.96
kenzo
When declared, variables are already initialized to 0, so there is no need to do so explicitly.
Around 2000 gas for each variable.
ownerSplit in Factory: https://github.com/code-423n4/2021-09-defiProtocol/blob/52b74824c42acbcd64248f68c40128fe3a82caf6/contracts/contracts/Factory.sol#L21
Manual analysis
Remove the redundant line.
#0 - GalloDaSballo
2021-11-26T17:10:20Z
Agree with finding, as per the yellowpaper the actual cost should be: 2900, as per EIP2200 the actual cost (since the value is the same) should be 800 https://ethereum.stackexchange.com/questions/90318/does-an-sstore-where-the-new-value-is-the-same-as-the-existing-value-cost-gas