The Donation That Skipped the Health Check — JavaScript Bug Hunt

Modelled on the Euler Finance exploit of 13 March 2023: the lending protocol's donateToReserves function let an account give away its collateral without the…

  • Language: JavaScript
  • Layer: Backend
  • Difficulty: Medium
  • Concepts: Money, Validation
  • Modelled on: Euler Finance · 2023
  • Visible tests: a donation that keeps the account healthy goes through; a donation that would leave the account unhealthy reverts
  • Reward: 50 XP for a complete fix

Briefing

Modelled on the Euler Finance exploit of 13 March 2023: the lending protocol's donateToReserves function let an account give away its collateral without the account-health (liquidity) check that other balance-reducing actions performed. The attacker used leverage, donated enough to leave the account under-collateralised, then liquidated it from a second account at a discount, taking about $197 million. The funds were later returned.

This project is a reconstruction. In pool.js, withdraw refuses to leave an account unhealthy; donateToReserves reduces collateral just the same, with no such check.

Fix donateToReserves so it can never leave an account unhealthy.

Bug report

BUG-EULER · Priority: Critical · Reported by: protocol security

Every action that reduces an account's collateral follows one rule:

  • amount must be > 0 and <= the account's collateral, else throw
  • after the change the account must satisfy risk.isHealthy; if it would not, throw and leave the account AND the pool exactly as they were

donateToReserves(pool, id, amount) additionally adds amount to pool.reserves when it succeeds, and returns the account.

Observed: donateToReserves lets an account push itself below the health line, after which it can be liquidated at a profit.

Logs

[pool] donate acct=0x5f amount=100000000 collateral->310000000 debt=390000000
[risk] acct=0x5f health=0.79 -> liquidatable

The code as shipped

src/lending/pool.js (editable)

var risk = require("./risk");

exports.create = function () {
  return { reserves: 0, accounts: {} };
};

exports.open = function (pool, id, collateral, debt) {
  pool.accounts[id] = { collateral: collateral, debt: debt };
  return pool.accounts[id];
};

exports.withdraw = function (pool, id, amount) {
  var acc = pool.accounts[id];
  if (amount <= 0 || amount > acc.collateral) throw new Error("bad amount");
  acc.collateral -= amount;
  if (!risk.isHealthy(acc)) {
    acc.collateral += amount;
    throw new Error("e/collateral-violation");
  }
  return acc;
};

exports.donateToReserves = function (pool, id, amount) {
  var acc = pool.accounts[id];
  if (amount <= 0 || amount > acc.collateral) throw new Error("bad amount");
  acc.collateral -= amount;
  pool.reserves += amount;
  return acc;
};

Read-only context: src/lending/risk.js.

Open the hunt to edit the files, run the visible tests and submit against the hidden ones. More JavaScript bug hunts.