Note
Go to the end to download the full example code or to run this example in your browser via JupyterLite.
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
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()

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()

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)