Flash loans and oracle manipulation: the bZx attacks (2020)#

A lender must know what its collateral is worth, and on a blockchain the easiest answer is the spot price of a decentralized exchange. In February 2020, attackers took flash loans, used them to move such a price within a single transaction, and borrowed from the bZx protocol against collateral it then overvalued, walking away with the difference.

The mechanism fits in one inequality. Borrowing \(U\) and buying the collateral token from a pool with reserves \((x, y)\) raises its spot price to the point where the tokens bought are valued at \(U (y + U) / y\). A lender requiring 150% collateral then lends

\[\frac{U (y + U)}{1.5\, y} > U \quad \text{whenever} \quad U > \frac{y}{2},\]

so the flash loan is repaid from the new loan, with a profit, and the lender keeps collateral worth far less than it lent. A price recorded before the transaction, the idea behind time-weighted averages, cannot be moved by a flash loan.

import matplotlib.pyplot as plt

import blockchainkit as bk
from blockchainkit.contracts.visualizers import plot_call_tree

A pool, a lender, and a flash lender#

def stage(delayed, loan=300_000):
    world = bk.contracts.World()
    tok = world.deploy("dex", bk.contracts.ERC20, 10**6, name="TOK")
    usd = world.deploy("bank", bk.contracts.ERC20, 10**7, name="USD")
    world.transact("bank", usd, "transfer", "dex", 100_000)
    pool = world.deploy("dex", bk.economics.ConstantProductPool, tok, usd, name="pool")
    for token in (tok, usd):
        world.transact("dex", token, "approve", pool, 10**6)
    world.transact("dex", pool, "add_liquidity", 100_000, 100_000)  # TOK at 1 USD.
    lender = world.deploy("bank", bk.economics.OracleLender, tok, usd, pool, delayed, name="lender")
    world.transact("bank", usd, "transfer", lender, 2_000_000)
    flash = world.deploy("bank", bk.contracts.FlashLender, usd, name="flash lender")
    world.transact("bank", usd, "transfer", flash, 2_000_000)
    world.advance()
    attacker = world.deploy("eve", bk.economics.OracleAttacker, name="attack contract")
    receipt = world.transact("eve", attacker, "attack", flash, pool, lender, loan)
    return world, usd, lender, receipt


world, usd, lender, attack = stage(delayed=False)
profit = world.view(usd, "balance_of", "eve")
print(f"spot-price oracle: {attack.success}, attacker keeps {profit:,} USD")
assert attack.success and profit > 400_000

safe_world, _, _, defended = stage(delayed=True)
print("price from before the block:", defended.error)
assert defended.error == "undercollateralized"

fig, ax = plt.subplots(figsize=(10, 6))
plot_call_tree(attack, names=world.name, ax=ax)
ax.set_title("One transaction: borrow, pump, over-borrow, repay. " + ax.get_title())
fig.tight_layout()
One transaction: borrow, pump, over-borrow, repay. Call tree: 21 calls, 210,550 gas, succeeded
spot-price oracle: True, attacker keeps 497,573 USD
price from before the block: undercollateralized

Profit against the size of the flash loan#

loans = [25_000 * i for i in range(1, 17)]
profits = []
for loan in loans:
    w, token, _, receipt = stage(delayed=False, loan=loan)
    profits.append(w.view(token, "balance_of", "eve") if receipt.success else 0)
predicted = [max(0, u * (100_000 + u) / 150_000 - u) for u in loans]
assert profits[0] == 0 and profits[-1] > 0  # Small loans do not pay for themselves.

fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(loans, profits, "o", color="#dc2626", label="simulated (0.3% fee)")
ax.plot(loans, predicted, color="black", label="U (y + U) / 1.5 y - U, no fee")
ax.axvline(50_000, color="#64748b", linestyle=":", label="U = y / 2")
ax.set(xlabel="flash loan U (USD)", ylabel="attacker profit (USD)")
ax.set_title("A deeper pool makes the attack costlier")
ax.legend()
fig.tight_layout()

plt.show()
A deeper pool makes the attack costlier

Exercise#

Double the pool’s reserves. How large must the flash loan now be for the attack to pay, and how much would it have to cost to borrow for a profitable attack to remain?

Total running time of the script: (0 minutes 0.139 seconds)

Gallery generated by Sphinx-Gallery