Solving Ethernaut - Part 4 - Levels 16 to 20
Overview
Fourth part of the solutions to Ethernaut. This time the challenges get harder and more interesting.
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 16: Preservation
A contract that relies on two libraries to keep track of time in two different timezones. The goal is to become the owner of the contract.
Contract analysis
The Preservation
contract implements two functions to set the time for each one of the libraries. setFirstTime(uint)
and setSecondTime(uint)
are almost identical, they make a delegatecall
to each library that calls the setTime(uint256)
function of the target contract.
There are four storage slots used:
- Slot 0:
address timeZone1Library
- Slot 1:
address timeZone2Library
- Slot 2:
address owner
- Slot 3:
uint storedTime
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.6.0;
3
4contract Preservation {
5
6 // public library contracts
7 address public timeZone1Library;
8 address public timeZone2Library;
9 address public owner;
10 uint storedTime;
11 // Sets the function signature for delegatecall
12 bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));
13
14 constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) public {
15 timeZone1Library = _timeZone1LibraryAddress;
16 timeZone2Library = _timeZone2LibraryAddress;
17 owner = msg.sender;
18 }
19
20 // set the time for timezone 1
21 function setFirstTime(uint _timeStamp) public {
22 timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
23 }
24
25 // set the time for timezone 2
26 function setSecondTime(uint _timeStamp) public {
27 timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
28 }
29}
30
31// Simple library contract to set the time
32contract LibraryContract {
33
34 // stores a timestamp
35 uint storedTime;
36
37 function setTime(uint _time) public {
38 storedTime = _time;
39 }
40}
The LibraryContract
implements the setTime(uint)
function that will be called by the setXxxTime(uint)
functions of the Preservation
contract.
Vulnerability / Attack vector
The use of delegatecall
on an untrusted address can have terrible consequences. Remember that delegatecall
executes code of another contract in the current contract context. Level 06 - Delegation was a simpler challenge with a similar exploit.
Solution
Since the libraries are delegated, the context is preserved and so they can modify the caller's storage. The goal is to modify the owner
variable in storage slot 2, and set it to the player's address.
The solution comes in three stages:
- Create a contract that implements a
setTime(uint)
function, with the code needed to modify storage slot 2. - Modify
timeZone1Library
address by callingsetSecondTime(uint)
, and set it to the contract deployed in the previous stage - Call
setFirstTime(uint)
to overwrite theowner
variable
For the first step, the attacker contract used was the following:
1contract PreservationAttack {
2
3 uint storedTime;
4 uint not_important;
5 address owner;
6
7 function setTime(uint _time) public {
8 storedTime = _time;
9 owner = msg.sender;
10 }
11}
It creates enough dummy variables to be able to overwrite storage slot 2. As a bonus, it implements the expected behavior of the library.
Next step, call setSecondTime(uint)
with the address of the deployed attacker as argument. This will effectively overwrite storage slot 0 of the Preservation
instance, and allow the player to call the attacker in the next stage.
Finally, call setFirstTime(uint)
. The argument is not really important, but whatever is sent will be set as the address timeZone1Library
.
Conclusions
This level was a review of the delegated calls, caller contexts and storage. An interesting fact of the level is the way that the delegatecall
is made, by crafting the function signature in the same contract (specifically in line 12 of the Preservation
contract listing).
Level 17: Recovery
A simple token factory. The problem is that the deployed contract address was lost, so apparently there is no way to recover the lost half ether. The goal is to recover the 0.5 ethers, or remove them from the contract somehow.
Contract analysis
The interesting part of the contract, and the one that is needed to solve the level is the destroy(address)
function. It calls selfdestruct
with the supplied argument, so it can transfer the contract balance to a user-supplied address.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.6.0;
3
4import '@openzeppelin/contracts/math/SafeMath.sol';
5
6contract Recovery {
7
8 //generate tokens
9 function generateToken(string memory _name, uint256 _initialSupply) public {
10 new SimpleToken(_name, msg.sender, _initialSupply);
11
12 }
13}
14
15contract SimpleToken {
16
17 using SafeMath for uint256;
18 // public variables
19 string public name;
20 mapping (address => uint) public balances;
21
22 // constructor
23 constructor(string memory _name, address _creator, uint256 _initialSupply) public {
24 name = _name;
25 balances[_creator] = _initialSupply;
26 }
27
28 // collect ether in return for tokens
29 receive() external payable {
30 balances[msg.sender] = msg.value.mul(10);
31 }
32
33 // allow transfers of tokens
34 function transfer(address _to, uint _amount) public {
35 require(balances[msg.sender] >= _amount);
36 balances[msg.sender] = balances[msg.sender].sub(_amount);
37 balances[_to] = _amount;
38 }
39
40 // clean up after ourselves
41 function destroy(address payable _to) public {
42 selfdestruct(_to);
43 }
44}
Vulnerability / Attack vector
Not a vulnerability. The goal is to find the contract address, that is hidden somewhere in the blockchain. "Hidden".
Solution
The contract deployment receipt shows two interactions with other contracts, as shown in the image below:
The underlined address has the 0.5 ethers, so calling its destroy(address)
function with the player's address as argument should get the funds back and solve the level.
Conclusions
Another proof that there are no secrets on the blockchain. Everything is visible.
Level 18: MagicNumber
This level implements a new form of challenge. You should deploy a contract whose whatIsTheMeaningOfLife()
function returns 0x42
, but with the caveat that the deployed code size must be less than or equal to 10 bytes. Welcome to low level programming, enjoy your ride.
Contract analysis
Nothing special, the setSolver(address)
should be called to tell the contract where the solver is, and the Ethernaut logic will check if the conditions are met.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.6.0;
3
4contract MagicNum {
5
6 address public solver;
7
8 constructor() public {}
9
10 function setSolver(address _solver) public {
11 solver = _solver;
12 }
13
14 /*
15 ____________/\\\_______/\\\\\\\\\_____
16 __________/\\\\\_____/\\\///////\\\___
17 ________/\\\/\\\____\///______\//\\\__
18 ______/\\\/\/\\\______________/\\\/___
19 ____/\\\/__\/\\\___________/\\\//_____
20 __/\\\\\\\\\\\\\\\\_____/\\\//________
21 _\///////////\\\//____/\\\/___________
22 ___________\/\\\_____/\\\\\\\\\\\\\\\_
23 ___________\///_____\///////////////__
24 */
25}
Vulnerability / Attack vector
None. Not a vulnerability, just another concept-teaching level.
Solution
This will be a rather long explanation, because it involves some interesting concepts and learning paths.
The first approach was to deploy a contract that implements the required logic, but obviously, without the size limitation. The goal is to create a function that returns 42
(or 0x2A
in hex) in Remix, and iterate from there. The shortest contract I came up with was the following:
1pragma solidity ^0.8.0;
2
3contract Test {
4 fallback() external payable {
5 assembly {
6 mstore(0x80, 0x2A)
7 return(0x80, 0x20)
8 }
9 }
10}
This code is the equivalent of a return 0x2A;
statement in Solidity. According to Yul documentation the return(p, s)
instruction finishes the execution of the current function and returns to the caller the data values located in memory positions p
to p+s
. This requires the return value to be stored somewhere in memory, and that's where the mstore(p, v)
instruction comes in, as it stores the value v
to memory positions p
to p+32
(or p+0x20
).
The Test
contract stores the uint256
value 0x2A
to memory positions 0x80
to 0x100
(that is, 32 bytes), and then returns to caller the 0x20
bytes starting at 0x80
, exactly what was stored in the previous instruction. This is as close as I could get to EVM assembly from Remix, however this still needs some tweaking.
Instead of implementing the whatIsTheMeaningOfLife()
function, I coded the solution in fallback()
. This allows the compiler to optimize out the function signature hash comparison, and react to any function called. The interesting part of the compiler output for this Test
contract is shown below:
1.data
2 0:
3 .code
4 PUSH 80
5 PUSH 40
6 MSTORE
7 PUSH 2A
8 PUSH 80
9 MSTORE
10 PUSH 20
11 PUSH 80
12 RETURN
13 .data
Lines 7 to 12 are the ones that solve the challenge. However, the PUSH
instruction can be compiled to a family of instructions depending on the length (in bytes) of the argument. In this case, we need the arguments to be of 1 byte, so the corresponding instruction would be PUSH1
. This is done to minimize the contract code length (otherwise we would need a longer constant for the memory address and value).
Looking up the instructions at Ethereum Virtual Machine opcodes, the ones needed for this challenge are:
Assembly instruction | EtherVM opcode |
---|---|
PUSH1 xx |
0x60 xx |
MSTORE |
0x52 |
RETURN |
0xF3 |
CODECOPY |
0x39 |
So, the code for the solver contract should be:
1PUSH1 2A
2PUSH1 80
3MSTORE
4PUSH1 20
5PUSH1 80
6RETURN
And its opcodes:
160 2A
260 80
352
460 20
560 80
6F3
Great, now the contract fits in the required 10 bytes. However, this is impossible to deploy as-is.
The code written so far is the runtime code of the contract. This doesn't include the code for a constructor (if there was one) or the creation code for the EVM. This post by monokh talks about the different bytecodes and explains the purpose and workings of the initialization code, an alias for the creation code. More detailed information can be found at this medium
The goal of this initialization code is to return the contract code. Pretty much like the function coded above returns 42
as an integer, this code should return the runtime code of the to-be-constructed contract. The good part is that, since this initialization code will not be part of the deployed contract, we don't have a 10-byte size limit.
The first step is to copy the runtime code, that comes as calldata
on the deployment transaction, to memory. The CODECOPY
instruction does exactly this, given 3 arguments in the stack:
destOffset
, the memory address where the code will be copiedoffset
, the calldata position where the code to be copied startslength
, how many bytes of code are going to be copied
Now that the runtime code is in memory, it has to be returned. This is the same code used above to return the constant 42
. The RETURN
instruction with offset
and length
as parameters. These values will be the same as the previous CALLCODE
instruction arguments.
Let's mock the initialization code:
1PUSH <runtime code length>
2PUSH <runtime code start>
3PUSH <memory destination>
4CODECOPY
5PUSH <runtime code length>
6PUSH <memory destination>
7RETURN
The <runtime code length>
is 10 (0x0A
), as stated above. <memory destination>
can be an arbitrary value, since there's no restriction on location, so I chose 0x00
. <runtime code start>
must be calculated as the position where the runtime code starts in the calldata
sent with the transaction. Since the runtime code will come after the initialization code, <runtime code start>
will be the length of the initialization code, 12 or 0x0C
(positions start at 0).
Replacing all the values and concatenating both codes, the complete solution for this level is then:
1PUSH1 0A
2PUSH1 0C
3PUSH1 00
4CODECOPY
5PUSH1 0A
6PUSH1 00
7RETURN
8
9PUSH1 2A
10PUSH1 80
11MSTORE
12PUSH1 20
13PUSH1 80
14RETURN
And so the whole bytecode is: 0x600A600C600039600A6000F3602A60805260206080F3
.
To deploy it, I used a variation of the Deployer
contract from CTE's Fuzzy identity challenge:
1pragma solidity ^0.8.0;
2
3contract Deployer {
4
5 function deployContract(bytes32 salt) public returns(address) {
6 uint176 bytecode = 0x600A600C600039600A6000F3602A60805260206080F3;
7 bytes memory code = abi.encodePacked(bytecode);
8 address addr;
9
10 assembly {
11 addr := create2(0, add(code, 0x20), mload(code), salt)
12 }
13
14 return(addr);
15 }
16
17 function testDeployed(address solver) public returns (uint256) {
18 (, bytes memory result) = solver.call("");
19
20 // This should return 42.
21 return uint256(bytes32(result));
22 }
23
24}
The deployContract(bytes32)
deploys the solver and returns the address. The testDeployed(address)
calls the fallback function of solver
to check its correct return value.
Conclusions
An amazing challenge whose goal is for the user to know the details of contract creation process, EVM assembly instructions and low level calls. There are many great resources online to learn from.
Level 19: Alien Codex
An Alien contract that expects the player to become the new owner. This time with a little twist.
Contract analysis
The contract is quite simple. It implements a bytes32[] codex
array that is used as data storage, and a bool contact
flag that indicates if the player made contact with the contract.
Two interesting facts come to light upon inspection: the contract was meant to be compiled with Solidity 0.5, and it inherits from Ownable
. The old version of the compiler can be a telltale sign of integer overflows, because it doesn't use SafeMath
library. The Ownable
contract is an OpenZeppelin implementation of a simple access control, defining an owner
address and a modifier to allow certain functions to be executed only by the owner.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.5.0;
3
4import '../helpers/Ownable-05.sol';
5
6contract AlienCodex is Ownable {
7
8 bool public contact;
9 bytes32[] public codex;
10
11 modifier contacted() {
12 assert(contact);
13 _;
14 }
15
16 function make_contact() public {
17 contact = true;
18 }
19
20 function record(bytes32 _content) contacted public {
21 codex.push(_content);
22 }
23
24 function retract() contacted public {
25 codex.length--;
26 }
27
28 function revise(uint i, bytes32 _content) contacted public {
29 codex[i] = _content;
30 }
31}
Vulnerability / Attack vector
Integer overflow at line 25 of AlienCodex
, combined with a full storage access as consequence.
Solution
Let's check the contract storage layout:
- Slot 0:
address owner
, inherited, andbool contact
, as stated by Solidity storage packing rules. - Slot 1:
codex.length
- Slot
keccak256(1)
:codex[0]
- Slot
keccak256(1)
+ 1:codex[1]
, and so on.
The retract()
function does not check for overflow, so it can make codex.length
overflow. This gives full read and write storage access to any slot, because every slot is part of the array now. So, the revise(uint, bytes32)
function will allow the attacker to overwrite any storage slot with any value.
However, to execute retract()
, the bool contact
flag must be set to true. This is done by calling make_contact()
in the first place.
The full procedure to exploit this contract is, then:
- Call
make_contact()
, to set thecontact
flag. - Call
retract()
to overflowcodex.length
. - Call
revise(uint, bytes32)
to overwrite theowner
address at slot 0.
The last call bytes32
argument should be the player's address converted to byte32
(that is, padded with zeros). The uint
argument, on the other hand, is used as index to the codex
array, so it should be calculated to overwrite storage slot 0.
Element n
of the codex
array is mapped to storage slot keccak256(1) + n
. The 1
comes from the fact that the codex.length
variable is at slot 1
. So, to overwrite storage slot 0, the correct index to be accessed should be calculated to satisfy keccak256(1) + k = 0
, or according to integer overflow rules, keccak256(1) + k = 2**256
. Therefore, k = 2**256 - keccak256(1)
.
Conclusions
Overflowing an array length value is a dangerous thing to happen, since it can give full access to every storage slot of the contract. This can be particularly devastating in contracts that keep accounting of token balances, or monetary values stored.
Keep an eye out for integer overflows, either by using SafeMath
library, or a Solidity version higher than 0.8.
Level 20: Denial
A wallet contract that allows the withdrawing partner and the contract owner to receive 1% of the balance everytime a function is called. The goal is to deny the owner to receive funds.
Contract analysis
This contract implements some helper functions, such as setWithdrawPartner(address)
that changes the current partner, and contractBalance()
that returns the current balance of the contract.
The withdraw()
function implements the value transfer from the contract to the partner and the owner. The first transfer is done via a call
to the fallback function, and the second via a transfer()
.
There's some logic after sending the amounts, but it's not relevant to the implementation.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.6.0;
3
4import '@openzeppelin/contracts/math/SafeMath.sol';
5
6contract Denial {
7
8 using SafeMath for uint256;
9 address public partner; // withdrawal partner - pay the gas, split the withdraw
10 address payable public constant owner = address(0xA9E);
11 uint timeLastWithdrawn;
12 mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances
13
14 function setWithdrawPartner(address _partner) public {
15 partner = _partner;
16 }
17
18 // withdraw 1% to recipient and 1% to owner
19 function withdraw() public {
20 uint amountToSend = address(this).balance.div(100);
21 // perform a call without checking return
22 // The recipient can revert, the owner will still get their share
23 partner.call{value:amountToSend}("");
24 owner.transfer(amountToSend);
25 // keep track of last withdrawal time
26 timeLastWithdrawn = now;
27 withdrawPartnerBalances[partner] = withdrawPartnerBalances[partner].add(amountToSend);
28 }
29
30 // allow deposit of funds
31 receive() external payable {}
32
33 // convenience function
34 function contractBalance() public view returns (uint) {
35 return address(this).balance;
36 }
37}
Vulnerability / Attack vector
The contract doesn't check the return value for the external call to the partner's address. This allows the call to fail and not affect the rest of the execution, however, if this call consumes up all the gas, the transaction would fail anyway.
Solution
An external call to a function can't really use up all the gas of the transaction. According to EIP-150, the call can use at most 63/64ths of the gas available. This makes an interesting bug: if the remaining 1/64th of the gas is enough to finish executing the caller's function, it will succeed, else it will fail. This is effectively a bug that can be hard to spot.
Now, let's talk about the solution. The attacker contract below uses an assert
instruction with a false argument, so when it is called, it consumes all remaining gas (well, almost, 63/64ths of the forwarded gas, as stated above).
1contract DenialAttack {
2 receive() external payable {
3 assert(1 == 2);
4 }
5}
Setting an instance of DenialAttack
as the withdrawing partner will cause the transaction to fail.
Conclusions
External calls should have a fixed gas limit, enforced by the caller: partner.call{value: amountToSend, gas: 1000000}("")
. This ensures the caller will have enough gas to finish its execution safely.