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: 52/199
Findings: 2
Award: $104.33
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: peanuts
Also found by: GreedyGoblin, J4de, KIntern_NA, Kumpa, LegendFenGuin, T1MOH, __141345__, deadrxsezzz, deliriusz, ltyu, m9800, rvierdiiev
43.9854 USDC - $43.99
There is no restrictions as to how many challenges can occur at one given auction time. A challenger can potentially create an insanely large amount of challenges with a tiny amount of collateral for each challenge.
function launchChallenge(address _positionAddr, uint256 _collateralAmount) external validPos(_positionAddr) returns (uint256) { IPosition position = IPosition(_positionAddr); IERC20(position.collateral()).transferFrom(msg.sender, address(this), _collateralAmount); uint256 pos = challenges.length; challenges.push(Challenge(msg.sender, position, _collateralAmount, block.timestamp + position.challengePeriod(), address(0x0), 0)); position.notifyChallengeStarted(_collateralAmount); emit ChallengeStarted(msg.sender, address(position), _collateralAmount, pos); return pos; }
Let's say if the fair price of 1000 ZCHF is 1 WETH, and the position owner sets his position at the fair price. Rationally, there will be no challenges and bidders because the price is fair. However, the challenger can attack the position owner this way:
function tryAvertChallenge(uint256 _collateralAmount, uint256 _bidAmountZCHF) external onlyHub returns (bool) { if (block.timestamp >= expiration){ return false; // position expired, let every challenge succeed } else if (_bidAmountZCHF * ONE_DEC18 >= price * _collateralAmount){ // challenge averted, bid is high enough challengedAmount -= _collateralAmount; // Don't allow minter to close the position immediately so challenge can be repeated before // the owner has a chance to mint more on an undercollateralized position //@audit-- calls restrictMinting if passed restrictMinting(1 days); return true; } else { return false; } }
function restrictMinting(uint256 period) internal { uint256 horizon = block.timestamp + period; if (horizon > cooldown){ cooldown = horizon; } }
Minting for Position owner will be suspended for a long time.
VSCode
In this Frankencoin protocol, the challenger never really loses.
If the bid ends lower than the liquidation price, then the bidder wins because he bought the collateral at a lower market value. The challenger also wins because he gets his reward. The protocol owner loses because he sold his collateral at a lower market value.
If the bid ends higher than the liquidation price, then the bidder loses because he bought the collateral at a higher market value. The challenger wins because he gets to trade his collateral for a higher market value. The protocol owner neither wins nor loses.
The particular POC above is one way a challenger can abuse his power to create many challenges without any sort of consequence in order to attack the owner. In the spirit of fairness, the challenger should also lose if he challenges wrongly.
Every time a challenger issues a challenge, he should pay a small fix sum of money that will go to the owner if the bidder sets an amount higher than fair market value. (because that means that the protocol owner was right about the fair market value all along.)
Although the position owner can be a bidder himself, if the position owner bids on his own position in order to win this small amount of money, the position owner will lose at the same time because he is buying the collateral at a higher-than-market price from the challenger, so this simultaneous gain and loss will balance out.
#0 - c4-pre-sort
2023-04-20T12:11:08Z
0xA5DF marked the issue as primary issue
#1 - 0xA5DF
2023-04-28T15:28:19Z
#385 highlights that even without the added cooldown - there's no price exacted from the challenger in case they fail. This can lead to false challenges that DoS legitimate positions, hoping to win some of the challenges by chance
#2 - luziusmeisser
2023-04-30T00:58:29Z
Note that challenges must have a minimum size, so launching a large number of challenges locks up a significant amount of funds.
However, it is true that a challenger and a bidder that collude could trigger a cooldown period.
This is a valid issue. My mitigation is to not allow a challenge to be launched and averted in the same block. This ensures that the challenger has some money at risk as the challenger cannot be sure that it will be the bidder he is colluding with that can buy the collateral at a discount.
Example:
#3 - c4-sponsor
2023-04-30T00:58:38Z
luziusmeisser marked the issue as sponsor confirmed
#4 - c4-judge
2023-05-18T05:02:44Z
hansfriese marked the issue as selected for report
🌟 Selected for report: __141345__
Also found by: Emmanuel, KIntern_NA, SaeedAlipoor01988, bin2chen, cccz, joestakey, ladboy233, peanuts, said
60.3367 USDC - $60.34
Challenger's collateral will be stuck in contract if collateral does not accept zero value transfer.
Assume that when a position is created, the _initialCollateral is equal to the _minCollateral. This means that the position owner only deposits _initialCollateral amount into the new position created.
MintingHub.sol#openPosition()
IERC20(_collateralAddress).transferFrom(msg.sender, address(pos), _initialCollateral);
Now, we know that there can be more than one challenge occuring at the same time. Let's say 3 challenges occur, position owner has 1 ETH max, and the 3 challengers put up 1 ETH, 1 ETH and 0.5 ETH respectively. launchChallenge will check whether the challenger's collateral size must be more than minimumCollateral and the challenger's size must be more than the collateralBalance inside the position, which is true at the start of the challenge.
function launchChallenge(address _positionAddr, uint256 _collateralAmount) external validPos(_positionAddr) returns (uint256) { IPosition position = IPosition(_positionAddr); IERC20(position.collateral()).transferFrom(msg.sender, address(this), _collateralAmount); uint256 pos = challenges.length; challenges.push(Challenge(msg.sender, position, _collateralAmount, block.timestamp + position.challengePeriod(), address(0x0), 0)); position.notifyChallengeStarted(_collateralAmount); emit ChallengeStarted(msg.sender, address(position), _collateralAmount, pos); return pos; }
function notifyChallengeStarted(uint256 size) external onlyHub { // require minimum size, note that collateral balance can be below minimum if it was partially challenged before if (size < minimumCollateral && size < collateralBalance()) revert ChallengeTooSmall(); challengedAmount += size; }
Assuming challenge has started and the first challenge has passed its challenge duration. The bidders in challenge 1 will get his fair share of collateral from the position owner, and the position will be left with no more collateral.
Now, challenge two finishes. Challenger 2 wants to collect his collateral and he calls end(), which calls position.notifyChallengeSucceeded().
(address owner, uint256 effectiveBid, uint256 volume, uint256 repayment, uint32 reservePPM) = challenge.position.notifyChallengeSucceeded(recipient, challenge.bid, challenge.size);
In notifyChallengeSucceeded, because _size is greater than colBal, (1 ETH > 0), the _bid will be redimension. However, _bid will equal to 0 because _divD18(_mulD18(_bid, 0), 1ETH) equals to zero. The _size will be equal to colBal.
Next, internalWithdrawCollateral will be called, with _bidder as the _bidder and _size as 0.
internalWithdrawCollateral(_bidder, _size); // transfer collateral to the bidder and emit update
However, if the collateral is unable to perform zero value transfers, then internalWithdrawCollateral will fail.
function internalWithdrawCollateral(address target, uint256 amount) internal returns (uint256) { IERC20(collateral).transfer(target, amount); uint256 balance = collateralBalance(); if (balance < minimumCollateral){ cooldown = expiration; } emitUpdate(); return balance; }
Manual Review
Make sure the collateral accepts zero value transfer as well so that the whole end() function can execute properly. Otherwise, skip the transfer.
function internalWithdrawCollateral(address target, uint256 amount) internal returns (uint256) { if(amount > 0){ IERC20(collateral).transfer(target, amount); } uint256 balance = collateralBalance(); if (balance < minimumCollateral){ cooldown = expiration; } emitUpdate(); return balance; }
#0 - c4-pre-sort
2023-04-20T13:02:04Z
0xA5DF marked the issue as duplicate of #680
#1 - 0xA5DF
2023-04-20T13:02:10Z
Subset of #680
#2 - c4-judge
2023-05-18T13:24:33Z
hansfriese marked the issue as satisfactory