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: 42/199
Findings: 3
Award: $192.79
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: yellowBirdy
Also found by: BenRai, ChrisTina, GreedyGoblin, Norah, carrotsmuggler
153.271 USDC - $153.27
https://github.com/code-423n4/2023-04-frankencoin/blob/main/contracts/Position.sol#L109 https://github.com/code-423n4/2023-04-frankencoin/blob/main/contracts/Position.sol#L249 https://github.com/code-423n4/2023-04-frankencoin/blob/main/contracts/Position.sol#L263
The collateral of an open position gets stuck for the whole expiration time when it gets denied and nobody challenges it during initialization time. This basically locks down the collateral of the position leaving the user unable to do anything with that collateral. No frankencoins can be minted, and the collateral cannot be withdrawn for the whole expiration time.
A position contains a variable called expiration
. This variable contains the value of the timestamp at which the position will expire. Position also contains a variable called cooldown
. This variable is used as a check to delay a specific interaction between the owner and the position.
modifier noCooldown() { if (block.timestamp <= cooldown) revert Hot(); _; }
When the owner wants to withdraw all the collateral deposited in the position, it calls the function withdrawCollateral()
:
/** * Withdraw collateral from the position up to the extent that it is still well collateralized afterwards. * Not possible as long as there is an open challenge or the contract is subject to a cooldown. * * Withdrawing collateral below the minimum collateral amount formally closes the position. */ function withdrawCollateral(address target, uint256 amount) public onlyOwner noChallenge noCooldown { uint256 balance = internalWithdrawCollateral(target, amount); checkCollateral(balance, price); }
This function contains the noCooldown
modifier, therefore if we are currently in the cooldown time span we cannot withdrawal any collateral.
When a position is denied by a qualified user the cooldown variable is assigned the expiration value.
function deny(address[] calldata helpers, string calldata message) public { if (block.timestamp >= start) revert TooLate(); IReserve(zchf.reserve()).checkQualified(msg.sender, helpers); cooldown = expiration; // since expiration is immutable, we put it under cooldown until the end emit PositionDenied(msg.sender, message); }
It is not uncommon to open positions for long periods of time, for example 1 year (the documentation explains that large periods of time are not uncommon and uses 365 days as an example). If a user gets its position denied, it cannot recover the collateral for the whole expiration time, since the cooldown has now the expiration value and in order to withdraw the collateral there has to be no cooldown. It is a strong issue for the user since if the position gets denied and nobody is interested in it, the user is unable to close the position since the only way to close a position is to withdraw all the collateral. This is explained as a comment over the withdrawCollateral()
function.
It might look far fetched that nobody will challenge the denied position, but this can easily happen for example when not so popular tokens are used as collateral. There are a plethora of reasons for which an open position might not get challenged, and it is important that the protocol is consistent through all of the different ramifications.
IDE
A possible solution could be to implement a check that makes sure that the position has been denied and the initialization period has ended and no challenges have been issued. When all the requirements are met the user should be able to withdraw the collateral.
#0 - c4-pre-sort
2023-04-24T07:14:59Z
0xA5DF marked the issue as duplicate of #874
#1 - c4-judge
2023-05-18T08:57:14Z
hansfriese marked the issue as satisfactory
🌟 Selected for report: peanuts
Also found by: GreedyGoblin, J4de, KIntern_NA, Kumpa, LegendFenGuin, T1MOH, __141345__, deadrxsezzz, deliriusz, ltyu, m9800, rvierdiiev
16.9175 USDC - $16.92
https://github.com/code-423n4/2023-04-frankencoin/blob/main/contracts/MintingHub.sol#L265
A challenger reward can be farmed by biding market prices in own challenge.
The protocol has 3 main actors on a position: the position owner, the challenger and the bidder.
When a position is opened, users have a chance to challenge the position for the duration of the position. When a position is challenged the challenger places the same amount of collateral out for auction. The highest bid determines if the challenge has been successful. If the highest bid is superior to the challenged price, which was the one suggested by the position, the bidder will buy from the challenger and the user is unaffected. If the highest bid is below the price suggested by the position owner the challenge succeeds and the bidder "buys" the collateral from the position owner.
When a challenge succeeds, the challenger gets rewarded with 2% of the volume. This opens a possible attack where the attacker has to find open positions where the price suggested is in line or less than 1% over the real price. Then challenge it and place a bid on the challenge for that same price minus a negligible amount (this is done to avoid failing the challenge). Because of this, other possible bidders are heavily discouraged to bid, because the bided price is already the price of the asset, therefore no possible revenue from the transaction. Also since positions can be challenged more than once, we can repeat this attack over and over. Steps:
1 - Find an open position with a realistic price (shouldn't be too hard since position owners that wish their position to be accepted should place realistic prices). 2 - Challenge the position 3 - Bid with a real price that does not fail the challenge and discourages other bidders for lack of revenue. 4 - Collect 2% from challenge succeeded. 5 - Repeat (if position has not reached limit).
If somebody outbids us, therefore failing the challenge. Its not a big deal since we are going to get the same or more Frankencoin for that collateral than it is actually worth (it should be highly improbable that somebody outbids us because they will generate no revenue, but is still a possibility).
The hardest parts of this attack are: 1 - Finding an open position with a collateral value ratio 1:1 (or at least of 1% variance so we can make some profit). 2 - Finding the price at which we don't lose value when winning the bid, and that discourages the bidders. This can be done by checking latest transactions and setting a median value. Also checking oracles that could track both coins.
Again the attack is far fetched, but it is definitely possible. The worst thing of this is that it encourages users to challenge open positions that are correctly collateralized jus to farm the reward.
IDE
This does not have an easy way to solve. The main idea here is that since the challenger gets rewarded by challenging we can force a challenge and bid on it with a price that generates no profit for other bidders, but that still does not fail the challenge.
The simplest way to solve this would be to reduce the maximum possible profit of this attack, therefore making it too hard to execute. To do this the reward of the challenger could be lowered so that finding an open position with the right price becomes harder because the margin of profit is no longer 2% but smaller. This does not eliminate the attack, but makes it significantly more unprofitable and harder to execute.
#0 - c4-pre-sort
2023-04-28T15:22:22Z
0xA5DF marked the issue as duplicate of #745
#1 - c4-pre-sort
2023-04-28T15:23:42Z
0xA5DF marked the issue as low quality report
#2 - 0xA5DF
2023-04-28T15:23:44Z
Partial dupe
#3 - c4-judge
2023-05-18T14:54:58Z
hansfriese marked the issue as partial-50