Posted on :: Tags: , , , , , ,

Alpha content here... To keep up my security review skills, I participated in Statemind web3 security fellowship CTF focused on smart contract security of various concepts like,

  • DeFi protocol vulnerabilities
  • Cross-chain bridge security
  • Oracle manipulation
  • Cryptographic implementations
  • Solidity and Vyper smart contracts
  • Huff programming

I'm super excited to share my solutions and insights from this CTF, where I managed to secure a spot in the top 3! 🏆 Get ready for detailed writeups of each challenge - trust me, you won't want to miss these!

All of these challenges with solutions can be found here : TheMj0ln1r/statemind-web3-ctf

Vault

P: "Your goal is to drain all ether from the Vault contract. Use the deposit and withdraw functions to reduce the vault's balance to zero. Once the isSolved function returns true, you've completed the challenge."

solution

The vulnerability in this contract lies in the withdraw function, which is susceptible to a reentrancy attack. Here's why:

  1. The contract follows the checks-effects-interactions pattern incorrectly
  2. The balance is updated after the external call
  3. The contract uses a low-level call which forwards all gas

Here's how I exploited it:

The exploit script uses a malicious contract (Attack) that implements a receive() function to recursively withdraw funds from the vault. When the vault sends ETH to our contract, the receive() function is triggered, allowing us to withdraw more funds before the vault updates its balance.


Proxy

P: "You've encountered a proxy contract setup where the Proxy delegates calls to an Executor implementation. Find a way to manipulate the logic and get isSolved to return true."

Solution

Okay, we got one vulnerable proxy implementation. This is a delegatecall based proxy pattern in which the Proxy contract holding the storage and logic is present in the Executor contract.

The goal is to manipulate the logic to make isSolved() return true. But If we observe that isSolved() function, it always returns false and it is hardcoded in the contract. So, that means we need to do complete upgrade of that contract and re-deploy in such a that it returns true.

The key vulnerability lies in the execute() function which uses delegatecall to execute arbitrary logic, allowing us to potentially manipulate the contract's state. Simply, the calls to Proxy contract is delegated to the Executor and the Executor is delegating to the exec() function of a arbitray Logic contract. So the context of the call (msg.sender, msg.value, this and storage) remains same inside the exec() function on Logic. So, If we can update the Executor address in the Proxy slot to a new contract which have the logic of returning true on calling isSolved() then the chall is done. This is what I did in the following exploit script.


Lending

P: "You have lending protocol that interacts with interesting pair. You need to steal all funds from lending protocol."

Solution

A Defi protocol requires nothing else than except complete understanding about the protocol control flow. Lets observe what each contract in this protocol is doing.

  1. Lending Contract:

    • Add collateral using collateralToken
    • Borrow borrowToken against their collateral
    • Repay borrowed tokens
    • Uses a Pair contract to determine exchange rates
  2. Pair Contract (UniswapV2-style):

    • Implements an automated market maker (AMM)
    • Manages liquidity between two tokens (token0 and token1)
    • Handles token swaps and price calculations
    • Maintains reserves and updates prices
    • Provides functions for adding/removing liquidity
    • Used by the Lending contract to get exchange rates
  3. SafeMath Library:

    • Provides safe arithmetic operations
    • Prevents overflows/underflows in mathematical calculations
    • Used by the Pair contract for calculations
  4. UQ112x112 Library:

    • Handles fixed-point number calculations
    • Used for price calculations in the Pair contract
    • Helps maintain precision in price calculations
  5. IUniswapV2Callee Interface:

    • Defines the callback interface for flash swaps
    • Used when executing flash swaps in the Pair contract

The goal is to drain all borrowToken from the Lending contract. I always prefer to know the initial state of the protocol by querying all the information from the contracts deployed. The following is the state of the protocol when we initially received the challenge instance.

Lending :  0x85799e7ae2964fd6D8BdC6e680dA881B8bb97ed6
Pair :  0xE86b07a57552655eEFe68894bD346c84da238B15
token0 (or) collateralToken  :  0xCcb0B898D555656e582b8d17ba7b426330Abb9D8
token1 (or) borrowToken:   0x7373EB31cBDd5428720a37d50E5ec64EfA891acc
Pair balance of collatoralToken :  500000000000000000000
Pair balance of borrowToken :  500000000000000000000
Pair reserve0 :  500000000000000000000
Pair reserve1 :  500000000000000000000
Lending balance of collatoralToken :  0
Lending balance of borrowToken :  5000000000000000000000
Player balance of collatoralToken :  0
Player balance of borrowToken :  0
price of borrowToken in terms of collatoralTokens :  1000000000000000000
Player :  0xE88150C42CC6c0294dD20893Bf5b1EC6eDD24Fc6

As you can observe I got zero amount of both collatoral and borrow tokens, but Lending contract have the 5000e18 of borrow tokens and the Pair contract got the 500e18 amount of both the tokens.

Now for me, I don't have any collatoralToken to borrow the token from the Lending contract and drain them all. So, lets see how the borrow() function calcuates how much collatoral needed to borrow all those 5000e18 amount of borrow tokens.

function borrow(uint256 _amount) external {
    uint256 needCollateral = _amount * getExchangeRate() / 1e18;
    require(needCollateral <= usersCollateral[msg.sender], "You don't have enough collateral");
    borrowToken.transfer(msg.sender, _amount);
    usersUsedCollateral[msg.sender] += needCollateral;
    usersCollateral[msg.sender] -= needCollateral;
    usersBorrowed[msg.sender] += _amount;
}

The needCollateral is calculated from the getExchangeRate() and divided by the 1e18. Can we make this numerator as lesser than 1e18 ? So that the division will rounded to zero and we don't need to pay any collatoralToken.

Smart right?

Lets see how can we do this? We need to make getExchangeRate() return a very small number or even zero would be perfect!

    // Lending
    function getExchangeRate() public view returns (uint256) {
        return pair.getSpotPrice();
    }

    //Pair
    function getSpotPrice() external view returns (uint256) {
        return Math.mulDiv(reserve1, 1e18, reserve0);
    }

Now we can see that the price of the collatoralToken is dependent on the reserve0 and reserve1. And we need to manipulate the reserve0 to be a large number than reserve1 * 1e18 so that the getSpotPrice() will return zero.

To do this, I found out that we can perform few swaps in the Pair contract which changes the values of reserve0 and reserve1. But if we do the borrow from Lending after the swap() the again the price will be increase. So, we need to find a way to update the reserves during the swap and borrow the amount in the same transacation. If we can observe there is a callback happening to the caller in the swap() function.

if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);

So, during this callback we can borrow from Lending contract but the reserves need a forceful update. To do this we can make use of the vulnerable skim() and sync() functions in the Pair contract.

Why skim() and sync() are vulnerable? Cause there is no reentrancy lock on these.

    // force balances to match reserves
    function skim(address to) external {
        address _token0 = token0; // gas savings
        address _token1 = token1; // gas savings
        _safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0));
        _safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));
    }

    // force reserves to match balances
    function sync() external {
        _update(IERC20(token0).balanceOf(address(this)), IERC20(token1).balanceOf(address(this)), reserve0, reserve1);
    }

Thats how I manipulated the borrowToken price to borrowed all the tokens from Lending. Find the exploit below.


Yeild

P: "UniswapV3 yield farming is so easy! Just make sure there is liquidity around the spot price. You are given 5e18 each of token0 and token1. Your goal is to get 15e18 of LP tokens."

Solution

Uniswap V3 is scary for sure but to break things you don't need to master it. This is an example challege of how you can do yeild farming in Uniswap V3.

Let's break down the key components and observations of this Yield contract:

  1. Core Functionality:

    • Implements a yield farming vault for Uniswap V3
    • Manages liquidity positions in a specific price range
    • Allows users to deposit and withdraw tokens
    • Collects and distributes fees from the pool
  2. Key State Variables:

    • baseLower and baseUpper: Defines the price range for liquidity provision
    • lastTick: Tracks the last price tick for rebalancing
    • protocolFee: Fee charged by the protocol (in basis points)
    • maxTotalSupply: Maximum allowed LP tokens
    • accruedProtocolFees: Tracks fees collected by the protocol
  3. Important Functions:

    • deposit(): Allows users to add liquidity and receive LP tokens
    • withdraw(): Lets users withdraw their share of liquidity
    • rebalance(): Adjusts the liquidity position based on price changes
    • _poke(): Updates the vault's holdings
    • _calcSharesAndAmounts(): Calculates LP tokens based on deposits

Goal Understanding:

  • We need to get 15e18 LP tokens
  • We're given 5e18 each of token0 and token1

I suspected the following things in the protocol as the potential issues to exploit,

  • The rebalance() function has a price movement requirement (diff >= 5 * tickSpacing)
  • The _calcSharesAndAmounts() function has a potential rounding issue
  • The deposit() function's share calculation might be manipulated
  • The compiler version is solidity ^0.7.0, i.e, suceptible to integer overflows
  1. Price Manipulation:

    • The Yield contract uses the pool's price to calculate LP token shares
    • We can manipulate the pool's price by performing large swaps
    • When we swap token0 for token1, we push the price to the minimum (MIN_SQRT_RATIO)
    • When we swap token1 for token0, we push the price to the maximum (MAX_SQRT_RATIO)
  2. Share Calculation Exploit:

    • The _calcSharesAndAmounts() function calculates shares based on the current pool price
    • When the price is manipulated to extreme values, the share calculation becomes inaccurate
    • This allows us to get more LP tokens than we should for our deposit

The exploit takes advantage of the fact that the Yield contract doesn't properly handle extreme price movements in the underlying Uniswap V3 pool, allowing us to manipulate the LP token calculations to our advantage.


Oracle

P: "Michael wrote a Dex pool for USDe and USDC tokens along with their respective oracles. Then he borrowed a large position from his own pool trusting his own code. The pool is deployed with the same code and params as the actual pool at https://etherscan.io/tx/0x6f4438aa1785589e2170599053a0cdc740d8987746a4b5ad9614b6ab7bb4e550. You are given 10000 tokens of USDe and USDC. Your goal is to get 20000 of USDe.

Might help you: check the differences betweeen the current implementation and the implementation deployed at the pool creation time on mainnet"

Solution

An interesting chall, we got a Lending protocol which uses two different price oracles to get the asset price.

Lets, observe the protocol first,

  1. Oracle (Main Contract)

    • A lending protocol that allows users to deposit and borrow assets
    • Manages multiple assets with their respective price oracles
    • Handles liquidations and interest calculations
    • Uses two price oracles to determine asset values for collateralization
  2. SimplePriceOracle

    • A basic price oracle that returns a fixed price
    • Has an owner who can set the price
  3. CurvePriceOracle

    • More sophisticated oracle that gets prices from a Curve pool
    • Validates price against an anchor value
    • Can get both oracle price and spot price from the Curve pool

The challenge involves manipulating these contracts to get 20,000 USDe tokens when starting with 10,000 each of USDe and USDC. As usual let me see the initial state of this protocol,

  Player :  0xa7048127553Ead5D0408B3C8C068565d1cD46BDb
  assetCount :  2
  ---------------Asset0----------------
  asset0 address:  0x21Bbb929210149d6a849caF486ee0263404056AD
  asset0 totalDeposited0 :  10000000000000000000000
  asset0 totalBorrowed0 :  0
  asset0 baseRate0 :  1
  asset0 priceOracle :  0xaabD0F52b2743ff3AF409f3f19f8626255961699
  -----------------Asset1--------------
  asset1 address:  0xA69af9EC4689Fad31B026c973eBf6Fc68F4c326d
  asset1 totalDeposited1 :  10000000000
  asset1 totalBorrowed1 :  18500000000
  asset1 baseRate1 :  1
  asset1 priceOracle :  0x9a99f79e1517c6ca48cA5B3A1994dB98CFECC29d
  ---------------Owner-----------------
  deposited0 :  10000000000000000000000
  borrowed0 :  0
  lastInterestedBlock0 :  3566869
  deposited1 :  10000000000
  borrowed1 :  18500000000
  lastInterestedBlock1 :  3566869
  asset0 balance :  0
  asset1 balance :  18500000000

  oracle asset0 balance :  90000000000000000000000
  oracle asset1 balance :  71500000000
  ---------------Player-----------------
  deposited0 :  0
  borrowed0 :  0
  lastInterestedBlock0 :  0
  deposited1 :  0
  borrowed1 :  0
  lastInterestedBlock1 :  0
  asset0 balance :  10000000000000000000000
  asset1 balance :  10000000000
  -------------Price Oracles---------------
  simplePriceOracle:  0xaabD0F52b2743ff3AF409f3f19f8626255961699
  simplePriceOracle asset0 Price :  1000000
  curvePriceOracle (asset 1):  0x9a99f79e1517c6ca48cA5B3A1994dB98CFECC29d
  curvePriceOracle Curve Pool :  0x46206ede2b79e862D91BFa0CB4ce21EDFa7fC96f
  Curve asset1 Price :  1000000000000000000
  Curve SpotPrice :  1000000000000000000
  -------------------------------------
  token 0 in curve pool :  0x21Bbb929210149d6a849caF486ee0263404056AD
  token 1 in curve pool :  0xA69af9EC4689Fad31B026c973eBf6Fc68F4c326d

Thats a lot of info but, I need at least this much to understood this protocol. So, looking at the initial state we can confirm that there are only two tokens in the lending Oracle contract and also the CurvePool oracle has also have the same tokens. As per the statement those two assets are USDe and USDC and we are given with 10000 amount of tokens each.

Can you guess? which one is the USDe? asset0 or asset1?

It's asset0, cause it has the decimals of 18. USDe is 18 decimals and USDC is 6.

Owner has deposited 10000 of USDe, 10000 of USDC and borrowed 18500 of USDC.

What should we do?

  1. Can we directly borrow() 20000 USDe from the Oracle ? Yes If we have sufficient health factor.
  2. Can we liquidate() owners collatoral and get all his collatoral asset ? Yes If can make owners health factor worse.

Both of above options depends on the health factor, lets see how the health factor is calculated.

function calculateHealthFactor(address _user) public view returns (uint256) {
    uint256 totalCollateralInEth = 0;
    uint256 totalBorrowedInEth = 0;

    for (uint256 i = 0; i < assetCount; i++) {
        Asset storage asset = assets[i];
        UserAccount storage account = userAccounts[_user];

        uint256 collateralInEth = account.deposited[i] * getAssetPrice(i);
        uint256 borrowedInEth = account.borrowed[i] * getAssetPrice(i);

        totalCollateralInEth += collateralInEth;
        totalBorrowedInEth += borrowedInEth;
    }

    if (totalBorrowedInEth == 0) {
        return type(uint256).max;
    }

    return totalCollateralInEth * PRECISION / totalBorrowedInEth;
}

Health factor is being calculated based on the asset prices, Okay lets see how the asset price is fetched.

function getAssetPrice(uint256 _assetId) public view returns (uint256) {
    if (priceOracles[_assetId] == address(0)) {
        return 0;
    }
    return IPriceOracle(priceOracles[_assetId]).getAssetPrice(_assetId);
}

Interesting, this is using different price oracles for each asset. SimplePriceOracle is used for asset0, and CurvePoolOracle is used for asset1. SimplePriceOracle always returns the same price meaning we can't manipulate this. But the CurvePoolOracle is fetching the price using price_oracle function of it.

We have the asset1(USDC), can we directly interact with CurvePool and do some deposit kind of thing these and change the price.??

Yes Bro, that's is what the challenge is and that's called ORACLE MANIPULATION too.

Let's see is that CurvePool is vulnerable to Oracle Manipulation or not?

There was an hint in the statement, "check the differences betweeen the current implementation and the implementation deployed at the pool creation time on mainnet"

So, the CurvePool deployed at that time might have this kind of vulnerability. Following the traces of the transaction in given in the statement got me a CurveStableSwapNG Vyper contract.

Now the grinding begins, I read all the documentation about this CurveStableSwapNG from here : CurveStableSwapNG Metapool Docs.

Let me the paste the snippet that is matter to us.

@external
@view
@nonreentrant('lock')
def price_oracle(i: uint256) -> uint256:
    return self._calc_moving_average(
        self.last_prices_packed[i],
        self.ma_exp_time,
        self.ma_last_time & (2**128 - 1)
    )

@external
@nonreentrant('lock')
def remove_liquidity_imbalance(
    _amounts: DynArray[uint256, MAX_COINS],
    _max_burn_amount: uint256,
    _receiver: address = msg.sender
) -> uint256:
    """
    @notice Withdraw coins from the pool in an imbalanced amount
    @param _amounts List of amounts of underlying coins to withdraw
    @param _max_burn_amount Maximum amount of LP token to burn in the withdrawal
    @param _receiver Address that receives the withdrawn coins
    @return Actual amount of the LP token burned in the withdrawal
    """
    amp: uint256 = self._A()
    rates: DynArray[uint256, MAX_COINS] = self._stored_rates()
    old_balances: DynArray[uint256, MAX_COINS] = self._balances()
    D0: uint256 = self.get_D_mem(rates, old_balances, amp)
    new_balances: DynArray[uint256, MAX_COINS] = old_balances

    for i in range(MAX_COINS_128):

        if i == N_COINS_128:
            break

        if _amounts[i] != 0:
            new_balances[i] -= _amounts[i]
            self._transfer_out(i, _amounts[i], _receiver)

    D1: uint256 = self.get_D_mem(rates, new_balances, amp)
    base_fee: uint256 = self.fee * N_COINS / (4 * (N_COINS - 1))
    ys: uint256 = (D0 + D1) / N_COINS

    fees: DynArray[uint256, MAX_COINS] = empty(DynArray[uint256, MAX_COINS])
    dynamic_fee: uint256 = 0
    xs: uint256 = 0
    ideal_balance: uint256 = 0
    difference: uint256 = 0
    new_balance: uint256 = 0

    for i in range(MAX_COINS_128):

        if i == N_COINS_128:
            break

        ideal_balance = D1 * old_balances[i] / D0
        difference = 0
        new_balance = new_balances[i]

        if ideal_balance > new_balance:
            difference = ideal_balance - new_balance
        else:
            difference = new_balance - ideal_balance

        xs = unsafe_div(rates[i] * (old_balances[i] + new_balance), PRECISION)
        dynamic_fee = self._dynamic_fee(xs, ys, base_fee)
        fees.append(dynamic_fee * difference / FEE_DENOMINATOR)

        self.admin_balances[i] += fees[i] * admin_fee / FEE_DENOMINATOR
        new_balances[i] -= fees[i]

    D1 = self.get_D_mem(rates, new_balances, amp)  # dev: reuse D1 for new D.

    self.upkeep_oracles(new_balances, amp, D1)

    total_supply: uint256 = self.total_supply
    burn_amount: uint256 = ((D0 - D1) * total_supply / D0) + 1
    assert burn_amount > 1  # dev: zero tokens burned
    assert burn_amount <= _max_burn_amount, "Slippage screwed you"

    total_supply -= burn_amount
    self._burnFrom(msg.sender, burn_amount)

    log RemoveLiquidityImbalance(msg.sender, _amounts, fees, D1, total_supply)

    return burn_amount

I found out that price_oracle() is volatile and dependent on ma_last_time, ma_last_time. And the remove_liquidity_imbalance() will make the pool imbalance.

Thats very interesting, let me summarize what I wanted to do here. - Increase price of asset 1 by adding liquidity and removing liquidity in different blocks - and liquidate owner and get his 10000 balance of asset0 and - Borrow remaining asset 0 balance to achieve 20000 asset 0 by depositing asset 1

Thats seems simple but you need to go through a lot of grinding there.

Can't explain more, just read my messy exploit script.


Stablecoin

P : """ There is a new algorithmic stablecoin backed by ETH!

Manager owner executes the following code:

manager.addCollateralToken(IERC20(address(ETH)), new PriceFeed(), 20_000_000_000_000_000 ether, 1 ether);

ETH.mint(address(this), 2 ether); ETH.approve(address(manager), type(uint256).max); manager.manage(ETH, 2 ether, true, 3395 ether, true);

(, ERC20Signal debtToken,,,) = manager.collateralData(IERC20(address(ETH))); manager.updateSignal(debtToken, 3520 ether); You are given 6000 of ETH. Your goal is to get 50_000_000 of MIM. """

Solution

Bro, this is pain I couldn't remember what I did to solve this. Spent more than 3 days (I know I'm dumb). By doing some magic I was to solve this in the end. I'll try my best to explain that magic.

Lets break down the StableCoin protocol,

  1. Manager (Main Contract)

    • Handles adding collateral tokens, managing positions, and liquidations
    • Maintains collateral and debt signals for each position
    • Controls the MIM token minting and burning
    • Key functions:
      • manage(): Add/remove collateral and debt
      • liquidate(): Liquidate undercollateralized positions
      • addCollateralToken(): Add new collateral types
      • updateSignal(): Update collateral/debt signals
  2. Token

    • ERC20 token contract for MIM stablecoin
    • Can only be minted/burned by the Manager
  3. ERC20Signal

    • Special ERC20 implementation for protocol collateral and debt tokens
    • Uses a signal multiplier for balance calculations
    • Cannot be transferred (all transfer functions revert)
    • Key functions:
      • mint(): Mints tokens with signal adjustment
      • burn(): Burns tokens with signal adjustment
      • setSignal(): Updates the signal multiplier
  4. PriceFeed

    • Simple price oracle that returns fixed prices
    • Returns (2207 ether, 0.01 ether) for price and timestamp
  5. Stablecoin

    • Challenge contract that sets up the initial state
    • Holds references to MIM, ETH tokens, and Manager

The goal is to get 50,000,000 MIM tokens when starting with 6000 ETH.

The Manager owner did the following after protocol deployement,

  1. The protocol adds ETH as collateral with a simple price feed and very high limits
  2. Creates an initial position with 2 ETH collateral and 3395 MIM debt
  3. Updates the debt token's signal to 3520 ether (this affects debt calculations)

Same routene, initial state of the protocol.

  Stablecoin :  0xE78Ab96cb44c5dDd3d51e2B96295b27c78D102d9
  Manager :  0xbd79fCDe0e6dC4BC9984Eb5f5AD79EA86bABA0fB
  Manager Owner:  0xf8C9Fb693d7c318C19ae00ABC5d24725F6cBB0BA
  MIM :  0x7a2B13B63367219128DD46d1ab179a542C17d48a
  MIM Manager :  0xbd79fCDe0e6dC4BC9984Eb5f5AD79EA86bABA0fB
  ETH :  0xc673093EC4446A0690Aeb98105faeB8528c50693
  ETH Manager :  0xf8C9Fb693d7c318C19ae00ABC5d24725F6cBB0BA
  protocolCollateralToken :  0xfe49524fEe1b2FeF5Dff149B1A0370cff0d68972
  protocolCollateralToken Signal:  20000000000000000000000000000000000
  protocolCollateralToken totalSupply():  2000000000000000000
  protocolDebtToken :  0x89cAaD14ca4eEA0272A2654A31A56D0a509E28fF
  protocolDebtToken Signal :  1036818851251840943
  protocolDebtToken totalSupply():  3520000000000000001485
  -------------------------------
  Manager balance of ETH :  2000000000000000000
  Manager balance of MIM :  0
  Manager Owner balance of ETH :  0
  Manager Owner balance of MIM :  3395000000000000000000
  Manager Owner balance of protocolCollateralToken :  2000000000000000000
  Manager Owner balance of protocolDebtToken :  3520000000000000001485
  Player balance of ETH :  6000000000000000000000
  Player balance of MIM :  0
  Player balance of protocolCollateralToken :  0
  Player balance of protocolDebtToken :  0
  protocolCollateralToken Signal:  20000000000000000000000000000000000
  protocolDebtToken Signal :  1036818851251840943

When the Manager owner added the collatoral token as ETH, protocolCollateralToken and protocolDebtToken will be deployed. And curresponding signal values are added by the owner.

Lets do the backtracking, our goal is to get the MIM tokens, where the transfer/mint of MIM happens? In the _updateDebt() internal function which is called at once in manage().

So, need to understand what does this manage() function do. When called, it can either add or remove ETH collateral and mint or burn MIM tokens. When adding collateral, it transfers ETH from the user to the Manager and mints protocolCollateralToken to the user. When minting MIM, it creates new MIM tokens and mints protocolDebtToken to track the debt. The function uses a signal-based system where both collateral and debt tokens have signal multipliers that affect the actual balances and health calculations. The protocol checks the position's health factor after each operation to ensure proper collateralization.

    function _updateCollateral(
        IERC20 token,
        ERC20Signal protocolCollateralToken,
        uint256 collateralDelta,
        bool collateralIncrease
    ) internal {
        if (collateralDelta == 0) {
            return;
        }

        if (collateralIncrease) {
            protocolCollateralToken.mint(msg.sender, collateralDelta);
            token.safeTransferFrom(msg.sender, address(this), collateralDelta);
        } else {
            protocolCollateralToken.burn(msg.sender, collateralDelta);
            token.safeTransfer(msg.sender, collateralDelta);
        }
    }

If we observe here, While adding collatoral the collatoral token will be transferred from user to Manager and the protocolCollateralToken is also minted to track the user collatoral amount. And this collatoral amount will affects the health of the user. If we closely look at the protocolCollateralToken.mint(msg.sender, collateralDelta) line.

function mint(address to, uint256 amount) external onlyManager {
    _mint(to, amount.divUp(signal));
}

Hmm, something is interesting, its not the usual mint. mint amount is calculated by doing divUp with the signal. So, can we make this divUp() calculation to result very large amount so that our protocolCollatoralToken coallatoral will be high and we can get more mim tokens due to increase of collatoral and health factor.

Okay, now how can we do this? By modifying the signal value.

// Manager
function _updateSignals(
    IERC20 token,
    ERC20Signal protocolCollateralToken,
    ERC20Signal protocolDebtToken,
    uint256 totalDebtForCollateral
) internal {
    protocolDebtToken.setSignal(totalDebtForCollateral);
    protocolCollateralToken.setSignal(token.balanceOf(address(this)));
}

// ERC20Signal 
function setSignal(uint256 backingAmount) external onlyManager {
    uint256 supply = ERC20.totalSupply();
    uint256 newSignal = (backingAmount == 0 && supply == 0) ? ProtocolMath.ONE : backingAmount.divUp(supply);
    signal = newSignal;
}

The _updateSignals() is called inside the liquidate() function. So, liquidating the manager owner will update the signals. The protocolCollatoralToken signal is updated with the value of token.balanceOf(address(this)), token here is ETH. So, since it is calculating the balance of address(this), i.e manager. We can donate ETH to manager by doing eth.transfer(address(manager), (large ETH). Now this causes an undefined behaviour in setSignal() function and the backingAmount.divUp(supply) will execute which make the signal value very low than compared to the initial one.

So, we succeeded in manipulating the protocolCollatoralToken signal. Now the signal of the protocolCollatoralToken is smaller (at least less than 1e18). Now what happens if we call the manage() again with very small increase in collatoralToken?

Lets say, we called manage(eth, 1 , true, 0, false). Now ultimately the following mint() will execute. So, now the signal showing up here is the manipulated one (we reduced it to less than 1e18).

function mint(address to, uint256 amount) external onlyManager {
    _mint(to, amount.divUp(signal));
}

function divUp(uint256 a, uint256 b) internal pure returns (uint256) {
    if (a == 0) {
        return 0;
    } else {
        return (((a * ONE) - 1) / b) + 1;
    }
}

So, the result from the divUp() will be very high. i.e, we are minting more protocolCollatoralToken by only sending only 1 wei of ETH. But this manage() with only 1 wei collatoral increase should be done for several times till we got the good health factor. Once we have the very good health factor and we can able to borrow all 50,000,000 MIM in one go.

Find my messy exploit below,


Bridge

P: "You've stumbled upon a cross-chain bridge contract, enabling ETH and ERC20 token transfers between chains. The Bridge contract has 100 ether of flag token. You are given 1 ether of flag token. Your goal is to drain Bridge contract below 90 ether."

Solution

Yes man, bridges.. More interesting and I personally I love bridges. Let's do this as quick as possible.

Lets, break down the Bridge protocol.

  1. Bridge (Main Contract)

    • Handles cross-chain messaging and token transfers
    • Manages remote bridge registrations and token registrations
    • Key functions:
      • ethOut/ethIn: Handles ETH transfers between chains
      • ERC20Out/ERC20In: Handles ERC20 token transfers
      • ERC20Register: Registers new tokens on remote chains
      • sendRemoteMessage: Core function for sending messages between chains
  2. BridgedERC20

    • Special ERC20 token for cross-chain transfers
    • Can only be minted by remote bridge and burned by local bridge
    • Tracks the remote token address and bridge contracts
    • Implements strict access controls for minting/burning
  3. Token

    • Simple ERC777 token used in the challenge
    • Mints initial tokens to deployer and user

This is not a complete bridge protocol because there is no off-chain componets like relayers, etc. But all the things were replicated in the smart contract itself. Because of this it's confusing to understand which one is source contract and which one is destination contract.

Just follow me, remoteBridge means destination bridge, ethOut() or ERC20Out() means that the source contract is sending to destination contract. ethIn() or ERC20In() are the functions which usually called by off-chain components like relayer but here the source contract directly calls these functions on destination contract. Here ethIn() or ERC20In() are restricted to be only calleable by the remote bridge. ERC20Register() is to deploy a equivalent token (wrapped) on the destination for a token on source chain. Here, If the token was not registered the on the first bridging of that token the registration and the deployment of wrapped token will be done automatically. The wrapped token which is going to be deployed for a token on source chain is BridgedERC20 token. Token we are going to bridge is Token an ERC777.

Usually asset moving bridges will follow following modes of bridging.

  • Lock asset on source chain then Mint a wrapped asset on destination
  • Burn and Mint
  • Lock and Release
  • Burn and Mint

Here in this protocol the Lock and Mint in forward direction and when the same Wrapped token bridged back to source then the Burn and Release happens. (I'd love to explain all these in a dedicated blog post)

sendRemoteMessage() function will log a message to be picked up by the off-chain relayer and send it to destination. But here all this relayer functionality was implemented in this function itself. It was restricted to be calling from by anyone else except the same contract functions with the following check. If this check was not there we could've simply call this to perform an attack. But no luck, we can't do this.

require(msg.sender == address(this), "S");

But the sendRemoteMessage() function is called by the ethOut() or ERC20Out() and then the call goes to ethIn() or ERC20In().

USER -> ethOut()/ERC20Out()  -> sendRemoteMessage() -> ethIn()/ERC20In() -> USER (mint/release tokens)

Let's get the initial state of the protocol,

Bridge:  0xB4a8227E3312F40Ad03fbe7f747da61266EDC0Ba
FLAG_TOKEN:  0x7a072D0a5C338679Da17C4922C364c03167D1fB2 (ERC777)
Player balance of FLAG :  1000000000000000000
SOURCE Bridge balance of FLAG :  100000000000000000000
SOURCE CHAIN_ID :  1
Total Default operators of FLAG :  0
REMOTE CHAIN_ID :  2
REMOTE Bridge:  0xd73fFbbd87624b59e166717676F0e10135C9fe3B
REMOTE Bridge balance:  0

Expected initial data..

How can we bridge the 1e18 of ERC777 Token that we got? By calling ERC20Out()

function ERC20Out(address _token, address _to, uint256 _amount) external {
        emit ERC20_transfer(_token, _to, _amount);
        uint256 _remoteChainId = CHAIN_ID == 1 ? 2 : 1;
        address _remoteBridge = remoteBridge[_remoteChainId];
        if (isBridgedERC20[_token]) {
            BridgedERC20(_token).burn(msg.sender, _amount);
            _token = BridgedERC20(_token).REMOTE_TOKEN();
        } else {
            uint256 balance = IERC20(_token).balanceOf(address(this));
            require(IERC20(_token).transferFrom(msg.sender, address(this), _amount), "T");
            _amount = IERC20(_token).balanceOf(address(this)) - balance;
            if (!isTokenRegisteredAtRemote[_remoteChainId][_token]) {
                this.sendRemoteMessage(
                    _remoteChainId,
                    _remoteBridge,
                    abi.encodeWithSelector(
                        Bridge.ERC20Register.selector,
                        _token,
                        IERC20Metadata(_token).name(),
                        IERC20Metadata(_token).symbol()
                    )
                );
                isTokenRegisteredAtRemote[_remoteChainId][_token] = true;
            }
        }
        this.sendRemoteMessage(
            _remoteChainId, _remoteBridge, abi.encodeWithSelector(Bridge.ERC20In.selector, _token, _to, _amount)
        );
    }

Observing the above function, if the token that we are sending is BridgedERC20 then the burn happens. If not the lock happens. Nothing exciting in the if block. But in the else block the lock of our token happens (ERC777).

Can you see the problem of these three lines???

uint256 balance = IERC20(_token).balanceOf(address(this));
require(IERC20(_token).transferFrom(msg.sender, address(this), _amount), "T");
_amount = IERC20(_token).balanceOf(address(this)) - balance;

I can see it.. The balance is fetched on line 1 then same balance is used after the trnasferFrom() call. What is anybody can reenter from that transferFrom call?? With standard ERC20 token transfers it's not possible. But it is possible from the ERC777 transfers bacause of an extra feature called Hooks and Callbacks.

"Hooks in ERC777 tokens serve as entry points for custom code execution during token transfers. They allow external smart contracts to intervene in the token transfer process, either before or after the transfer occurs. This flexibility is a double-edged sword, as it can be used for legitimate purposes but also exploited for malicious actions." - Johny

ERC777

The following are the functions of ERC777 standard. In the transferFrom() the contract will call the _send() hook, there in the hook if the sender is registered a IERC777Sender interface implementer in the _ERC1820_REGISTRY contract then the hook will call the tokensToSend() function on the implementor. Here the user is the sender but the implementor is someother contract registered by the user as his IERC777Sender implementor. Look at the following control flow for better understanding.

User -> Deploys a contract -> Declares the contract as willing to be an implementer 
User -> transferFrom() -> _send() -> _callTokensToSend() -> user Implementor.tokensToSend()

Okay, enough reconnaissance. Now we know the it is possible to reenter to back to the ERC20Out() function with callback hooks of ERC777 via the ERC-1820 registry,

    function transferFrom(
        address holder,
        address recipient,
        uint256 amount
    ) public virtual override returns (bool) {
        address spender = _msgSender();
        _spendAllowance(holder, spender, amount);
        _send(holder, recipient, amount, "", "", false);
        return true;
    }

        function _send(
        address from,
        address to,
        uint256 amount,
        bytes memory userData,
        bytes memory operatorData,
        bool requireReceptionAck
    ) internal virtual {
        require(from != address(0), "ERC777: transfer from the zero address");
        require(to != address(0), "ERC777: transfer to the zero address");

        address operator = _msgSender();

        _callTokensToSend(operator, from, to, amount, userData, operatorData);

        _move(operator, from, to, amount, userData, operatorData);

        _callTokensReceived(operator, from, to, amount, userData, operatorData, requireReceptionAck);
    }

    function _callTokensToSend(
        address operator,
        address from,
        address to,
        uint256 amount,
        bytes memory userData,
        bytes memory operatorData
    ) private {
        address implementer = _ERC1820_REGISTRY.getInterfaceImplementer(from, _TOKENS_SENDER_INTERFACE_HASH);
        if (implementer != address(0)) {
            IERC777Sender(implementer).tokensToSend(operator, from, to, amount, userData, operatorData);
        }
    }

Attack

  • Deploy an Attacker contract and register this attacker contract as the Implementer for the Player.

  • Send 0.5 ether amount of ERC777 tokens to Attack

  • Inside tokensToSend(operator, from, to, amount, userData, operatorData) function of Attack contract, add the following logic

    • reenter to the ERC20Out() function by sending same amount again.
  • Start the attack by calling ERC20Out() function with amount 0.5 ether.

  • The following vulnerable lines of code will execute

        uint256 balance = IERC20(_token).balanceOf(address(this));  // 100 ether
        require(IERC20(_token).transferFrom(msg.sender, address(this), _amount), "T"); // Call to Attack.tokensToSend()
        _amount = IERC20(_token).balanceOf(address(this)) - balance;
    
  • Attack contract wil reenter the function with amount 0.5 ether, but the bridge balance is still 100 ether.

  • Second tranferFrom call from Attack will be succeeded and Attack contract will get 0.5 ether of BridgedERC20 tokens. Bridge also gets 0.5 ether of ERC777 tokens.

  • First transferFrom call completes will get 0.5 ether of BridgedERC20 tokens. Bridge also gets 0.5 ether of ERC777 tokens. Bridge balance is now 101 ether

  • Now on the third line _amount = IERC20(_token).balanceOf(address(this)) - balance;

    • _amount = 101 ether - 100 ether = 1 ether
  • Now the 1 ether of BridgedERC20 will be minted to Player.

  • If we bridge these tokens back, BridgedERC20 will be burned and ERC777(FLAG) tokens will be sent to Player.

  • After one successfull iteration of these steps we got 0.5 ether of more tokens than we have.

Now do you own math and find a way to execute this logic until you got atleast 10 ether of ERC777 tokens or FLAG tokens.

Don't look at my following exploit, I did a terrible math there.


Exchange

P: "You heard there is a new Dex primitive that has launched on-chain with a lot of tokens. As a whitehat hacker, you race to find any bugs before the blackhats do. Can you rescue the tokens from the contract?"

Solution

What does this protocol is doing? Let's break it down

  1. Setup Contract

    • Initializes the exchange with initial liquidity
    • Creates three tokens (token1, token2, token3) with balances:
      • token1: 300,000 tokens
      • token2: 300,000 tokens
      • token3: 600,000 tokens
    • Adds initial liquidity pairs:
      • token1/token2: 100,000 each
      • token1/token3: 100,000/200,000
      • token2/token3: 100,000/200,000
  2. Exchange Contract

    • Core DEX functionality with unique swap mechanism
    • Key components:
      • Pool: Tracks reserves for token pairs
      • SwapState: Manages ongoing swap states and positions
      • SavedBalance: Tracks balance snapshots during swaps
    • Main functions:
      • addLiquidity(): Add tokens to pools
      • swap(): Initiates a swap transaction
      • swapTokens(): Performs the actual token swap
      • withdraw(): Withdraws tokens during a swap
      • initiateTransfer/finalizeTransfer: Two-step transfer process

As usual what's the initial state of the protocol??

Player :  0xa7048127553Ead5D0408B3C8C068565d1cD46BDb
Setup :  0xd9beE8f7dF07fd718f54ed05CAD77FC0EF1F9A7B
Exchange :  0xb3CE3E482D1caf5b444f3f6b95a9d8799f6dac11
Token1 :  0xa95A2a693880626911bb521CB50b7DC7Caa0EC05
Token2 :  0x601C3EA942c5Eae7301C39c95342307a17cEc0B7
Token3 :  0xd5e4b9f37E1b51D18CD2f281B85DCDC07b4540a1
isSolved() :  false
Exchange balance Token1 :  200000
Exchange balance Token2 :  200000
Exchange balance Token3 :  400000
Player balance Token1 :  0
Player balance Token2 :  0
Player balance Token3 :  0

Okay, Goal is to drain all tokens from the exchange. Lets do this.

We need to find the function where the token amount is being sent to us. It is the withdraw() function.

function withdraw(address token, uint256 amount) public duringSwap {
    require(allowedTokens[token], "token not allowed");

    Token(token).transfer(msg.sender, amount);
    updatePosition(token, -int256(amount));
}

The withdraw function is optimistically sending the amount we are requesting directly to the caller and then updating the position, but first of all the swap should begin (duringSwap). For this we can call swap() functions to start the swap.

function swap() external {
    SwapState storage swapState = getSwapState();

    require(!swapState.hasBegun, "swap already in progress");
    swapState.hasBegun = true;

    SwapCallback(msg.sender).doSwap();

    require(swapState.unsettledTokens == 0, "not settled");
    nonce += 1;
}

If we call the swap() function it will callback the doSwap() function on the caller. So, Now we can call the withdraw() function inside the callback of doSwap().

Let's see what happens if we withdraw all the 200000 tokens of Token1. The withdraw function will send all the 200000 token1 to us but it updates our position.

withdraw( token1, 200000 ) {
    Token(token).transfer(msg.sender, 200000);
    updatePosition(token1, -int256(200000));
}

function updatePosition(address token, int256 amount) internal {
    require(allowedTokens[token], "token not allowed");

    SwapState storage swapState = getSwapState();

    int256 currentPosition = swapState.positions[token];
    int256 newPosition = currentPosition + amount;

    if (newPosition == 0) swapState.unsettledTokens -= 1;
    else if (currentPosition == 0) swapState.unsettledTokens += 1;

    swapState.positions[token] = newPosition;
}

Now our newPosition becomes 200000 and the swapState.unsettledTokens = 1 will be updated. So, we need to settle these unsettledTokens before completing this swap. If we observe the swapTokens() function where we can do swap and the positions are also being updated there.

function swapTokens(address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut) public duringSwap {
    require(allowedTokens[tokenIn], "token not allowed");
    require(allowedTokens[tokenOut], "token not allowed");

    uint256 liquidityBefore = getLiquidity(tokenIn, tokenOut);

    require(liquidityBefore > 0, "no liquidity");

    uint256 newReservesIn = getReserves(tokenIn, tokenOut) + amountIn;
    uint256 newReservesOut = getReserves(tokenOut, tokenIn) - amountOut;

    setReserves(tokenIn, tokenOut, newReservesIn);
    setReserves(tokenOut, tokenIn, newReservesOut);

    uint256 liquidityAfter = getLiquidity(tokenIn, tokenOut);

    updatePosition(tokenIn, -int256(amountIn));
    updatePosition(tokenOut, int256(amountOut));

    require(liquidityAfter >= liquidityBefore, "insufficient liquidity");
}

The amountIn to the swapTokens() will be deducted from the current position.

    updatePosition(tokenIn, -int256(amountIn));
    updatePosition(tokenOut, int256(amountOut));

So lets do these steps,

Attack1.doSwap() {
    exchange.withdraw(address(token1), 200000);
    // 200000 token1 drained
    // updatePosition() in withdraw:
    // - currentPosition = 0
    // - newPosition = 0 - 200000 = -200000
    // - swapState.unsettledTokens = 1
    // - swapState.positions[token1] = -200000
    exchange.swapTokens(address(token1), address(token2), 200000, 0);
    // updatePosition() for token1 in swapTokens:
    // - currentPosition = -200000
    // - newPosition =  -200000 + (-200000)= -400000
    // - swapState.unsettledTokens = 1 (currentPosition!=0 || newPosition!=0)
    // - swapState.positions[token1] = -400000

    // updatePosition() for token2 in swapTokens:
    // - currentPosition = 0
    // - newPosition =  0 + 0 = 0
    // - swapState.unsettledTokens = 0 (newPosition==0)
    // - swapState.positions[token2] = 0 (because amountOut = 0)
    // @notice: Here we can observe some inconsistency between state postions and unsettled tokens.

    exchange.withdraw(address(token2), 200000);
    // 200000 token2 drained
    // updatePosition() in withdraw:
    // - currentPosition = 0
    // - newPosition = 0 - 200000 = -200000
    // - swapState.unsettledTokens = 1 (currentPosition==0)
    // - swapState.positions[token2] = -200000

    exchange.swapTokens(address(token2), address(token3), 200000, 0);
    // updatePosition() for token2 in swapTokens:
    // - currentPosition = -200000
    // - newPosition =  -200000 + (-200000)= -400000
    // - swapState.unsettledTokens = 1 (currentPosition!=0 || newPosition!=0)
    // - swapState.positions[token2] = -400000

    // updatePosition() for token3 in swapTokens:
    // - currentPosition = 0
    // - newPosition =  0 + 0 = 0
    // - swapState.unsettledTokens = 0 (newPosition==0)
    // - swapState.positions[token3] = 0 (because amountOut = 0)
}

Attack2.doSwap(){
    exchange.withdraw(address(token3), 400000);
    // 400000 token3 drained
    // updatePosition() in withdraw:
    // - currentPosition = 0
    // - newPosition = 0 - 400000 = -400000
    // - swapState.unsettledTokens = 1 (currentPosition==0)
    // - swapState.positions[token3] = -400000

    exchange.swapTokens(address(token3), address(token1), 400000, 0);
    // updatePosition() for token3 in swapTokens:
    // - currentPosition = -400000
    // - newPosition =  -400000 + (-400000)= -800000
    // - swapState.unsettledTokens = 1 (currentPosition!=0 || newPosition!=0)
    // - swapState.positions[token3] = -800000

    // updatePosition() for token1 in swapTokens:
    // - currentPosition = 0
    // - newPosition =  0 + 0 = 0
    // - swapState.unsettledTokens = 0 (newPosition==0)
    // - swapState.positions[token1] = 0 (because amountOut = 0)
}

Don't ask me anything please follow the math explained above. Th issues I see here are,

  • Allowing withdraw() during swap
  • nonce being updated after the swap
  • Inconsistency between swapState.positions and swapState.unsettledTokens.
  • Only handling the cases where the currentPosition == 0 || newPosition == 0.

Fallout

P: "In the aftermath of the Great War, the world lies shattered, but hope endures in the form of Nuka-Cola Caps, the currency of the wasteland. Your mission, should you choose to accept it, is to obtain 1,000,000 Nuka-Cola Caps and secure your place as a true survivor in the barren expanse of post-apocalyptic America."

Solution

Aaah.. Mathematics, Cryptography and Smart contracts and elite combination here. The goal is very clear here we need to call the mint() function with the amount 1_000_000 ether. The challenge for us is to pass the signature verification.

So, what the heck is the math is doing in the solidity smart contract? Well it is an Elliptic Curve Cryptography scheme implementation. To be precisely it is a SECP256R1 curve.

Now, first learn a bit about the ECC and how an implementation looks.

Elliptic Curve Cryptography

Elliptic Curve Cryptography (ECC) is an asymmetric cryptographic that provides the same level of security as RSA or discrete logarithm systems with considerably shorter operands (approximately 160–256 bit vs. 1024–3072 bit). An elliptic curve is a special type of polynomial equation. For cryptographic use, we need to consider the curve not over the real numbers but over a finite field.

This is how an ECC equations looks like,

\( E:Y^2 = X^3+aX+b \)

There is point P, 2P and a straight line marked on the graph. These are the operations we can perform on Elliptic Curves. Addition of two points (P, Q) will result point R. IF we double the same point (P+P) will result in a 2P. If I did this point addition for n times, i.e, \( Q = n*P \). Now I'll give you the values of Q,P can you find what is n? This is where the ECC security lies.

Let's see how a secure signing and verification process looks like,

ECDSA Signing Process

A user selects a private key \( d \) where \( 1 \leq d < n \) and computes the public key: \( Q = d \cdot G \)

  1. Compute the message hash \( z \) (typically \( z = H(m) \), using SHA-256).

  2. Select a random integer \( k \) where \( 1 \leq k < n \).

  3. Compute the elliptic curve point:

    \[ (x_1, y_1) = k \cdot G \]

  4. Compute the first signature component:

    \[ r = x_1 \mod n \]

    If \( r = 0 \), choose a new \( k \) and repeat.

  5. Compute the second signature component:

    \[ s = k^{-1} (z + r d) \mod n \]

    If \( s = 0 \), choose a new \( k \) and repeat.

  6. The signature is \( (r, s) \).

ECDSA Verification Process

Given a signature \( (r, s) \) and public key \( Q \):

  1. Compute the message hash \( z = H(m) \).

  2. Compute the modular inverse of \( s \) modulo \( n \):

    \[ s^{-1} \mod n \]

  3. Compute:

    \[ u_1 = z s^{-1} \mod n \]

    \[ u_2 = r s^{-1} \mod n \]

  4. Compute the elliptic curve point:

    \[ (x', y') = u_1 G + u_2 Q \]

  5. Compute \( x' \mod n \) and check:

    \[ x' \equiv r \mod n \]

    If true, the signature is valid.

Attack

Lets get all the values and point details from the protocol.

Fallout :  0xf96C8C1685180b9551f86952992baAA220E7C91C
Vault :  0x11e44e424A85203E1208097128B9B1e897C8A9A9
Qx =  228372021298333142209829245091882548944496316312635232236
Qy =  3693481507636668030082911526987394375826206080991036294396
a =  479674765111403080798288599752794621357071126054239970719
b =  1839890679886286542886449861618094502587090720247817035647
gx =  741691539696267564005241324344676638704819822626281227364
gy =  3102360199939373249439960210926161310269296148717758328237
p =  4007911249843509079694969957202343357280666055654537667969

Enough equtions, now compare the current solidity implementation of the verification process with the above equations.

function validateSignature(bytes32 message, uint[2] memory rs, uint[2] memory Q) public view
    returns (bool)
{
    // To disambiguate between public key solutions, include comment below.
    if(rs[0] == 0 || rs[0] >= p || rs[1] == 0) {// || rs[1] > lowSmax)
        return false;
    }
    if (!isOnCurve(Q[0], Q[1])) {
        return false;
    }
    uint x1;
    uint x2;
    uint y1;
    uint y2;
    uint sInv = inverseMod(rs[1], p);
    (x1, y1) = multiplyScalar(gx, gy, mulmod(uint(message), sInv, p));
    (x2, y2) = multiplyScalar(Q[0], Q[1], mulmod(rs[0], sInv, p));
    uint[3] memory P = addAndReturnProjectivePoint(x1, y1, x2, y2);
    if (P[2] == 0) {
        return false;
    }
    uint Px = inverseMod(P[2], p);
    Px = mulmod(P[0], mulmod(Px, Px, p), p);
    return Px % p == rs[0];
}

The sInv is computed using mod n. u1(x1, y1) and u2(x1, y1) are also computed over mod n and suprisingly we didn't got n value from the protocol. So, something is fishy...

n is the order of the curve. We can compute this by doing the following.

\[ Ep = EllipticCurve(GF(p), [a,b]) \]

\[ n = Ep.order()\]

\[ n = 4007911249843509079694969957202343357280666055654537667969\]

Interesting, \[ p == n \]

These kind of ECC curves are called as Anomalous Curves. It is easy to solve the ECDLP in linear time when the underlying elliptic curve is anomalous, i.e. when the number of rational points on Fp is equal to the prime number p. There was a research paper named Generating Anomalous Elliptic Curves published on how to do this. This paper explained an attack called Smart to solve ECDLP of Anomalous Curves.

So, now with the Smart attack we can compute the Private key from the given values. So, once the we compute the Private Key we need to generate the message which we are going to sign and pass to the mint function.

bytes32 message = keccak256(abi.encode(player, 1_000_000 ether));

Now we need to sign the above message with the computed private key and pass the signature to mint() function. That's all.

Python script to perform Smart Attack

Solidity script to call mint().


Chef

I learned Huff programming just to solve this challenge. This challenge deserves a dedicated blog, read it here : Learn Huff by solving a CTF challenge


Kudos to you for sticking with me till the end and hope you've learned something from this.

References

  1. Anti Proxy Patterns
  2. Oracle Manipulation
  3. Uniswap V3 Concentrated Liquidity
  4. Oracles
  5. Stable Coins
  6. CurveStableSwapNG Metapool Docs
  7. Elliptic Curve For Developers
  8. ECDSA Handle with care
  9. Generating Anomalous Elliptic Curves