The Ethernaut is a capture-the-flag style contest for finding bugs in Solidity contract.
It was created by OpenZeppelin and is available here.
I will use this repository to describe my thought process, hold any code I write and document any useful insights.
- The contracts are compiled with solc 0.4.18.
- We are currently using solc 0.5.x.
- Although truffle can be configured to use an old compilers,
it does not produce the
legacyASTvariable, which causes errors. - Whenever I want truffle to produce an ABI for a level, I modify the code to be compatible with the latest compiler.
- Level 0. Hello Ethernaut
- Level 1. Fallback
- Level 2. Fallout
- Level 3. Coin Flip
- Level 4. Telephone
- Level 5. Token
- Level 6. Delegation
- Level 7. Force
- Level 8. Vault
- Level 9. King
- Level 10. Re-entrancy
- Level 11. Elevator
- Level 12. Privacy
- Level 13. Gatekeeper One
- Level 14. Gatekeeper Two
- Level 15. Naught Coin
- Level 16. Preservation
- Level 17. Locked
- Level 18. Recovery
- Level 19. MagicNumber
- Level 20. Alien Codex
- Level 21. Denial
- Level 22. Shop
This level is designed to get used to interacting with contracts through the console.
- The level presents a series of instructions (eg. call the function
info()) - It eventually instructs us to enter the password in
authenticate - There is a state variable named
passwordset to ethernaut0 - Pass that to
authenticateto complete the level
- There is a
Fallbackcontract - The goal is to take ownership and reduce the balance to 0
- There is a
contributionsvariable that tracks how much each user has contributed - The constructor assigns 1000 ETH to the owner ( notionally - the contract does not own any ETH )
- There is a payable
contributefunction:- You must send less than 0.001 ETH
- Your contribution gets recorded against your account
- If you become the highest contributor, you also become the owner.
- Obviously, calling
contributea million times is not the desired solution - There is a
withdrawfunction that lets the owner drain the contract - There is a fallback function:
- You need to send some ETH
- You need to already have contributed some ETH
- You become the owner
So the strategy is:
- Call
contributewith 1 Wei (so we can call the fallback function) - Call the fallback function with 1 Wei to become the owner
- Call
withdrawto drain the contract
This is implemented in migrations/level1.js
- There is a
Falloutcontract - The goal is to get ownership of the contract
- There are some irrelevant functions to adjust the allocations (which are also buggy - sending allocations does not reduce the balance)
- The supposed constructor is misspelled (as Fal1out) which means it is callable
- It sets the owner to the caller
So the strategy is to simply call that function and become the owner.
This is implemented in migrations/level2.js
Note that this bug is mitigated with the new syntax, where constructors are not named after the contract but are just called constructor.
- There is a
CoinFlipcontract - There is a
flipfunction that accepts a guess, generates a random bit and updates the winning streak:- If the guess matched the random bit, increment the streak
- If the guess did not match the random bit, reset the streak to 0
- Note: the guess is a boolean, and the random bit is cast to a boolean before comparison
- The goal is to get a winning streak of 10
- The random number generation mechanism within
flipis:- Ensure this is the first time the function is called this block
- Divide the previous block's hash by 0x8000000000000000000000000000000000000000000000000000000000000000
- This is an integer division that rounds down
- Note that this value is 256 bits long (a 1 followed by 255 0s)
- A block hash is also 256 bits long
- This means the random number equals the previous block hash's first bit
- Since block hashes are public, anyone can predict the outcome of the flip before it occurs
So the strategy is to repeatedly (10 times):
- retrieve the previous block's hash
- use it to predict the outcome of the flip
- 'guess' correctly
This is implemented in migrations/level3.js
- There is a
Telephonecontract - The goal is to get ownership of the contract
- There is a
changeOwnerfunction that sets the owner to a passed value, providedtx.origin != msg.sender - Recall
tx.originis the externally owned account (EOA) that initiated the transaction msg.senderis the EOA or contract that directly called the current function
So the strategy is to call changeOwner from a relay contract, which will ensure:
tx.originis the account that called the relay contractmsg.senderis the address of the relay contract
This is implemented in migrations/level4.js
- There is a
Tokencontract - We have already been given 20 tokens
- The goal is to get more
- The token distribution is recorded in a state variable
balances - There is a
transferfunction- it accepts
_toand_valueparameters - if confirms that the sender's balance exceeds
_value - it then reduces the sender's balance increases
_to's balance by_value
- it accepts
- The
balancesand_valueparameter have typeuint - The guard against overspending is achieved by subtracting
_valuefrom the sender's balance and confirming the result is non-negative. - Since both parameters are
uints, if_valueexceeds the sender's balance, it will underflow and the check will stil pass - Additionally, when
_valueis subtracted from the sender's balance, it will underflow.
So the strategy is to send 21 tokens to anyone else so our balance will underflow.
This is implemented in migrations/level5.js
- There is a
Delegationcontract - The goal is to claim ownership of the contract.
- It is initialised with a state variable equal to a
Delegatecontract - It's fallback function executes
delegatecall(msg.data)on theDelegatecontract - The
Delegatecontract has apwnfunction the sets its owner to the msg sender delegatecallis intended for library functions that execute in the context of the caller function- This means that if we can get the
Delegationtodelegatecallto thepwnfunction, it will set the owner ofDelegationto the message sender
So the strategy is to invoke Delegation's fallback function with message data corresponding
to the pwn function of Delegate
This is implemented in migrations/level6.js
- There is a
Forcecontract, which is empty - There is no payable function.
- The goal is to send ETH to the contract
- There are two ways to send ETH to a contract without a payable function:
- we can mine directly to that address
- we can call
selfdestructon a contract with ETH, and direct the refund to the target contract
So the strategy is:
- Create a contract that accepts ETH
- Send some ETH to the contract
- Call
selfdestructon the contract and direct the refund to the target
This is implemented in migrations/level7.js
- There is a
Vaultcontract - It has a private state variable
lockedset to true - The goal is to set that to false
- It has a
passwordstate variable that is initialised on deployment - There is an
unlockfunction that will setlockedto false if we supply the right password - The fact that the state variable is private means we can't query it with the contract interface
- It is not a secret value though - it is still stored on the blockchain
- We can retrieve it by looking at the contract storage
So the strategy is:
- Get the password from the
Vaultstorage - Call
unlockwith the password
This is implemented in migrations/level8.js
- There is a
Kingcontract - It has a state variable
king - The goal is to become the king and then prevent anyone from reclaiming the throne.
- The contract is initialised with a 1 ETH
prizeon deployment - It has a payable fallback function that:
- ensures the transaction amount exceeds the current prize (or the sender is the owner)
- sends the amount to the current king
- makes the message sender the new king
- updates the prize to be the transaction amount
- We can become king by sending more than 1 ETH.
- If we use a contract, its fallback function will be called whenever the owner attempts to reclaim the thrown (because that involves sending us the new amount)
So the strategy is:
- Create a contract to be king
- Set the fallback function to always revert, preventing the owner from reclaiming the throne.
- Claim the throne
Note: the contract should have a mechanism for us to withdraw the funds in it. I will ignore that for now.
This is implemented in migrations/level9.js
- There is a
Reentrancecontract with 1 ETH - The goal is to steal the funds from the contract
- It tracks address balances with a
balancesmapping - It has a
donatefunction which allows a user to donate ETH to any address - It has a
withdraw(_amount)function that:- confirms the message sender has a balance of at least
_amount - sends
_amountto the message sender - reduce the message sender's balance by
_amount
- confirms the message sender has a balance of at least
- This is vulnerable to the re-entrancy attack:
- the message sender is a contract
- it has a callback function that calls
withdraw - since
withdrawsends funds before reducing the balance, re-entrant calls will all be executed with the original balance and then the balance will be reduced after sending the funds.
- This means that an attacker can withdraw their balance multiple times in a single call.
- In this case, it also means that their balance will underflow to a massive value ( which will all them to withdraw all the funds )
- In a typical re-entrancy attack, the fallback function should have an exit condition to prevent an out-of-gas exception reverting the transaction
- In this case, the
withdrawfunction sends funds withcall, which will simply return false once it is out of gas.
So the strategy is:
- Create an attacker contract
- Set the fallback to call
withdraw. - Point the attacker contract to the target
Reentrancecontract - Call
donatewith the attacker contract address - Trigger the
withdrawfunction, which will loop between the attacker fallback andwithdraw - Drain the rest of the funds.
This is implemented in migrations/level10.js
- There is an
Elevatorcontract - It has a
topvariable defaulting to false - The goal is to set that variable to true
- There is a
Buildinginterface with one function:- `function isLastFloor(uint) view external returns (bool);
- The
Elevatorcontract has agoTo(_floor)function that:- Casts the sender to a
Building - If
isLastFloor(_floor)return false, settoptoisLastFloor(_floor)
- Casts the sender to a
- Note that this implies that if the function returns the same value both times,
topremains false - The fact that
isLastFlooris aviewfunction seems to imply it cannot modify storage. - However, it is possible to cast any address to any type, so casting to
Buildingdoes not guarantee conformance to the interface - This may create a run-time error if an executed function is missing or accepts the wrong parameter types
So the strategy is:
- Create a phony
Buildingcontract that does not inherit fromBuilding, but does implementisLastFloor - Return false on the first call and true on the second call
- Trigger the
Elevatorcontract'sgoTofunction from the attacker contract.
This is implemented in migrations/level11.js
- There is an
Privacycontract - It has a
lockedstate variable - The goal is to set that to false
- It seems similar to the Level 8 challenge
- There are a number of state variables, one of which is
data - If we can submit a processed version of
datato theunlockfunction, it will unlock the contract - We should be able to just look at the contract storage while paying attention to variable sizes.
- The first item in a (32-byte) storage slot is lower-order aligned
- If an item cannot fit in the rest of a storage slot, it is moved to the next one
- Structs and array data always occupy a new (whole) slot, but individually items are still packed
- Inherited contracts can have storage slots shared between variables from different contracts
constantvariables do not occupy storage slots
In our case, the storage is packed as follows:
| 27 bytes | 2 bytes | 1 byte | 1 byte | 1 byte |
|---|---|---|---|---|
| ( unused ) | awkwardness | denomination | flattening | locked |
| 32 bytes |
|---|
| data[0] |
| 32 bytes |
|---|
| data[1] |
| 32 bytes |
|---|
| data[2] |
So the strategy is:
- Read the 4th storage location (
data[2]) - Take the top half (
bytes16( data[2] )) - Pass that value to the
unlockfunction
This is implemented in migrations/level12.js
- There is a
GatekeeperOnecontract - The goal is to register as an entrant
- There is an
enterfunction which lets you register if you can pass three modifiers - The first one ensures
msg.sender != tx.origin, which means we need to use a contract - The second one ensures
gasleft() % 8191 === 0, so we have to set the gas appropriately - The third one ensures the passed
_gateKeyparameter satisfies three simultaneous conditions:uint32(_gateKey) == uint16(_gateKey)=> bytes 2 and 3 (counting from the right) are zerouint32(_gateKey) != uint64(_gateKey)=> bytes 4-7 are collectively non-zerouint32(_gateKey) == uint16(tx.origin)=> bytes 0 and 1 are the bottom two bytes of tx.origin
- Experimenting with Remix suggests the gas used by the first gate is 39, but the particular value will depend on the compiler and optimizations used.
- I don't know of a sensible way to deal with this, so I will just brute force possible gas values.
- The third gate does not compile with Remix (even with an old compiler) because you can no longer cast bytes to uints. This means I can't directly check the gas usage of the whole function. For simplicity, I will just provide significantly more gas than required per call.
So the strategy is:
- Create a registration contract
- Generate a
_gateKeyvalue in line with the third gate - Call
enterwith this value and (10 * 8191 + i) gas, for a range of i. (Note: we don't need to brute force different transactions - the contract itself can brute force as long as we use thecallfunction instead oftransfer)
This is implemented in migrations/level13.js
- There is a
GatekeeperTwocontract - This has the same structure as the previous challenge with different modifiers
- The first one ensures
msg.sender != tx.origin, which means we need to use a contract - The second one uses inline assembly to ensure
extcodesize(caller) == 0. This implies that the caller does not have any code (it is not a contract) - I think these two conditions can be met if we use
delegatecallin our contract (so thecallerparameter is still the externally-owned account). - I would have guessed that this means we can't update the state of the
GatekeeperTwo, but maybe it uses its own variables if our contract does not have a matching variable (ie. if our contract does not have anentrantvariable, then we can still update theGatekeeperTwocontract'sentrantvariable throughdelegatecall) - Or maybe we have to use assembly to jump to the function without changing the context, but if such a thing is possible, it is probably a massive security issue
- Experimenting with Remix I have been able to confirm:
calleris typically the calling contract, but ifdelegatecallis used, it is the previous caller.- this means we can bypass the
extcodesize(caller) == 0check if we usedelegatecall - however, a function called with
delegatecallwill not affect the called contract's state, so we can't updateentrant
- I can't think of another way to ensure the first two gates can be both passed.
- I will leave it for now - if I really can't come up with any ideas I will look at the solutions
- I had to look up the answer, although it seems obvious in retrospect
- The important insight is that a contract's constructor returns the run-time contract code,
which means
extcodesizereturns 0 before the constructor completes. - The
calleraddress still points to the address that will eventually hold the code. - So we just need to register as an entrant in the constructor of our contract to bypass the first two gates.
- The third gate ensures the passed
_gateKeyis theuint64bit inverse ofkeccak256(msg.sender)(ie. our contract address), cast tobytes8
So the strategy is:
- Create a registration contract
- In the constructor of the contract:
- calculate the
_gateKey(using the contract's address) - pass this value to the
enterfunction on theGatekeeperTwocontract
- calculate the
This is implemented in migrations/level14.js
- There is a
NaughtCoincontract, which is aStandardToken - We currently hold all the coins.
- The
transferfunction has been overloaded to prevent us specifically from sending the tokens. - The goal is to bypass the timelock and be able to transfer them freely.
- The
StandardTokenallows us to assign tokens to other addresses (the other addresses can spend tokens on our behalf)
So the strategy is:
- Assign the tokens to another address that we hold
- Transfer them to that address
This is implemented in migrations/level15.js
- There is a
Preservationcontract - The goal is to take ownership
- The contract has two library addresses
timeZone1LibraryandtimeZone2Library - When either
setFirstTimeorsetSecondTimeis called,setTimein the corresponding library instance is executed usingdelegatecall. - The
LibraryContractis specified and itssetTimefunction simply sets thestoredTimevalue to the passed parameter. - Because
delegatecallmaintains scope, this sets thestoredTimevariable in the target contract. - There is no direct way to set the owner.
- I suspect we need to overflow the storage, except
storedTimeis after theownervariable and all the relevant values areuints. - I also suspect that any casting shenanigans we could play would be prevented by the new compiler.
- If we could set one of the library addresses, we can point it to our own contract, which can then edit the state arbitrarily.
- But neither of the functions that we can call directly set the state.
- Another idea: we know the address of the libraries, so we could call them directly (to set their own state) or via
delegatecall - Note: In order to use the latest compiler, I replaced the line:
- timeZone1Library.delegatecall(setTimeSignature, _timeStamp), with
- timeZone1Library.delegatecall(abi.encodeWithSignature("setTime(uint256)", _timeStamp))
- Maybe the old
delegatecallaccepted more parameters - OH, I just discovered an important fact. I thought
delegatecalldid lookups by variable names. - Actually, The
delegatecallcallee performs variable lookups by storage slot number - Each variable name is simply an index into the storage
- So in this case, the library contract has
storedTimeset at slot 0. WhensetTimeis called, it sets slot 0 to be the passed inuint. - When the library is
delegatecalled from thePreservationcontract, it will set whatever is stored at slot 0, which happens to be the first library address.
So the strategy is:
- Create an attack contract with a
setTime(uint)function that overwrites the third storage slot - Call
setSecondTimewith the attack contract address as a parameter ( our attack contract is nowtimeZone1Library) - Call
setFirstTimewith the player address, overwriting the owner storage slot
This is implemented in migrations/level16.js
- There is a
Lockedcontract - It has a storage variable
unlockedinitialised tofalse - The goal is to set it to
true - There is only one function
register, which updates the other state variables registerdeclares an uninitialised- in the challenge version, the struct defaults to
storage, and since it's uninitialised, it default to zero. - In the current version of solidity, you have to specify either
memoryorstorageand you cannot compile a contract with an uninitialised storage variable - In this case, values written into the struct will overwrite the first storage slot,
which holds the
unlockedvariable
So the strategy is:
- Call
registerwith abytes32name parameter that has a non-zero last byte
This is implemented in migrations/level17.js
- There is a
Recoverycontract - It generates new
SimpleTokencontracts - The creator generated a new token, sent ETH to the contract and has now lost the address
- The goal is to recover the lost ETH
- The first step should be to find the lost contract. When contracts are deployed, addresses are generated as follows:
- RLP encode [ sender, nonce ]
- Compute Keccak256 of the result
- Take the bottom 20 bytes
- Since we know the
Recoverycontract address and we're trying to find the first deployed contract (nonce = 1), we should be able to calculate the address. - The
SimpleTokencontract has adestroyfunction that will return the funds to the specified address
So the strategy is:
- Calculate the address of the
SimpleTokencontract - Call
destroywith the player address
This is implemented in migrations/level18.js
- There is a
MagicNumbercontract - We need to provide it with a
Solvercontract that- returns 42 when
whatIsTheMeaningOfLife()is called - has at most 10 opcodes
- returns 42 when
- There is nothing in the supplied
MagicNumbercontract to check the code size, but presumably the one deployed to the Ethernaut callsextcodesizewhen validating the contract. - This seems like a good opportunity to learn Solidity assembly. I will write any notes I take in an
Assembly.mdfile in this repository. - This problem requires a detailed explanation. I have written my analysis in MagicNumber.md
The solution is implemented in migrations/level19.js
- There is an ownable
AlienCodexcontract - The goal is to take ownership
- It has three functions to modify a
codexarray. All of them are protected by a modifier than ensuresmake_contacthas already been called make_contactaccepts abytes32[]parameter that must be at least (1 << 200) long.- Obviously, we can't send such a long parameter - we have to simply set the length field to be larger than (1 << 200)
- The
recordfunction pushes abytes32onto the array - The
retractfunction decrements the array length - The
revisefunction lets us set the value of the array at any index we choose to whatever value we choose. - Recall from
Assembly.mda dynamic array is stored as follows:- the array storage slot holds the length of the array
- the data is stored contiguously at a location specified by keccak256(index of storage slot)
- Experimenting with the contract confirms:
- the
retractfunction does not do any bounds checking - we can underflow it - the
revisefunction does bounds checking - you cannot update beyond the length of the array - however, if the array is longer than the memory address space, it wraps back to the start
- the
So the strategy is:
- Send a message to
make_contactwith a (fake) message length greater than 1 << 200 - Call
retractto underflow the array (so the whole address space is part of the array) - Calculate the array contents location, and the relative offset to the
ownervariable ( which is just the twos complement of the array content location ) - Call
reviseto overwrite theownervariable with our own address
This is implemented in migrations/level20.js
- There is a
Denialcontract - The goal is to prevent the owner from retrieving their funds
- We can become the withdraw partner with the
setWithdrawPartnerfunction - The
withdrawfunction calculates 1% of the remaining funds (rounding down), sends the that amount to the withdraw partner and then the owner. - Obviously this means that if the balance dips below 100, the funds are trapped
- More relevantly, the fact that the funds get sent to us in the same transaction as the owner means that we can prevent the transaction from occurring.
- The contract makes a low-level
calland ignores the result, so we can't simplyrevert - However, we can waste the remaining gas with an
assert(false)statement - Note that the gas is not limited to 2300 because the fallback function is invoked with
calland notsendortransfer
So the strategy is:
- Create a contract to be the withdraw partner
- Set the fallback function to execute
assert(false) - Call
setWithdrawPartnerfrom the contract
This is implemented in migrations/level21.js
- There is a
Shopcontract - It contains a single item and a boolean indicating if it is bought or sold
- It contains a price for the item
- The goal is to get the item for less than the specified price
- The
buyfunction- Treats the message sender like a
Buyer(an interface with aviewfunctionprice) - Confirms the offered price meets or exceeds the asking price
- Sells the item at the offered price (which is retrieved with a second call)
- Treats the message sender like a
- This challenge is very similar to the Level 11
Elevatorchallenge:- we can create a contract has a
pricefunction that doesn't conform to theBuyerinterface - specifically, it doesn't have to be a view function.
- If it returns a high price the first time it is called and a low price the second time, we will get the item for the lower price
- we can create a contract has a
- The additional complication is that each call only gets 3000 gas
- This is enough to read from storage, but not to write to storage
- Therefore we can't toggle a storage variable in between calls like we did in Level 11
- Moreover, both calls are identical so there is no context in the call information to distinguish them
- The only thing that changes between calls is that
isSoldin theShopcontract gets toggled - Fortunately, the
isSoldvariable inShopis public (so we can query it) - Interestingly, this means that our price function can be a
viewfunction which conforms to the interface
So the strategy is:
- Create a
Buyercontract - The
pricefunction reads theShopsisSoldvariable and returns a high price when it is false, and a low price when it is true - Call the
buyfunction from theBuyercontract
This is implemented in migrations/level22.js