Frankencoin - cccz's results

A decentralized and fully collateralized stablecoin.

General Information

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

Frankencoin

Findings Distribution

Researcher Performance

Rank: 3/199

Findings: 8

Award: $4,388.24

🌟 Selected for report: 3

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: carrotsmuggler

Also found by: Ace-30, KIntern_NA, Nyx, bin2chen, cccz, juancito, mahdikarimi, mov, nobody2018

Labels

bug
3 (High Risk)
satisfactory
edited-by-warden
duplicate-945

Awards

201.1223 USDC - $201.12

External Links

Lines of code

https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L159-L167

Vulnerability details

Impact

A challenge mechanism exists in frankcoin, whereby a challenger can use collateral to launch a challenge when it observes that the price in a position is too high (i.e., the position may overstate the price).

    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;
    }

If the bidder's bid is greater than or equal to the current price, the bidder gets the challenger's collateral and the challenger gets the bidder's bid.

    function bid(uint256 _challengeNumber, uint256 _bidAmountZCHF, uint256 expectedSize) external {
        Challenge storage challenge = challenges[_challengeNumber];
        if (block.timestamp >= challenge.end) revert TooLate();
        if (expectedSize != challenge.size) revert UnexpectedSize();
        if (challenge.bid > 0) {
            zchf.transfer(challenge.bidder, challenge.bid); // return old bid
        }
        emit NewBid(_challengeNumber, _bidAmountZCHF, msg.sender);
        // ask position if the bid was high enough to avert the challenge
        if (challenge.position.tryAvertChallenge(challenge.size, _bidAmountZCHF)) {
            // bid was high enough, let bidder buy collateral from challenger
            zchf.transferFrom(msg.sender, challenge.challenger, _bidAmountZCHF);
            challenge.position.collateral().transfer(msg.sender, challenge.size);
            emit ChallengeAverted(address(challenge.position), _challengeNumber);
            delete challenges[_challengeNumber];

There is a issue here, if the owner of the position adjusts the price before the challenger launches the challenge, it may cause the challenger to lose assets.

    function adjustPrice(uint256 newPrice) public onlyOwner noChallenge {
        if (newPrice > price) {
            restrictMinting(3 days);
        } else {
            checkCollateral(collateralBalance(), newPrice);
        }
        price = newPrice;
        emitUpdate();
    }

Consider the current WETH : ZCHF = 2000:1

  1. At this point price = 2000, alice clones the position, providing 2 WETH to mint 4000 ZCHF.
  2. alice calls Position.adjustPrice to adjust price to 3000 (this step is the bait)
  3. bob notices that the price is overstated and the position may be undercollateralized, so bob calls MintingHub.launchChallenge to challenge the position with 2 WETH.
  4. alice observes bob's transaction in MemPool, so alice uses MEV to provide another 3 WETH to the position and calls Position.adjustPrice(bob's challenge has not yet been launched, adjustPrice can be called) to adjust the price to 1000 (5 * 1000 > 4000)
  5. bob's challenge is then launched, and the price in the position is 1000
  6. alice calls MintingHub.bid with 2000 ZCHF to get 2 WETH of bob and delete the challenge.
    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
            restrictMinting(1 days);
            return true;
        } else {
            return false;
        }
    }

This vulnerability will break the entire challenge mechanism, as the challenger will be unwilling to launch a challenge for fear of being attacked, leading to a prevalence of overstating prices in the position, so I consider this a high risk vulnerability.

Proof of Concept

https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L159-L167 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L140-L148 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L199-L213 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L304-L317

Tools Used

None

Consider adding an expectedPrice parameter in launchChallenge, when the price in the position does not match expectedPrice, revert the transaction

- function launchChallenge(address _positionAddr, uint256 _collateralAmount) external validPos(_positionAddr) returns (uint256) { + function launchChallenge(address _positionAddr, uint256 _collateralAmount, uint256 expectedPrice) external validPos(_positionAddr) returns (uint256) { IPosition position = IPosition(_positionAddr); + require(expectedPrice == position.price(),"price not match"); 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; }

#0 - c4-pre-sort

2023-04-22T18:55:55Z

0xA5DF marked the issue as duplicate of #945

#1 - c4-judge

2023-05-18T14:50:11Z

hansfriese marked the issue as satisfactory

Findings Information

🌟 Selected for report: cccz

Also found by: mahdikarimi

Labels

bug
3 (High Risk)
primary issue
selected for report
sponsor confirmed
H-03

Awards

3036.9204 USDC - $3,036.92

External Links

Lines of code

https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L268-L276

Vulnerability details

Impact

When the challenge is successful, internalWithdrawCollateral will be called to transfer the collateral in the position. Note that the cooldown period of the position will be extended until the position expires only if the collateral in the position is less than minimumCollateral, if the user sends collateral to the position in advance, then the cool down period of the position will not be extended.

    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;
    }

I will use the following example to illustrate the severity of the issue.

Consider WETH:ZCHF=2000:1, the position has a challenge period of 3 days and the minimum amount of collateral is 1 WETH.

  1. alice clones the position, offering 1 WETH to mint 0 zchf.
  2. alice adjusts the price to 10e8, the cooldown period is extended to 3 days later.
  3. bob offers 1 WETH to launch the challenge and charlie bids 1800 zchf.
  4. Since bob has already covered all collateral, other challengers are unprofitable and will not launch new challenges
  5. After 3 days, the cooldown period ends and the challenge expires.
  6. bob calls end() to end the challenge.
  7. alice observes bob's transaction and uses MEV to send 1 WETH to the position in advance.
  8. bob's transaction is executed, charlie gets the 1 WETH collateral in the position, and alice gets most of the bid.
  9. Since the position balance is still 1 WETH, the position cooldown period does not extend to the position expiration. 10.Since the position is not cooldown and there is no challenge at this point, alice uses that price to mint 10e8 zchf.

Proof of Concept

https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L268-L276 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L329-L354 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L252-L276

Tools Used

None

Consider extending the cooldown period of the position even if the challenge is successful

#0 - c4-pre-sort

2023-04-24T20:04:55Z

0xA5DF marked the issue as primary issue

#1 - luziusmeisser

2023-04-30T01:22:20Z

Excellent finding! Will implement 1 day cooldown on successful challenges.

#2 - c4-sponsor

2023-04-30T01:22:31Z

luziusmeisser marked the issue as sponsor confirmed

#3 - c4-judge

2023-05-17T18:16:34Z

hansfriese marked the issue as selected for report

Findings Information

Awards

35.0635 USDC - $35.06

Labels

bug
3 (High Risk)
satisfactory
edited-by-warden
duplicate-458

External Links

Lines of code

https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L260-L265

Vulnerability details

Impact

When the challenge is successful, the challenger will receive the challenge reward. In MintingHub.end, the challenge reward is 2% of the volume returned by notifyChallengeSucceeded.

        (address owner, uint256 effectiveBid, uint256 volume, uint256 repayment, uint32 reservePPM) = challenge.position.notifyChallengeSucceeded(recipient, challenge.bid, challenge.size);
        if (effectiveBid < challenge.bid) {
            // overbid, return excess amount
            IERC20(zchf).transfer(challenge.bidder, challenge.bid - effectiveBid);
        }
        uint256 reward = (volume * CHALLENGER_REWARD) / 1000_000;
        uint256 fundsNeeded = reward + repayment;
        if (effectiveBid > fundsNeeded){
            zchf.transfer(owner, effectiveBid - fundsNeeded);
        } else if (effectiveBid < fundsNeeded){
            zchf.notifyLoss(fundsNeeded - effectiveBid); // ensure we have enough to pay everything
        }
        zchf.transfer(challenge.challenger, reward); // pay out the challenger reward

In notifyChallengeSucceeded, volumeZCHF == price * bid.size

    function notifyChallengeSucceeded(address _bidder, uint256 _bid, uint256 _size) external onlyHub returns (address, uint256, uint256, uint256, uint32) {
        challengedAmount -= _size;
        uint256 colBal = collateralBalance();
        if (_size > colBal){
            // Challenge is larger than the position. This can for example happen if there are multiple concurrent
            // challenges that exceed the collateral balance in size. In this case, we need to redimension the bid and
            // tell the caller that a part of the bid needs to be returned to the bidder.
            _bid = _divD18(_mulD18(_bid, colBal), _size);
            _size = colBal;
        }

        // Note that thanks to the collateral invariant, we know that
        //    colBal * price >= minted * ONE_DEC18
        // and that therefore
        //    price >= minted / colbal * E18
        // such that
        //    volumeZCHF = price * size / E18 >= minted * size / colbal
        // So the owner cannot maliciously decrease the price to make volume fall below the proportionate repayment.
        uint256 volumeZCHF = _mulD18(price, _size); // How much could have minted with the challenged amount of the collateral
        // The owner does not have to repay (and burn) more than the owner actually minted.  
        uint256 repayment = minted < volumeZCHF ? minted : volumeZCHF; // how much must be burned to make things even

        notifyRepaidInternal(repayment); // we assume the caller takes care of the actual repayment
        internalWithdrawCollateral(_bidder, _size); // transfer collateral to the bidder and emit update
        return (owner, _bid, volumeZCHF, repayment, reserveContribution);
    }

where price can be adjusted to any value by the owner of the position, which results in an unlimited challenge reward for the challenger.

    function adjustPrice(uint256 newPrice) public onlyOwner noChallenge {
        if (newPrice > price) {
            restrictMinting(3 days);
        } else {
            checkCollateral(collateralBalance(), newPrice);
        }
        price = newPrice;
        emitUpdate();
    }

Consider WETH:ZCHF = 2000:1 alice clones the position, offering 1 WETH to mint 2000 ZCHF. alice adjusts the price to 50000 * 2000. bob challenges the position with 1 WETH and charlie bids 2000 ZCHF. After the challenge expires, bob calls the end function, volumeZCHF = 50000 * 2000, and the challenge reward is 2000000 zchf. when the zchf reserves are insufficient, new zchf will be minted

       uint256 reward = (volume * CHALLENGER_REWARD) / 1000_000;
        uint256 fundsNeeded = reward + repayment;
        if (effectiveBid > fundsNeeded){
            zchf.transfer(owner, effectiveBid - fundsNeeded);
        } else if (effectiveBid < fundsNeeded){
            zchf.notifyLoss(fundsNeeded - effectiveBid); // ensure we have enough to pay everything
        }
...
   function notifyLoss(uint256 _amount) override external minterOnly {
      uint256 reserveLeft = balanceOf(address(reserve));
      if (reserveLeft >= _amount){
         _transfer(address(reserve), msg.sender, _amount);
      } else {
         _transfer(address(reserve), msg.sender, reserveLeft);
         _mint(msg.sender, _amount - reserveLeft);
      }
   }

Proof of Concept

https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L260-L265 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L329-L354

Tools Used

None

Consider capping challenge rewards, or use effectiveBid instead of volume to determine challenge rewards

        (address owner, uint256 effectiveBid, uint256 volume, uint256 repayment, uint32 reservePPM) = challenge.position.notifyChallengeSucceeded(recipient, challenge.bid, challenge.size);
        if (effectiveBid < challenge.bid) {
            // overbid, return excess amount
            IERC20(zchf).transfer(challenge.bidder, challenge.bid - effectiveBid);
        }
-       uint256 reward = (volume * CHALLENGER_REWARD) / 1000_000;
+       uint256 reward = (effectiveBid * CHALLENGER_REWARD) / 1000_000;
        uint256 fundsNeeded = reward + repayment;
        if (effectiveBid > fundsNeeded){
            zchf.transfer(owner, effectiveBid - fundsNeeded);
        } else if (effectiveBid < fundsNeeded){
            zchf.notifyLoss(fundsNeeded - effectiveBid); // ensure we have enough to pay everything
        }

#0 - c4-pre-sort

2023-04-22T18:57:22Z

0xA5DF marked the issue as duplicate of #973

#1 - c4-pre-sort

2023-04-24T18:44:27Z

0xA5DF marked the issue as duplicate of #458

#2 - c4-judge

2023-05-18T14:44:09Z

hansfriese marked the issue as satisfactory

Findings Information

Awards

35.0635 USDC - $35.06

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
duplicate-458

External Links

Lines of code

https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L140-L148

Vulnerability details

Impact

When a challenge is launched on a position, it sets challenge.end == block.timestamp + position.challengePeriod()

    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;
    }

and when bidding on the challenge, it requires block.timestamp < challenge.end

    function bid(uint256 _challengeNumber, uint256 _bidAmountZCHF, uint256 expectedSize) external {
        Challenge storage challenge = challenges[_challengeNumber];
        if (block.timestamp >= challenge.end) revert TooLate();
        if (expectedSize != challenge.size) revert UnexpectedSize();
        if (challenge.bid > 0) {
            zchf.transfer(challenge.bidder, challenge.bid); // return old bid
        }

If the position has challengePeriod == 0 (set by the user when opening the position and not denied by the major shareholder), then challenge.end == block.timestamp, i.e. it will end when the challenge is launched and users will not be able to bid on the challenge, i.e. the position will not be affected by challenges.

    function openPosition(
        address _collateralAddress, uint256 _minCollateral, uint256 _initialCollateral,
        uint256 _mintingMaximum, uint256 _initPeriodSeconds, uint256 _expirationSeconds, uint256 _challengeSeconds,
        uint32 _mintingFeePPM, uint256 _liqPrice, uint32 _reservePPM) public returns (address) {
        IPosition pos = IPosition(
            POSITION_FACTORY.createNewPosition(
                msg.sender,
                address(zchf),
                _collateralAddress,
                _minCollateral,
                _mintingMaximum,
                _initPeriodSeconds,
                _expirationSeconds,
                _challengeSeconds,
                _mintingFeePPM,
                _liqPrice,
                _reservePPM
            )
        );
        zchf.registerPosition(address(pos));
        zchf.transferFrom(msg.sender, address(zchf.reserve()), OPENING_FEE);
        require(_initialCollateral >= _minCollateral, "must start with min col");
        IERC20(_collateralAddress).transferFrom(msg.sender, address(pos), _initialCollateral);

        return address(pos);
    }

A position that cannot be challenged can set any price to mint a large number of zchf

Proof of Concept

https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L140-L148 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L199-L205 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L88-L113

Tools Used

None

Consider requiring challengePeriod > 0 when opening positions

#0 - c4-pre-sort

2023-04-21T09:24:49Z

0xA5DF marked the issue as primary issue

#1 - c4-pre-sort

2023-04-21T09:26:30Z

0xA5DF marked the issue as duplicate of #830

#2 - c4-pre-sort

2023-04-24T18:48:53Z

0xA5DF marked the issue as duplicate of #458

#3 - c4-judge

2023-05-18T14:38:10Z

hansfriese changed the severity to 3 (High Risk)

#4 - c4-judge

2023-05-18T14:40:27Z

hansfriese marked the issue as satisfactory

Lines of code

https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Equity.sol#L309-L316

Vulnerability details

Impact

When the system reserve drops below 1000 due to some reasons, the structureCapTable function allows major shareholders to burn the FPS of some users. However, restructureCapTable will only repeatedly burn the FPS of addressesToWipe[0]. In extreme cases, major shareholders may think that reconstructionCapTable is successful execute and fund the system so that users who have not burned FPS split funds.

    function restructureCapTable(address[] calldata helpers, address[] calldata addressesToWipe) public {
        require(zchf.equity() < MINIMUM_EQUITY);
        checkQualified(msg.sender, helpers);
        for (uint256 i = 0; i<addressesToWipe.length; i++){
            address current = addressesToWipe[0];
            _burn(current, balanceOf(current));
        }
    }

Proof of Concept

https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Equity.sol#L309-L316

Tools Used

None

Change to

    function restructureCapTable(address[] calldata helpers, address[] calldata addressesToWipe) public {
        require(zchf.equity() < MINIMUM_EQUITY);
        checkQualified(msg.sender, helpers);
        for (uint256 i = 0; i<addressesToWipe.length; i++){
-           address current = addressesToWipe[0];
+           address current = addressesToWipe[i];
            _burn(current, balanceOf(current));
        }
    }

#0 - c4-pre-sort

2023-04-20T14:21:58Z

0xA5DF marked the issue as duplicate of #941

#1 - c4-judge

2023-05-18T14:27:05Z

hansfriese marked the issue as satisfactory

Findings Information

🌟 Selected for report: __141345__

Also found by: Emmanuel, KIntern_NA, SaeedAlipoor01988, bin2chen, cccz, joestakey, ladboy233, peanuts, said

Labels

bug
2 (Med Risk)
satisfactory
duplicate-680

Awards

60.3367 USDC - $60.34

External Links

Lines of code

https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L268-L276

Vulnerability details

Impact

When the challenge is successful, internalWithdrawCollateral will be called to send the collateral in the position to the bidder,

    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;
    }

and the challenger’s collateral will be refunded, and the challenge reward will be sent to the challenger.

    function end(uint256 _challengeNumber, bool postponeCollateralReturn) public {
        Challenge storage challenge = challenges[_challengeNumber];
        require(challenge.challenger != address(0x0));
        require(block.timestamp >= challenge.end, "period has not ended");
        // challenge must have been successful, because otherwise it would have immediately ended on placing the winning bid
        returnCollateral(challenge, postponeCollateralReturn);
        // notify the position that will send the collateral to the bidder. If there is no bid, send the collateral to msg.sender
        address recipient = challenge.bidder == address(0x0) ? msg.sender : challenge.bidder;
        (address owner, uint256 effectiveBid, uint256 volume, uint256 repayment, uint32 reservePPM) = challenge.position.notifyChallengeSucceeded(recipient, challenge.bid, challenge.size);
        if (effectiveBid < challenge.bid) {
            // overbid, return excess amount
            IERC20(zchf).transfer(challenge.bidder, challenge.bid - effectiveBid);
        }
        uint256 reward = (volume * CHALLENGER_REWARD) / 1000_000;
        uint256 fundsNeeded = reward + repayment;
        if (effectiveBid > fundsNeeded){
            zchf.transfer(owner, effectiveBid - fundsNeeded);
        } else if (effectiveBid < fundsNeeded){
            zchf.notifyLoss(fundsNeeded - effectiveBid); // ensure we have enough to pay everything
        }
        zchf.transfer(challenge.challenger, reward); // pay out the challenger reward

If the collateral is an ERC777 token, the bidder can revert the transaction in the callback when receiving the collateral, preventing the challenger from receiving collateral refunds and challenge rewards.

Proof of Concept

https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L268-L276 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L351-L353 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L252-L260

Tools Used

None

Consider saving the bidder's collateral in pendingReturns when postpone is true in the end function

#0 - c4-pre-sort

2023-04-22T15:55:59Z

0xA5DF marked the issue as duplicate of #680

#1 - c4-judge

2023-05-18T13:25:18Z

hansfriese marked the issue as satisfactory

Findings Information

🌟 Selected for report: cccz

Also found by: DishWasher, KIntern_NA, SolidityATL, ToonVH, giovannidisiena, joestakey, santipu_

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor confirmed
M-10

Awards

121.0458 USDC - $121.05

External Links

Lines of code

https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Equity.sol#L241-L255

Vulnerability details

Impact

When minting and redeeming FPS in Equity, there is no slippage control. Since the price of FPS will change with the zchf reserve in the contract, users may suffer from sandwich attacks.

Consider the current contract has a zchf reserve of 1000 and a total supply of 1000.

Alice considers using 4000 zchf to mint FPS. Under normal circumstances, the contract reserve will rise to 5000 zchf, and the total supply will rise to (5000/1000)**(1/3)*1000 = 1710, that is, alice will get 1710 - 1000 = 710 FPS.

bob holds 400 FPS, and bob observes alice's transaction in MemPool, bob uses MEV to preemptively use 4000 zchf to mint 710 FPS.

When alice's transaction is executed, the contract reserve will increase from 5000 to 9000 zchf, and the total supply will increase from 1710 to (9000/5000)**(1/3)*1710 = 2080, that is, alice gets 2080-1710 = 370FPS.

Then bob will redeem 400 FPS, the total supply will drop from 2080 to 1680, and the contract reserve will drop from 9000 to (1689/2080)**3*9000 = 4742, that is, bob gets 9000-4742 = 4258 zchf.

bob's total profit is 310 FPS and 258 zchf.

Proof of Concept

https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Equity.sol#L241-L255 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Equity.sol#L266-L270 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Equity.sol#L275-L282 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Equity.sol#L290-L297

Tools Used

None

Consider setting minFPSout and minZCHFout parameters to allow slippage control when minting and redeeming FPS

#0 - c4-pre-sort

2023-04-20T13:10:35Z

0xA5DF marked the issue as primary issue

#1 - c4-sponsor

2023-05-03T06:12:12Z

luziusmeisser marked the issue as sponsor confirmed

#2 - c4-judge

2023-05-18T05:00:32Z

hansfriese marked issue #976 as primary and marked this issue as a duplicate of 976

#3 - c4-judge

2023-05-18T05:20:05Z

hansfriese marked the issue as not a duplicate

#4 - c4-judge

2023-05-18T05:20:19Z

hansfriese marked the issue as primary issue

#5 - c4-judge

2023-05-18T05:22:39Z

hansfriese marked the issue as selected for report

Findings Information

🌟 Selected for report: cccz

Also found by: RaymondFam

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor acknowledged
M-11

Awards

911.0761 USDC - $911.08

External Links

Lines of code

https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L217-L224

Vulnerability details

Impact

When bidders bid, if the expiration time of the challenge is less than 30 minutes, the expiration time will be extended.

            uint256 earliestEnd = block.timestamp + 30 minutes;
            if (earliestEnd >= challenge.end) {
                // bump remaining time like ebay does when last minute bids come in
                // An attacker trying to postpone the challenge forever must increase the bid by 0.5%
                // every 30 minutes, or double it every three days, making the attack hard to sustain
                // for a prolonged period of time.
                challenge.end = earliestEnd;
            }

However, extending the expiration time will break the order of the challenges, so that the later challenges will succeed before the previous ones, thus affecting the challenger's reward expectations.

Consider the following scenario There is a collateral of 2 WETH in a position, and as the actual price of WETH drops, challengers are attracted to challenge it. In block 1, alice uses 2 WETH to challenge the position, and the expiration time is block 7201 At block 2, bob challenges the position with 2 WETH, expiring at block 7202 The bidder then bids 4000 ZCHF each for alice's and bob's challenges. In block 7200, bob finds that if alice's challenge is successful, then bob will not be able to get the challenge reward, so bob bids 4200 ZCHF to alice's challenge. Alice's challenge expiration time is extended to block 7351. At block 7201, alice cannot call end to make the challenge successful because the expiration time is extended At block 7202, bob successfully calls end to make his challenge successful and gets the challenge reward. In block 7351, alice calls the end function. Since the collateral in the position is 0 at this time, alice will not be able to get the challenge reward, and bob's 4200 zchf will be returned.

    function notifyChallengeSucceeded(address _bidder, uint256 _bid, uint256 _size) external onlyHub returns (address, uint256, uint256, uint256, uint32) {
        challengedAmount -= _size;
        uint256 colBal = collateralBalance();
        if (_size > colBal){
            // Challenge is larger than the position. This can for example happen if there are multiple concurrent
            // challenges that exceed the collateral balance in size. In this case, we need to redimension the bid and
            // tell the caller that a part of the bid needs to be returned to the bidder.
            _bid = _divD18(_mulD18(_bid, colBal), _size);
            _size = colBal;
        }

        // Note that thanks to the collateral invariant, we know that
        //    colBal * price >= minted * ONE_DEC18
        // and that therefore
        //    price >= minted / colbal * E18
        // such that
        //    volumeZCHF = price * size / E18 >= minted * size / colbal
        // So the owner cannot maliciously decrease the price to make volume fall below the proportionate repayment.
        uint256 volumeZCHF = _mulD18(price, _size); // How much could have minted with the challenged amount of the collateral
        // The owner does not have to repay (and burn) more than the owner actually minted.  
        uint256 repayment = minted < volumeZCHF ? minted : volumeZCHF; // how much must be burned to make things even

Proof of Concept

https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L217-L224 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L329-L350

Tools Used

None

Consider implementing a challenge queue that allows the end function to be called on subsequent challenges only after previous challenges have ended

#0 - c4-pre-sort

2023-04-24T20:13:47Z

0xA5DF marked the issue as primary issue

#1 - luziusmeisser

2023-05-03T06:16:10Z

Bids must at least be 0.5% higher than the previous bid, so pro-longing the challenge four times already costs as much as the whole challenger reward of 2%, making this attack not very attractive under normal circumstances.

--> Not worth to add any complexity to change this.

#2 - c4-sponsor

2023-05-03T06:16:39Z

luziusmeisser marked the issue as sponsor acknowledged

#3 - c4-judge

2023-05-18T05:26:03Z

hansfriese marked the issue as selected for report

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter