- All 29 Ethernaut solutions with friendly explanations using hardhat.
contracts
contains the.sol
files with the hack contracts. The target contracts are also included, but commented out. Some solutions can be done more easily through the console.scripts
contains the hardhat scripts to deploy and attack the target contracts. Some only require deployment.npm install
to install dependencies (hardhat, hardhat toolbox, dotenv, openzeppelin contracts). Thennpx hardhat init
to setup empty JavaScript hardhat project if needed.
- Goal: Just an introduction to the website getting familiar with the syntax and operations.
- Methodology: Play with the console, calling various properties of the contract and following the steps.
- Lessons: You can query things about the deployed vulnerable contract in the console. When you click 'get instance', it deploys your very own instance of that vulnerable/target smart contract to the sepolia network (If that's what your metamask is on). If you think you've solved a problem,
submit instance
button has the site check the status of your deployed vulnerable/target smart contract, and if it matches what the end result is, then you passed!
- Goal: Take ownership of the contract and withdraw to win.
- Methodology:
- Can be done solely from the console.
await contract.contribute({value: "1"});
Call contribute to make a contribution to the contract.await sendTransaction({from: "yourWalletAddres", to: "yourContractAddress", value: "1"});
Send a direct transaction to the contract address for 1 wei, this makes the receive fallback function trigger, it checks if the incoming value is greater than zero, and if msg.sender (you) have made a previous contribution, if so you become the owner.await contract.withdraw();
You can call this function now that you are the owner.
- Lessons:
- Be wary of fallback functions, as they are triggered whenever incoming ether tries to be sent to your address.
- Be strict in who you allow to be an owner.
- Goal: Become owner of the contract.
- Methodology:
await contract.Fal1out()
- Can be done solely from the console.
- Lessons: In solidity v6, the constructor was actually just seen as a function with the same name as the contract. If you misspell the name of your constructor function, it can be called like a normal function!
- Goal: 'Guess' correct outcome of a 'randomly' generated coinflip 10 times in a row in a smart contract.
- Methodology:
- Interface with the target contract, grab its flip function. Create the myGuess function which always matches the targets calculation of randomness.
- Inside our own flip function, set our guess and require it matches before returning.
- Deploy
CoinFlipHack
withnpx hardhat run scripts/deployCoinFlipHack.js --network sepolia
- Create a script to flip the coin 10 times and run with
npx hardhat run scripts/CoinFlipHack.js --network sepolia
- Lessons:
- You must be careful in how you derive randomness in your contracts. Remember, they are a completely transparent and deterministic system, so randomness must be achieved off-chain in some capacity, and brought in. One solution is chainlink VRF (verifiable random function).
- You can pass in other contract addresses during deployment so functions know where the target/vulnerable contract is on chain.
- You can interface with other contracts functions with the interface keyword.
- Goal: Claim ownership of contract
- Methodology: Interface into Telephone, invoke it's changeOwner function.
- Lessons:
- tx.origin != msg.sender.
- tx.origin is the original sender who started the chain of events, it's always the EOA (externally owned account).
- msg.sender is the immediate last caller, which could be an EOA or a smart contract.
- Goal: Get more than your initial 20 tokens.
- Methodology:
- 0 - 1 = underflow, which becomes maximum uint, breaking the require check.
- This allows the transfer function to send us the value we passed in during deployment
- Lessons:
- Solidity v6 SafeMath isn't enabled, so we can perform overflow/underflows without any errors.
-
Goal: Become owner of
Delegation
contract. -
Methodology:
- Load interface of
Delgate
contract at the address ofDelegation
contract. - Trigger fallback function which calls
delegatecall
function, which executes the code inside ofDelegate
, which will execute the functionpwn
, BUT it will update the state variable inside ofDelegation
and notDelegate
. - state variable 'owner' of
Delegation
is updated to us.
- Load interface of
-
Lessons:
- When a contract A calls
delegatecall
to a function in contract B, the code of contract B gets executed, but it operates on the storage of contract A. - If someone calls a function that exists in the
Delegate
contract but not in theDelegation
contract, the function will be executed in theDelegate
contract but operate on the storage of theDelegation
contract. - The key here is we create an instance of
Delegation
using the ABI fromIDelegate
. - Metamask always fails to guess the gas right, therefore you need to pass in a custom gas amount. ethers v6 uses
toBeHex
instead ofhexlify
!!!
- When a contract A calls
- Goal: Make the balance of the empty target contract greater than zero.
- Methodology:
-The problem is that the target contract is empty. Usually it needs a payable fallback or receive method, or some function that is payable. This target contract is a completely emptry contract with no methods, so how are we supposed to 'pay' into it?
- Create a contract, pass in target address and some eth on deployment, and immediately self destruct it.
- Lessons:
selfdestruct
destroys itself and forcibly sends its eth to another contract address.
- Goal: Unlock the vault by getting access to the password.
- Methodology:
- Simply grab the password with
await web3.eth.getStorageAt(contract.address, 1)
. - And then call
contract.unlock('password')
. This makes locked false because the password matches.
- Simply grab the password with
- Lessons:
- Even though the state variable password is private, it can be seen on the blockchain. Most blockchains are transparent.
- The second argument in getStorageAt returns the state variables exactly the order they are shown in the contract storage. (0 is the first state variable, 1 is the second, and so on.)
- Goal: Become the king by sending more ether to the contract than anyone else, and then stop anyone else from becoming the king.
- Methodology:
- Needed to instantiate
KingHack
contract with at least 0.001 ETH, as that is the prize amount that was found via the console usingawait web3.eth.getStorageAt(contract.address, 1)
- Inside the constructor it automatically interacts with the target address by sending the prize amount.
- Once the prize amount is sent,
KingHack
contract is the permanent king, because without including a way for the contract to receieve ether, all possible attempts at someone becoming the new king will fail because their transactions will revert, since we can never be paid out.
- Needed to instantiate
- Lessons:
- You can make a
transfer
function always fail by not including a fallback, which can make an entire function never return.
- You can make a
- Goal: Take all the ETH in the target contract.
- Methodology:
- The target contract's vulnerability happens because it follows a
checks --> interact --> update state
pattern rather than achecks -> update state -> interact
pattern. Meaning, it interacts (sends the ETH via withdraw) before updating the ledger's state, so it can be re-entered and harrassed since the state isn't changed (balance updated) until after the interaction. - Note:
Donate
function in the target contract is more akin to deposit, we first deposit into the target contract, so we can then use the withdraw function to exploit. - The chain of events that occur: Our hack contract deposits into the target contract via the donate function. The target contract is like a bank, and now owes us that if we would like to withdraw. We call the withdraw function, which invokes the receive function, which calls the withdraw function again, and so on.
- We get away with it because the "bank" updates it's ledger (state) AFTER it already sent the money. And the target contract CANNOT update it's state until the entire external call transfer is complete... But that means the withdraw->receieve loop can run as long as it needs to, cleaning the contract, and leaving it with nothing, before the target contract updates it's ledger and realizes it's pockets are empty.
- The target contract's vulnerability happens because it follows a
- Lessons:
- The whole idea is the
withdraw
function fromReentrance
target contract, which is called from ourReentranceHack
contract, invokes theReentranceHack
'sreceive
method, which then calls thewithdraw
function again fromReentrance
. Thewithdraw
function invokes thereceieve
because in order to withdraw, the other contract must receive via a fallback of some sort.
- The whole idea is the
- Goal: Reach the
top
floor, turning theElevator
boolean state variable to true. - Methodology:
- Our
attack
function keeps callinggoTo
fromElevator
, and reverts unlesstop
is true. - The
goTo
function takes in a floor number, and first checks to verify thatisLastFloor
is false by callingisLastFloor
frombuilding
. building
is the contract that called thegoTo
method, which is ourElevatorHack
contract via theattack
function.- The first call to our
isLastFloor
is false because our counter is not greater than 1. This passes the initial check of thegoTo
function. So it sets the floor, and then immediately checksisLastFloor
again. Upon callingisLastFloor
a second time, our function now returns true because the counter is greater than 1. This makestop
= true, solving the level. - Explaining
Building building = Building(msg.sender);
:Building building
Assigns typeBuilding
tobuilding
variable.Building(msg.sender)
casts msg.sender to be treated as an instance of theBuilding
interface.msg.sender
is ourElevatorHack
contract that calledgoTo
.- We can then call
building.isLastFloor(_floor)
because we've defined that anybuilding
should have this function.
- Our
- Lessons:
- Interfaces define expected behaviors, but implementations of the specific functions could vary. Do not naively assume that these functions will return consistent values! External calls to these functions could return any possible value within the type, in our example multiple calls produced different results like false and then true.
- Even if you try to include
view
orpure
function modifier on an interface to prevent state modificators, it's just a mere suggestion.function isLastFloor(uint) external view returns (bool);
still relies on external contracts to respect that view modifier. - If you expect consistent results from an external function, cache the result of the first call and use that for any logic operations.
- Goal: Unlock the contract by setting
locked
state variable to false. - Methodology:
- Can solve entirely with the console.
- In order to satisfy the check to successfully call the
unlock
function, we require a key. The key has to be the 16 byte version of the 3rd index of the 32 byte fixed sizedata
array which is of length 3:key == bytes16(data[2])
. - First set deployed instance address to a variable if you like:
instanceAddress = "0x72c8876a96e6a83D9C5E5dC9E11d0E64E230A819"
data[2]
is housed on chain in storage slot index 5:data = await web3.eth.getStorageAt(instanceAddress, 5)
- data example: "0x7d6c54b37a85017f180f2dff7796ca15bed1cd9115da21e318011bfbb5c20edd"
- Convert the key from 32 bytes to 16 bytes:
key = data.slice(0, 34)
- key example: "0x7d6c54b37a85017f180f2dff7796ca15"
- Call unlock to solve:
contract.unlock(key)
. This setslocked
to false.
- Lessons:
- A 66 character address that starts with 0x is 32 bytes because 2 characters = 1 byte, and 0x doesn't count.
- The order of the state variables defind in a smart contract are persistant with the order they appear in the storage array on chain. The only caveat is that different data types yield different byte sizes. This is important when we know each slot in on chain storage holds 32 bytes.
bool
takes up 32 bytes of on chain storage, fully filling up slot 0.uint256
takes up 32 bytes of on chain storage, fully filling up slot 1.uint8
takes up 1 byte of on chain storage, partially filling up slot 2.bytes32[3]
is a fixed size array of length 3, each slot taking up 32 full bytes. Slot 2 will not be able to handle the first index in the array, so this type will take up slot 3, slot 4, and slot 5, for it's 3 indexes.
- Most blockchains are transparent, the keyword
private
in solidity does not obfuscate your state variables from showing up in public storage on chain.
- Goal: Become the
entrant
address by making theenter
function return true. - Methodology:
- We need to satisify all the requirements for all three custom modifiers gateOne, gateTwo, and gateThree. If they are all satisfied, then
enter
will return true. - Deploy contract when you know gateOne and gateThree are solved, then brute force gateTwo gas guessing.
gateOne
: By making our hack contract and having it execute the function enter will work to satisfy this requirement, because msg.sender is our hack contract, and tx.origin is our metamask address that deployed our hack contract.gateTwo
: Requires us to adjust the gas sent to the transaction such that the remaining gas when gasleft() is called inside gateTwo is divisible by 8191. Wrote script that calls the enter function, passing in the target contract address and our gas guess. We iterated through gas numbers until it was successful. The gas should be static and always 256, because the gas used is a function of the type of computation. Could have also technically tried to calculate gas used up until that point based on op code gas costs as these are all static. The correct gas is 8191*X + 256, where X is a value you think is enough for the transaction to be validated. (X=10 works fine for example).gateThree
: Craft a single bytes8 key, which upon inspection of certain parts of it satisfy all 3 requirements at the same time. A type casting/bit manipulation puzzle. We can manipulatetx.origin
, which is 20 bytes in length. (1 byte = 8 bits = 2 characters).- We took the last 4 characters of tx.origin with
uint16 k16 = uint16(uint160(tx.origin));
= 'wxyz'. Thenuint64(1 << 63)
creates 1000000000000000 which is 0x8000000000000000 in hex. We then addk16
as the last 4, souint64 k64
=0x800000000000wxyz
. - condition 1:
uint32(uint64(_gateKey)) == uint16(uint64(_gateKey));
- last 32 bits of k64 = last 16 bits of k64,
0x0000wxyz = 0xwxyz
- last 32 bits of k64 = last 16 bits of k64,
- condition 2:
uint32(uint64(_gateKey)) != uint64(_gateKey);
- last 32 bits of k64 != k64,
0x0000wxyz != 0x800000000000wxyz
.
- last 32 bits of k64 != k64,
- condition 3:
uint32(uint64(_gateKey)) == uint16(uint160(tx.origin));
- last 32 bits of k64 = last 16 bits of tx.origin,
0x0000wxyz = wxyz
.
- last 32 bits of k64 = last 16 bits of tx.origin,
- We took the last 4 characters of tx.origin with
- We need to satisify all the requirements for all three custom modifiers gateOne, gateTwo, and gateThree. If they are all satisfied, then
- Lessons:
- Exact gas required can be calculated or found via brute force.
- Modifiers append conditions to function executions. If the conditions in the modifiers are not met, the function will revert. Multiple modifiers execute in the order they are listed. If a modifier requires an argument, that gets passed in through the function, and passed along to the modifier.
- Puzzles with bit manipulation are lame.
- Goal: Become the
entrant
address by making theenter
function return true. - Methodology:
- We need to satisify all the requirements for all three custom modifiers gateOne, gateTwo, and gateThree. If they are all satisfied, then
enter
will return true. gateOne
: Just make sure to call enter from your deployed hack contract.gateTwo
: requires code size of the caller to be 0. This means we have to call enter within the constructor.gateThree
: Craft the correct key that satisfies(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max);
- This is basically saying a hashed version of our contract address XORed with the key must equal 0xFFFFFFFFFFFFFFFF.
- This can be simplified to
a ^ b = c
. You can simplysolve for key
with XOR as if it's a traditional addition or subtraction symbol. - Where
a
is the hashed version of the contract address,b
is the desired key, andc
is the max.
- We need to satisify all the requirements for all three custom modifiers gateOne, gateTwo, and gateThree. If they are all satisfied, then
- Lessons:
- Bitwise XOR properties: if
a = 1010
andb = 0110
then a ^ b = 1100
anda ^ a = 0
and0 ^ b = b
anda ^ a ^ b = b
anda ^ b = c
=b = c ^ a
- Bitwise XOR properties: if
- Goal: Get your Naught Coin token balance to zero despite the 10 year lockup.
- Methodology:
- Make sure that you have all the open zeppelin contracts to reference for imports.
npm install @openzeppelin/contracts
. - We need the NaughtCoin ABI to instantiate it so we can call approve from a script. Copy NaughtCoin code to NaughtCoin.sol, tweaked it to fix openzeppelin imports and then compiled with
npx hardhat compile
. NO NEED TO ACTUALLY DEPLOY, just need to grab the ABI. - Wrote hack contract which will simply call
transferFrom
on behalf of the player for their entier balance. Deploy contract hack contract. - Find the amount from console with
(await contract.balanceOf(player)).toString()
. Can also just open metamask and import the Naught Coin token and see the 1000000 tokens in your account. - Write script to approve and call attack to transferFrom the tokens out.
- Make sure that you have all the open zeppelin contracts to reference for imports.
- Lessons:
- If you're locking one function like
transfer
, make sure all other functions capable of moving tokenstransferFrom
are also locked. - Even if your contract doesn't define a function, if it's part of an inherited contract or interface, it's accessible. Always be aware of the full set of functions available in your contract, both from your code and from any inherited contracts.
approve
andtransferFrom
are powerful, anyone who can call those successfully for any amount, have access to all the tokens.- Understand your interfaced and template contracts and all their functions associated with them.
- If you're locking one function like
- Goal: Claim ownership of target contract.
- Methodology:
- Create a contract that first mimicks the storage layout of the target
Preservation
contract. This is so we can modify the state variables ofPreservation
. WhensetTime
is called from our hack, it's 'supposed' to update thestoredTime
, but instead will updateowner
since they occupy the same slot in storage due to the identical layout. - The first call to
setFirstTime
sets thetimeZone1Library
address to thePreservationHack
address. The second call tosetFirstTime
is supposed to callsetTime
onPreservationHack
, but because there is adelegatecall
insetFirstTime
, it runs in the context ofPreservation
and overwrites theowner
state variable withmsg.sender
, which is our hack contract claiming ownership. - Our malicious
setTime
function, instead of setting the time, can manipulate thePreservation
state variables, and simply overwrites theowner
state variable. uint256(uint160(address(this)))
is a way to cast the address of the current contract to a uint256 type. Needed becausesetFirstTime
expects a uint256 type.uint256(uint160(address(this)))
casts the address of the current contract to a uint256 type. Needed becausesetFirstTime
expects a uint256 type.address(uint160(_ownerAddress));
casts the uint256 address back into type address.
- Create a contract that first mimicks the storage layout of the target
- Lessons:
- Libraries are just reusable code without storage. Always use the
library
keyword for building libraries properly, to ensure they don't have their own storage. delegatecall
allows calling a function in another contract but runs in the context of the calling contract, meaning the state of the calling contract can be modified!- Delegate Calls can be dangerous. In our case, the unguarded
delegatecall
allows for overwriting of storage! (The attack can simply allign their storage to match. Allowing arbitrary overwrite of the library addresses was the pitfall.) - Before making a delegate call, always validate the target address to ensure it's an expected and trusted address. Never allow arbitrary addresses to be set and then invoked via delegate call.
- The use of delegatecall to call libraries can be risky, espeicially if the contract libraries have their own storage state!
- Libraries are just reusable code without storage. Always use the
- Goal: Everyone forgot the address of a contract that has 0.001 ether. Recover the 0.001 ether from the lost contract address.
- Methodology:
- Find address of token contract, then call function
destroy
which callsselfdestruct
to recover the 0.001 ether. - Can can simply calculate the address of the token contract, assuming it's nounce is 1. (Compile the SimpleToken contract to get it's ABI also, so we can instantiate it to interact with it.)
- Wrote a script to calculate and return the address, which could then instantiate the recovered contract, and then call
destroy
, passing in ato
address to receieve the self destruct funds.
- Find address of token contract, then call function
- Lessons:
- Contract addresses are deterministic and are calculated by keccak256(address, nonce) where the address is the address of the contract (or ethereum address that created the transaction) and nonce is the number of contracts the spawning contract has created (or the transaction nonce, for regular transactions).
addressIfNounce0= keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), sender, bytes1(0x80)));
addressIfNounce1 = keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), sender, bytes1(0x01)));
- You could send ether to an address that doesn't exist yet. And then create a contract at that exact address which could recover the ether. The ether would essentially be gone, sitting in a void inside a non-existant smart contract, with the only way to retrieve it via creating the address and recovering the ether.
- Goal: Provide Ethernaut with a
Solver
, which is a contract that responds towhatIsTheMeaningOfLife()
with the right number (42). The solvers code needs to be 10 opcodes at most. - Methodology:
- We have to use Raw EVM bytecode.
- Deploying our
MagicNumberHack
contract will manually deploy a contract with the specified hexed bytecode and then immediately callsetSolver
using the bytecode contract address to solve and return 42. - The runtime code of this bytecode contract is at most 10 opcodes. This piece (skipping first 2)
602a60005260206000f3
is 20 characters, or 10 bytes, which is 10 op codes.
- Lessons:
hex"69602a60005260206000f3600052600a6016f3"
is a representation of EVM opcodes in bytecode. This one happens to return 42.69
is the PUSH opcode for the next 9 bytes. Everything after f3 is discarded, because it's the constructor code which is temporary and only used for deployment.- You can manually deploy a contract using inline assembly, which takes direct bytes instead of high level code.
602a60005260206000f3
is the actual run time code which gets deployed and stays on chain - returns 42.- 60 2a: Push the number 0x2a (42 in decimal) onto the stack.
- 60 00: Push the number 0x00 onto the stack.
- 52: Swap the two top elements of the stack.
- 60 20: Push the number 0x20 onto the stack.
- 60 00: Push the number 0x00 onto the stack.
- f3: Return the top stack element.
- It seems redundant but it's organizing the stack correctly to return 42.
- Goal: Become owner of the contract.
- Methodology:
AlienCodex
inheritsOwnable
. The first state variable insideOwnable
is type address calledowner
. We need to changeowner
tomsg.sender
. Therevise
function allows us to change the content of thecodex
array at indexi
. Therefore, we need to find the indexi
such that accessingcodex[i]
will instead accessowner
slot 0.- Before doing anything else, we have to call
makeContact
, setting contact to true, satisfying the modifier, 'unlocking' all the other functions to call. - Next we call
retract
which reduces the length of the codex array by 1 using the antiquated length-- syntax from solidity v5. If you reduce the length of an empty array you get an underflow, 'wrapping around' the max uint256 giving us an array of length 2^256 - 1. By calling retract, we essentially have write access to ALL of the state variables inside theOwnable
contract. - Ethereum's
Storage
contains2^256
slots, each can hold 32 bytes of data. It's akin to a key:value dictonary, where each key is a slot, starting at slot 0, and each value can contain anything up to 32 bytes. - The
Codex
array after being underflowed, has2^256 - 1
indices. Each index can hold 32 bytes. The metadata associated with the length of Codex, despite not having it's own index, is stored in a storage slot. And by definition,Codex[0]
, it's first real index containing data can be found at a mystery slot h, whereh = hash(x);
wherex
is the slot position it's supposed to start at. Since ourCodex
array 'starts' at slot 1, then we hash(1). If it started at slot 5, we do hash(5). - Looking at the layout of the
Storage
ofAlienCodex
contract:- slot 0 =
owner
andcontact
(20 bytes and 1 byte, totalling 21 bytes). - slot 1 = Codex metadata about the length of itself.
- slot h stores Codex[0]
- slot h + 1 in storage stores Codex[1]
- slot h + 2 in storage stores Codex[2]
- slot h + 3 in storage stores Codex[3]
- slot h + i in storage stores Codex[i]
- slot 0 =
- We want
Codex[i]
to match withslot 0
, which contains theowner
variable we want to change. How is this possible ifslot 0
comes before theCodex
array?! slot 0
is what we want. The only way to get toslot 0
is to satisfyh + i = 0
. This meansi = - h
. There we leti
default to 0 and then doi = i - h
- So we call
revise
and pass ini
and msg.sender in bytes 32 to change the owner.
- Lessons:
- Ethereum hashes dynamic data like arrays and mappings in this manner to prevent collisions.
- This level exploits the fact that the EVM doesn't validate an array's ABI-encoded length vs its actual payload.
- You can exploit antiquated versions of solidity via underflowing an array. This expands the array's bounds to the entire storage area of 2^256, which you can then modify any of the storage slots that you wish.
- Goal: Deny the owner from withdrawing funds when they call withdraw(). Contract must still have funds and gas used less than 1M.
- Methodology:
- In the withdraw function, find a way to revert before
payable(owner).transfer(amountToSend);
. We can callsetWithdrawPartner
, making the partner our contract, and then doingpartner.call{value:amountToSend}{""};
making sure it reverts afterwards. - Because
withdraw
usescall
, that means a fallback will be executed. Which is our entrance to do whatever we need to do in order to stop the function at that point. There were a few techniques to immediately revert functions prior to Solidity v8. - One way is to use up all the gas. We can call
invalid()
inside of assembly inside of a fallback which will use up all the gas so the function reverts.
- In the withdraw function, find a way to revert before
- Lessons:
revert()
wouldn't suffice to immediately stop because there is norequire
check after our partner call, so it would just continue to execute the next part of the code.- An infinite loop would also consume all gas.
assert(false)
also would consume all gas prior to solidity v8.
- Goal: Get the item from the shop for less than the price asked.
- Methodology:
- We need to set
isSold
to true and price < 100. price
is actually called twice, once during the check, and again to set the price. Therefore, we just need to pass a number greater than 100 the first timeprice
is called, and less than100
the second time. We can't use state variables but we can use the booleanisSold
to determine what value to pass.- So we interface as a
buyer
, callattack
which callsbuy
which calls ourprice
which returns 420 (greater than 100) to pass the check, and then 69 to actually change theprice
to that. Voila!
- We need to set
- Lessons:
- It's unsafe to change the state based on external and untrusted contracts logic. You have to trust they pass consistent data (like price) over multiple calls. A solution would be to cache the first response, or don't rely on externally interfaced function calls to produce static variables.
- Even though the
Buyer
interface's functionprice
is view, notice we can still produce non-static results by referencing any other state variable that might change (isSold
) in our case.
- Goal: Player starts with 10 of token1 and token2. DEX starts with 100 of token1 and token2. Get all of token1 or token2.
- Methodology:
- The
getSwapPrice
function determines price solely based on the contract's current balances. It doesn't even account for the input amount when updating balances. So you can swap back and forth gaining more and more tokens for less.swapAmount = (amountIn * toTokenBalance) / fromTokenBalance
- From console, call their 'custom' approve function to approve our hack contract to spend/swap both tokens:
contract.approve("hack contract address", 1000)
. (Otherwise you find the address of their two tokens by going on etherscan sepolia with the deployed dex contract instance, and approve manually.) - Script can then call
attack
, which swaps tokens back and forth abusing the poor pricing mechanism. Calculated how many token2 needed to go in for the last swap to completely drain out token1.
- The
- Lessons:
- Getting data from a single source is a massive attack vector. Instead use oracles and aggregated data.
- Goal: Player starts with 10 of token1 and token2. DEX starts with 100 of token1 and token2. Get entire balances of token1 and token2 from the dex.
- Methodology:
- There is no check inside of
swap
to verify thefrom
orto
addresses match onlytoken1
ortoken2
. This means any token, like one we created can be input as afrom
orto
address. swapAmount = (amountIn * toTokenBalance) / fromTokenBalance
. We want swapAmount = 100, taking the entire balance of a token from the dex. We have the ability to manipulate theamountIn
and thefromTokenBalance
.- 100 = (amountIn * toTokenBalance) / fromTokenBalance
- 100 = (x * 100) / y where y >= x. ==> x = y. (1, 1) is a solution. (69, 69) is also a solution. fromTokenBalance = 1, amountIn = 1
- In our hack contract, we create an interface for token1 and token2, deploy two of our own poisonous tokens, mint 2 of each, transferring 1 of each to dex, approve our tokens, then when we swap 1 of our poison tokens we get the entire 100 balance of the token1 and token2.
- There is no check inside of
- Lessons:
- Have a check to determine the kind of ERC-20 tokens on your DEX. Use better formula to determine price.
-
Goal: Change the
admin
state variable inside ofPuzzleProxy
to bemsg.sender
, our hack contractPuzzleWalletHack
. -
Methodology:
Background
- Let
implementation
=PuzzleWallet
. Letproxy
=PuzzleProxy
. If we succeed in part 1 and part 2, that allows us to callsetMaxBalance
, which will change the theadmin
state variable inside ofPuzzleProxy
. - When using
delegatecall
, the code of the called contractimplementation
executes in the context and uses the storage of the calling contractproxy
. Collisions can allow unwanted changes to the overlapped storage slots. PuzzleProxy
inheritsUpgradeableProxy
, which has a fallback that uses adelegatecall
to call missing functions fromPuzzleWallet
.- There is overlap in the storage layout between the
proxy
andimplementation
, allowing for us to update state variables from eachother. pendingAdmin
state variable fromPuzzleProxy
is in the same slot asowner
fromPuzzleWallet
, colliding with it.admin
state variable fromPuzzleProxy
is in the same slot asmaxBalance
fromPuzzleWallet
, colliding with it.
Part 1: Whitelist
PuzzleWalletHack
- Calling
proposeNewAdmin
and passing in ourPuzzleWalletHack
address updatespendingAdmin
state variable inPuzzleProxy
, which updatesowner
state variable ofPuzzleWallet
to be ourPuzzleWalletHack
contract address. - This makes us the
owner
ofPuzzleWallet
, so we can calladdToWhiteList
passing in our address makingwhiteListed = true
. We can now use many of the needed functions to eventually callsetMaxBalance
.
Part 2: Reduce
PuzzleWallet
ether balance from 0.001 ETH to 0.execute
fromPuzzleWallet
will send some ether out via acall
function. However, we cannot callexecute
becausebalances[msg.sender]
is 0 since we haven't set anything, so passing in any value will fail. We need to increasebalances[msg.sender]
. We can do this by callingdeposit
. We also cannot directly calldeposit
because the check will always fail. maxBalance is set to 0, and thePuzzleWallet
balance cannot be less than 0 eth.multicall
is the key here. It allows us to call multiple functions within a single call withdelegatecall
, allowing us to calldeposit
twice, keepingmsg.value
at 0.001 ETH, but updating thebalances[msg.sender]
twice to 0.002 ETH. It also passes thedeposit
check because the balances doesn't update until after the call, so we can calldeposit
successfully twice.- The
deposit
checkaddress(this).balance <= maxBalance
will only execute once, but the increment to balance happens twice. - When
deposit
is invoked viadelegatecall
, it affects the balances mapping in thePuzzleWallet
’s storage without sending more ETH. - Calling
deposit
withmulticall
for the first time, it updatesbalances[msg.sender]
andmsg.value
to 0.001 ETH. - Calling
deposit
withmulticall
for the second time, it updatesbalance[msg.sender]
to 0.002 ETH, butmsg.value
persists at 0.001 ETH multicall
says we cannot calldeposit
more than once. So we calldeposit
frommulticall
successfully the first time, and then have to callmulticall
again, and then calldeposit
again. This resets thedepositCalled
flag check.- Now
PuzzleWallet
should have gone from the starting balance of 0.001 ETH to 0.002 ETH, BUT our withdraw allowance from insideexecute
(withdraw) function is successfully 0.002 ETH instead of 0.001 ETH. Therefore, we just callexecute
and withdraw the entire 0.002 ETH balance, satisfying the 2nd condition.
Part 3: Finishing Up
- We've satisfied the conditions to successfully call
setMaxBalance
. We pass in ourPuzzleWalletHack
address, which updatesmaxBalance
state variable inPuzzleWallet
, which updatesadmin
state variable ofPuzzleProxy
to be ourPuzzleWalletHack
contract address.
- Let
-
Lessons:
- Storage updating occurs in the storage of the contract that’s providing the context in which the delegatecall is made. In most cases this is the proxy contract, unless there are storage collisions.
- Using a multi-call pattern where you perform multiple
delegatecalls
to a function could lead to unwanted transfers of ETH.delegatecalls
keeps the originalmsg.value
andmsg.sender
. - Using proxy contracts is good to bring upgradeability features and reduce the deployment's gas cost. But beware of storage collisions/overlaps.
- Function contexts and storage slots need to be meticulously managed and understood.
- Function exists in
implementation
but notproxy
:User → Proxy → Implementation
. The proxy's fallback gets triggered making adelegatecall
to the implementation. Despite executing code in the context of implementation, this updates the collidedproxy
storage. - Function exists in
proxy
but notimplementation
:User → Proxy
. Proxy executes normally within it's own context but updates the collidedimplementation
storage.
- Note: I had submit this one via a console because the traditional hardhat solution wasn't working. Perhaps Sepolia was just busy or I misspelled or forgot something. The general idea still persists. Check out Cayo Tor's Motorbike solution using just the console: https://coinsbench.com/25-motorbike-ethernaut-explained-bcec27ae306. CONTRACT/SCRIPT MAY NOT WORK.
- Goal:
selfdestruct
the Engine contract. - Methodology:
Motorbike
is the proxy forEngine
. If we make our hack contract the upgrader we can callupgradeToAndCall
which gives us the ability to pass in a malicious function on behalf of Motorbike which will execute in the context ofEngine
due to thedelegatecall
.- We obtain the implementation address with
await web3.eth.getStorageAt(contract.address, 'imp_slot')
. We can now use this address in our attack. - We can make our
MotorBikeHack
contract theupgrader
by simply callinginitialize
because theupgrader
is not yet initialized. - Then we can call
upgradeToAndCall
passing in our address and any function we want. We pass inexplode
which callsselfdestruct
. At this point,Engine
sets its new implementation address toMotorBikeHack
. Then it callsexplode
onMotorBikeHack
withdelegatecall
. Importantly, thisselfdestruct
is routed toEngine
contract because thedelegatecall
makes the data run in the context ofEngine
.
- Lessons:
- Ensure the
initialize
function is protected so it cannot be called arbitrarily, and already set an upgrader address beforehand. - Have guards in place to prevent direct calls into an implementation contract, ensure all interactions occur via the proxy.
- Have the upgrade functionality in the proxy.
- Ensure the
-
Goal: Determine vulnerability and make a
Forta
bot to notify when the vulnerability takes place. Exploiting the vulnerability is optional here. -
Methodology:
Background
-
There are 3 interfaces,
IForta
,IDetectionBot
, andDelegateERC20
. There are 4 contracts.Forta
,CryptoVault
,LegacyToken
akaLGT
, andDoubleEntryPoint
akaDET
.Interfaces
IForta
has functions for interacting with Forta platform. WithsetDetectionBot
you can create a detection bot with your bot's contract address. Functionsnotify
andraiseAlert
are used as notification mechanisms. Importantly,notify
tries to call thehandleTransaction
method in the players bot with `msg.data.IDetectionBot
represents the bot with its mainhandleTransaction
function, responsible for processing transaction, taking in user address and transaction data as input.DelegateERC20
hasdelegateTransfer
function allowing delegated transfers of ERC20 tokens, taking in recipient's address, amount, and the original sender.
Contracts
Forta
is the bot setup contract, dealing with setting/connecting a detection bot. It implements all 3 functions fromIForta
.CryptoVault
contains logic for sweeping (transferring) tokens.setUnderlying
function sets theunderlying
variable of typeIERC20
for whichever token it's interacting with.sweepToken
takes in a non-underlying token and transfers it to a recipient along with an amount, basically just allowing the token to call itstransfer
function. It's deployed with an initial recipient address as thesweptTokensRecipient
.LegacyToken
is a token, inheritingDelegateERC20
. It'sdelegateToNewContract
function allows it to set a new contract as thedelegate
for token transfers. It'stransfer
function is custom, usingdelegateTransfer
instead if adelegate
is set, otherwise calling the standardtransfer
function of an ERC20.DoubleEntryPoint
is a token, inheritingDelegateERC20
. It initializes some state variables and mints 100 ether worth of the token toCryptoVault
. It has two modifiers before it'sdelegateTransfer
function can be called. The firstonlyDelegateFrom
requires the caller to be thedelegatedFrom
address of theLegacyToken
contract. The other modifierfortaNotify
notifies forta. Finally thedelegateTransfer
is a custom function that overrides theDelegateERC20
delegateTransfer
function to transfer tokens on behalf of origSender, notifying Forta in the process.
Vulnerability
- Let's query the storage of
CryptoVault
andLegacyToken
via the console. - The
underlying
address is found by runningawait contract.cryptoVault()
to yield the CryptoVault address, and then querying it's storage withawait web3.eth.getStorageAt(CryptoVaultAddress, 1)
. TheDET
token is the initialunderlying
address. - The
delegate
address is found by runningawait contract.delegatedFrom()
to get theLegacyToken
address. (InDET
, legacyToken is set asdelegatedFrom
). Then we can runawait web3.eth.call({from: player, to: LegacyTokenAddress, data: '0xc89e4361'})
. Pass in yourLegacyToken
address.data
is the automatically generated getter function for the publicdelegate
state variable ofLegacyToken
, found also via the console. - The
underlying
address and thedelegate
address match, which is the problem. Recall that theCryptoVault
contract has anunderlying
state variable, which is intended to be the main token that should NOT be swept by the sweepToken function. If someone callssweeptToken
with the address ofLGT
,DET
will be transferred out of theCryptoVault
. This is because thesweepToken
function will successfully see thatLegacyToken
is not theunderlying
token. But becauseLegacyToken
has itstransfer
function overridden to use thedelegate
, which isDET
, the actual transfer of tokens is done viaDET
. It's basically passing theunderlying
check using theLegacyToken
contract address, and then under the hood theLegacyToken
uses thedelegate
, which isDET
. - To exploit (optional) you could call
sweepToken
fromCryptoVault
withLegacyToken
contract address passed in.
Bot
- If an exploiter calls
sweepToken
withLGT
contract address passed in, this initiates a call todelegateTransfer
withinDET
contract. The calls data,msg.data
, is whathandleTransaction
receives becausedelegateTransfer
has thefortaNotify
modifier. - We need to deploy a bot contract with a
handleTransaction
method that alerts/notifies Forta aboutsweepToken
calls. We flag allsweepToken
calls by checking iforigSender
is theCryptoVault
address, which ultimately is what callssweepToken
. - We'll need the
IDetectionBot
andIForta
interfaces in our bot contract for theForta
setup andhandleTransaction
function. We'll also needDelegateERC20
interface for it'sdelegateTransfer
function, used by another interface we'll needIDoubleEntryPoint
, and alsoICryptoVault
for the vault. - We set the state variables and instantiate them with the ethernaut instance address (DoubleEntryPoint), and make a
handleTransaction
function. - Importantly, we need to be able to parse the incoming
msg.data
from thedelegateTransfer
function (because it has the fortaNotify which passes it's data along to your botshandleTransaction
function.). We need to determine iforigSender
is the vault address, and if so raise an alert. - Looking at
delegateTransfer
has a function selector and three parameters, the call data is ordered with function selector first and then by the parameters. (function selector, address to, uint value, address origSender). The function selector is 4 bytes. The parameters are of types that are 32 bytes each. That's 100 bytes total for the calldata. - So grabbing the last 32 bytes should suffice? No. Addresses are 32 bytes but technically padded with 0's for 12 bytes (24 0's). So instead to grab
origSender
address, we need only the last 20 bytes out of 100, soaddress(bytes20(msgData[80:]))
does the trick. - You could also do it with assembly like this:
assembly {origSender := calldataload(0xa8)}
Because 0xa8 is the position if you look at the byte data.
Deploy and Submit
- We deploy the bot via script, passing in the ethernaut level instance during it's construction.
- We then just have to instantiate Forta, and link our bot up with Forta to prove to ethernaut that our
handleTransaction
function will properly alert Forta. - Grab fortaAddress from console with
await contract.forta()
. Grab fortaABI from when we deployed our bot contract via hardhat, the artifacts folder will have the IForta.json which is the ABI which is good enough, should match the actual. DOUBLE CHECK PATH. Grab deployer like normal. - Finally we can instantiate Forta and call
setDetectionBot
passing in our deployed bot contract address. This sets our bot, and we can now submit our instance to ethernaut.
-
-
Lessons:
- If you are checking for a specific address to be blacklisted from transferring out of a contract, make the blacklist more straightforward. Also, don't accidentally set the delegate to be the same as the blacklisted token address. And make sure nobody else can set your blacklisted token to be a delegate.
- calldata is based on the parameter types and function selector, organized with the function selector first, and the parameters after.
- Goal: Drain all the balance from the wallet.
- Methodology:
- Four contracts,
GoodSamaritan
,Coin
andWallet
and aINotifyable
interface.GoodSamaritan
is supposed to be a benevolent faucet-like mechanism. It deploysWallet
andCoin
as helpers. - Our
GoodSamaritanHack
contractattack
function callsrequestDonation
. TherequestDonation
function fromGoodSamaritan
tries to calldonate10
fromwallet
. Thedonate10
checks and sees there is ample balance left in thecoin
contract, so it calls thetransfer
function fromcoin
to send 10 coins. Thetransfer
function sends our hack contract 10 coins, verifies that it is a contract, and then theINotifyable
interface tells our hack contract to callnotify
, innocently attempting to tell our contract to notify itself of 10 coins coming in. This is a grave mistake. Our maliciousnotify
function throws the custom errorNotEnoughBalance
because our check of amount == 10 passed. So what happens is back inGoodSamaritan
contract, therequestDonation
function half finishes thetry wallet.donate10
, the part that sends us 10 coins, but then our maliciousnotify
that throws the custom error will be caught byrequestDonation
, which then callstransferRemainder
fromwallet
. SotransferRemainder
once again invokes thetransfer
function ofcoin
, passing incoin
entire balance! This time the amount is 999,990, which equals it'scurrentBalance
. So it sends us all those tokens too, and then callsnotify
again fromGoodSamaritanHack
. Butnotify
no longer matters since we already have all the coins, nothing happens since amount is not 10 anymore, and sorequestDonation
fully completes.
- Four contracts,
- Lessons:
- External calls to an unknown contract function is dangerous. External calls to other contracts can introduce can introduce problems like changing the state of the current contract, introduce reentrancy attacks, or throwing custom errors.
- Any contract can implement the
INotifyable
interface. - Get rid of the custom
NotEnoughBalance
error, replace it with an explicit check to see if it's currentBalance is 10 or less, so thetransferRemainder
can only ever transfer 10 or less. - 'Sanitize' external calls with Checks-Effects-Interactions pattern, understand external calls can do anything.
- Make sure errors cannot be generated from unexpected sources.
- Instead of pushing funds out to recipients (which requires calling into their contracts), allow them to pull their funds. This way, you don't call into external contracts during your contract's critical operations.
- I thought the logic was flawless and made sense but the attack function still reverted. Perhaps a small typo or blindspot somewhere. I understand how the hack works, so I will not bother to debug further. CONTRACT/SCRIPT MAY NOT WORK.
- Goal: Become the entrant.
- Methodology:
- gateOne: Our hack contract can become owner by calling
construct0r
function. It is mispelled so it is a normal function, not a constructor, so an owner was never initialized. As long as we call theenter
function from our attack script,tx.origin
is our metamask deployer, not our hack contract. - gateTwo: Calling
getAllowance
function with the right password will turnallowEntrance
to true. - Getting the password can be done in a few ways. The roundabout way is to grab it from storage. First instantiate trick with
await contract.createTrick()
. Now you can get the trick address withawait contract.trick()
. Finally, you can callawait web3.eth.getStorageAt(trickAddress, 2)
to get the password. - You can also just see that the password is set to
block.timestamp
. So we can callgetAllowance(block.timestamp)
directly. This will call thecheckPassword
fromtrick
which is theSimpleTrick
contract, which verifies the password, returning true, changing theallowEntrance
to be true, satisfying gateTwo. - gateThree: We simply have to send the contract more than 0.001 ether and make sure to not have a receive or fallback function in our hack contract, so their send ether function fails when they try to send ether to the owner, which is us.
- All modifiers are satisifed and calling
enter
is successful.
- gateOne: Our hack contract can become owner by calling
- Lessons:
- tx.origin != msg.sender as we already knew.
- We can see all storage on chain even if it's private.
- If a smart contract doesn't have a receive or fallback method, it cannot receive ether (unless you self destruct into it like in other levels).
-
Goal: Make
switchOn
true. -
Methodology:
- Can be solved with console.
- We do not have access to
turnSwitchOn
orturnSwitchOff
functions because theironlyThis
modifier cannot be passed because we are not theSwitch
contract address that can call them. - The only method we can call is
flipSwitch
, which has anonlyOff
modifier we need to pass. The modifier checks the calldata starting with position 68, and assumes the next 4 bytes is the selector ofturnOffSwitch
. It seems likeflipSwitch
can only be called withturnOffSwitch
as its data. But we can manipulate the calldata encoding. - The function signatures for
turnSwitchOff
andturnSwitchOn
will be nice to have. We can get them withweb3.eth.abi.encodeFunctionSignature("turnSwitchOff()");
andweb3.eth.abi.encodeFunctionSignature("turnSwitchOn()");
. Their 4 byte signatures are0x20606e15
and0x76227e12
. - For the
flipSwitch
function we can directly see what the actual ABI-encoded calldata to the EVM is supposed to look using theabi.encodeFunctionCall
method. We simply pass in the function name, type, the inputs that the function takes in, and it's signature. Notice we pass in the signature ofturnSwitchOff
as that's what it's supposed to call.
web3.eth.abi.encodeFunctionCall({ name: 'flipSwitch', type: 'function', inputs: [{ type: 'bytes', name: '_data' }] }, ['0x20606e15'])
- Function signature:
0x30c13ade
- Chunk 0:
0000000000000000000000000000000000000000000000000000000000000020
- Chunk 1:
0000000000000000000000000000000000000000000000000000000000000004
- Chunk 2:
20606e1500000000000000000000000000000000000000000000000000000000
- The function signature is for
flipSwitch
.Chunk 0
is a pointer where the 20 is in hexidecimal, which translates to 32 bytes. Therefore, it says 'Go toChunk 1
to find the actual value of the bytes'. ThenChunk 1
is a pointer saying 'The next 4 bytes after me is the actual data'. That points to20606e15
which isturnSwitchOff
. - The idea is that we can change
Chunk 0
pointer to point toturnSwitchOn
instead, while being careful to keepturnSwitchOff
at the same hardcoded location of 68 bytes (4 function signature + 32 chunk byte + 32 chunk byte) location so theonlyOff
check passes. - Function signature:
0x30c13ade
- Chunk 0:
0000000000000000000000000000000000000000000000000000000000000060
- Chunk 1:
0000000000000000000000000000000000000000000000000000000000000000
- Chunk 2:
20606e1500000000000000000000000000000000000000000000000000000000
- Chunk 3:
0000000000000000000000000000000000000000000000000000000000000004
- Chunk 4:
76227e1200000000000000000000000000000000000000000000000000000000
Chunk 0
now points toChunk 3
instead ofChunk 1
because 60 is in hexidecimal which translates to 96 bytes. AndChunk 3
says 'The next 4 bytes after me is the actual data'. So we putturnSwitchOn
function signature there. The check passes, verifying thatturnSwitchOff
is at locatoin 68, butflipSwitch
will use the function signatureturnSwitchOn
instead ofturnSwitchOff
. Concatenate this and pass it in as the data, and submit instance.await sendTransaction({from: player, to: contract.address, data: manipulatedCalldata})
-
Lessons:
- Assuming static positions in calldata with dynamic types is bad. The EVM doesn't understand ABI encoding or function signatures, those are concepts from Solidity. If you venture into the low-level assembly, understand that there is a difference between what the EVM digests and what the calldata is actually doing and how it is organized.
- ethernaut: https://ethernaut.openzeppelin.com/
- Most solutions heavily inspired and adapted from Smart Contract Programmer's remix solutions: https://www.youtube.com/@smartcontractprogrammer
- DoubleEntryPoint Solution: https://github.com/osmanozdemir1/ethernaut-solutions
- DoubleEntryPoint great overview: https://medium.com/coinmonks/26-double-entry-point-ethernaut-explained-f9c06ac93810
- The most elegant of the GatekeeperThree solutions: https://ardislu.dev/ethernaut/28
- Switch good explanation 1: https://ardislu.dev/ethernaut/29
- Switch good explanation 2: https://medium.com/coinmonks/28-gatekeeper-three-ethernaut-explained-596115f165a
- Switch good explanation 3: https://github.com/0xIchigo/Ethernaut/blob/master/Switch/Solution.md
- provider: https://alchemy.com/
- metamask: https://metamask.io/
- faucet for free Sepolia Test ETH: https://sepoliafaucet.com/
- hardhat: https://hardhat.org/docs
- etherscan sepolia: https://sepolia.etherscan.io/