Platform: Code4rena
Start Date: 25/11/2021
Pot Size: $80,000 USDC
Total HM: 35
Participants: 32
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 27
Id: 59
League: ETH
Rank: 2/32
Findings: 6
Award: $7,451.02
🌟 Selected for report: 4
🚀 Solo Findings: 3
gzeon
Timelock governor can change delay and gracePeriod at will, which render the timelock useless.
function setDelay(uint256 _delay) public onlyRole(GOVERNOR_ROLE, "Must have timelock role")
function setGracePeriod(uint256 _gracePeriod) public onlyRole(GOVERNOR_ROLE, "Must have timelock role")
Make delay and gracePeriod constant, when change is needed deploy a new timelock and transfer ownership.
#0 - 0xScotch
2021-12-08T12:48:10Z
#263
#1 - GalloDaSballo
2022-01-08T17:11:28Z
Bumping up to high severity
#2 - GalloDaSballo
2022-01-08T17:11:35Z
Duplicate of #263
🌟 Selected for report: gzeon
3679.1897 USDC - $3,679.19
gzeon
According to https://github.com/code-423n4/2021-11-malt#high-level-overview-of-the-malt-protocol
When the Malt price TWAP drops below a specified threshold (eg 2% below peg) then the protocol will revert any transaction that tries to remove Malt from the AMM pool (ie buying Malt or removing liquidity). Users wanting to remove liquidity can still do so via the UniswapHandler contract that is whitelisted in recovery mode.
However, in https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/DexHandlers/UniswapHandler.sol#L236 liquidity removed is directly sent to msg.sender, which would revert if it is not whitelisted https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/PoolTransferVerification.sol#L53
Liquidity should be removed to UniswapHandler contract, then the proceed is sent to msg.sender
#0 - GalloDaSballo
2022-01-11T23:10:27Z
I believe this finding to be correct, because of the whitelisting on verifyTransfer
, during recovery mode the removal of liquidity from UniSwapV2Pair will perform safeTransfers: https://github.com/Uniswap/v2-core/blob/4dd59067c76dea4a0e8e4bfdda41877a6b16dedc/contracts/UniswapV2Pair.sol#L148
This means that the _beforeTokenTransfer
will be called which eventually will call verifyTransfer
which, if the price is below peg will revert.
Transfering the funds to the whitelisted contract should avoid this issue.
I'd like to remind the sponsor that anyone could deploy similar swapping contracts (or different ones such as curve), so if a person is motivate enough, all the whitelisting could technically be sidestepped.
That said, given the condition of LPing on Uniswap, the check and the current system would make it impossible to withdraw funds.
Because this does indeed compromises the availability of funds (effectively requiring the admin to unstock them manually via Whitelisting each user), I agree with High Severity
gzeon
maltMarketPrice in UniswapHandle return incorrect decimals of price when rewardDecimals < maltDecimals
} else if (rewardDecimals < maltDecimals) { uint256 diff = maltDecimals - rewardDecimals; price = (rewardReserves.mul(10**diff)).mul(10**rewardDecimals).div(maltReserves); decimals = maltDecimals;
The correct decimals given the price formula should be rewardDecimals.
Changing price formula and keep using maltDecimals is desired to preserve more decimal, i.e. change L104 to
price = (rewardReserves.mul(10**diff)).mul(10**maltDecimals).div(maltReserves);
#0 - 0xScotch
2021-12-08T11:38:09Z
#255
#1 - GalloDaSballo
2022-01-11T23:31:50Z
Duplicate of #255
🌟 Selected for report: gzeon
1103.7569 USDC - $1,103.76
gzeon
When malt is under-peg and the swing trader module do not have enough capital to buy back to peg, a Dutch auction is triggered to sell arb token. The price of the Dutch auction decrease linearly toward endprice until _endAuction() is called. https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/Auction.sol#L589
_endAuction() is called in
When auction.commitments >= auction.maxCommitments https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/Auction.sol#L212
On stabilize() -> checkAuctionFinalization() -> _checkAuctionFinalization() https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/StabilizerNode.sol#L146 https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/Auction.sol#L754
On stabilize() ->_startAuction() -> triggerAuction() -> _checkAuctionFinalization() https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/StabilizerNode.sol#L170 https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/Auction.sol#L754
It is possible manipulate the dutch auction by preventing _endAuction() being called.
Consider someone call purchaseArbitrageTokens with auction.maxCommitments minus 1 wei, _endAuction
won't be called because auction.commitments < auction.maxCommitments. Further purchase would revert because purchaseAndBurn
(https://github.com/code-423n4/2021-11-malt/blob/c3a204a2c0f7c653c6c2dda9f4563fd1dc1cecf3/src/contracts/Auction.sol#L184) would likely revert since swapping 1 wei in most AMM will fail due to rounding error. Even if it does not revert, there is no incentive to waste gas to purchase 1 wei of token.
As such, the only way for the auction to finalize is to call stabilize().
However, this is not immediately possible because it require
block.timestamp >= stabilizeWindowEnd
where
stabilizeWindowEnd = block.timestamp + stabilizeBackoffPeriod
stabilizeBackoffPeriod is initially set to 5 minutes in the contract
After 5 minute, stabilize() can be called by anyone. By using this exploit, an attacker can guarantee he can purchase at (startingPrice+endingPrice)/2 or lower, given the default 10 minute auctionLength and 5 minute stabilizeBackoffPeriod. (unless a privileged user call stabilize() which override the stability window)
Also note that stabilize() might not be called since there is no incentive.
#0 - GalloDaSballo
2022-01-25T02:54:25Z
By purchasing all but 1 wei of arbitrageTokens, any caller can guarantee that the auction will offer the steepest discount.
This is caused by the fact that AMMs (esp UniV2) will revert with small numbers, as rounding will cause the amountOut to == 0 which will cause a INSUFFICIENT_AMOUNT revert.
I agree with the pre-conditions and the possibility of this to happen. In a sense I believe this can become the meta strategy that every dutch auction participant will use (the fight between buyers will be done by paying gas to be the first few to buy arbitrageTokens).
So the question is how to avoid it. I guess purchasing at a time should lock the price for that particular buyer at that particular time (adding a mapping should increase cost of 20k on write and a few thousand gas on read).
#1 - GalloDaSballo
2022-01-25T02:55:30Z
TODO: Decide on severity
Medium for sure, extract value reliably. Is it high though? Arguably the dutch auction is not properly coded as it allows to get a further discount instead of locking in a price for the user at time of deposit
#2 - GalloDaSballo
2022-01-26T13:25:56Z
While an argument for the Dutch Auction being coded wrong (user locking in price) can be made, that's not the definition of a Dutch Auciton
<img width="600" alt="Screenshot 2022-01-26 at 14 16 34" src="https://user-images.githubusercontent.com/13383782/151169628-a1d8e7e7-15c2-4064-a985-9f923154170a.png"> https://www.investopedia.com/terms/d/dutchauction.aspSo arguably the dutch auction is properly coded, it's that there's a specific way to sidestep it which is caused by:
For this conditions to happen the "exploiter" needs to be fast enough to place an order, in such a way that their order will leave 1 commitment left. I can imagine them setting up a contract that reverts if this condition isn't met and that checks for w/e amount is needed at that time.
I would assume that having an admin privileged function to close the auction prematurely would solve this specific attack.
I believe that the warden has identified a fairly reliable way to get a discount from the protocol because of the impact and some of the technicalities I believe Medium Severity to be more appropriate. This will be exploited, but in doing so will make the protocol successful (all auction full minus 1 wei), the premium / discount will be reliable predicted (50% between start and end) and as such I believe it will be priced in
🌟 Selected for report: gzeon
1103.7569 USDC - $1,103.76
gzeon
One of the innovative feature of Malt is to block buying while under peg. The buy block can be bypassed by swapping to the whitelisted UniswapHandler, and then extract the token by abusing the add and remove liquidity function. This is considered a high severity issue because it undermine to protocol's ability to generate profit by the privileged role as designed and allow potential risk-free MEV.
swapExactTokensForTokens(amountDai, 0, [dai.address, malt.address], uniswapHandler.address, new Date().getTime() + 10000);
According to documentation in https://github.com/code-423n4/2021-11-malt#high-level-overview-of-the-malt-protocol
Users wanting to remove liquidity can still do so via the UniswapHandler contract that is whitelisted in recovery mode. , this should be exploitable. Meanwhile the current implementation did not actually allow remove liquidity during recovery mode (refer to issue "Unable to remove liquidity in Recovery Mode") This exploit can be mitigated by disabling addLiquidity() when the protocol is in recovery mode
#0 - GalloDaSballo
2022-01-25T02:40:00Z
The Malt token is programmed to explicitly prevent buying it when at discount.
However, because addLiquidity
and removeLiquidity
are callable by anyone, and the UniswapHandler
is whitelisted, through a calculated addition and removal of liquidity anyone can mimick buying Malt for a discount (by providing imbalanced underlying and redeeming them).
I believe this sidesteps the majority of the functionality of the protocol, and while the attack is fairly simple, it denies the protocol functionality and as such agree with a High Severity
#1 - GalloDaSballo
2022-01-30T15:27:35Z
After re-review: Addressing each step:
transfer
will be from pool to user and as such will be reverted due to the system being in recovery mode.I believe that the warden identified a way to sidestep purchasing malt via purchase -> send to UniswapHandler -> add liquidity which should allow for some MEV extraction (with the user risking IL as they assume malt will go back to peg)
Because of this there's still some validity to the finding, but it's not as dire as I originally believed.
I'm going to downgrade the finding to Medium Severity as:
But this doesn't allow the selling of malt, so it's not a protocol breaking exploit but rather a way to gain MEV.
gzeon
function setLpProfitCut(uint256 _profitCut) public onlyRole(ADMIN_ROLE, "Must have admin privs") { require(_profitCut >= 0 && _profitCut <= 1000, "Must be between 0 and 100%"); lpProfitCut = _profitCut; }
can write as
function setLpProfitCut(uint256 _profitCut) public onlyRole(ADMIN_ROLE, "Must have admin privs") { require(_profitCut <= 1000, "Must be between 0 and 100%"); lpProfitCut = _profitCut; }
since uint256 always >= 0
#0 - 0xScotch
2021-12-08T18:16:48Z
#309
#1 - GalloDaSballo
2021-12-30T16:15:20Z
Duplicate of #309
10.3016 USDC - $10.30
gzeon
AuctionData struct can be repacked to save some storage slot. e.g.
active
and finalized
together to save 1 slotstartingTime
and endingTime
to save 2 slot when packed with active
and finalized
struct AuctionData { uint256 fullRequirement; uint256 maxCommitments; uint256 commitments; uint256 maltPurchased; uint256 startingPrice; uint256 endingPrice; uint256 finalPrice; uint256 pegPrice; uint120 startingTime; uint120 endingTime; bool active; bool finalized; uint256 preAuctionReserveRatio; uint256 claimableTokens; uint256 finalBurnBudget; uint256 finalPurchased; mapping(address => AccountCommitment) accountCommitments; }
#0 - 0xScotch
2021-12-09T22:09:15Z
#38
#1 - GalloDaSballo
2022-01-01T15:56:57Z
Duplicate of #38
🌟 Selected for report: gzeon
56.5245 USDC - $56.52
gzeon
return ( maltDataLab.maltPriceAverage(priceLookback) > priceTarget * (10000 - thresholdBps) / 10000, "The price of Malt is below peg. Wait for peg to be regained or purchase arbitrage tokens." );
when the condition is true (which should be the majority of time), the reason string is unnecessary. Only return the string when the condition is false.
#0 - GalloDaSballo
2022-01-01T15:56:13Z
Agree that you'd return the string only on failure