Ethernaut: Levels 19 to 21
April 20, 2020•1,398 words
The Ethernaut is a Web3/Solidity based wargame inspired from overthewire.org, played in the Ethereum Virtual Machine. Each level is a smart contract that needs to be 'hacked'.
goto:
Alien Codex
Shop
Level 19 - Denial
Target
This is a simple wallet that drips funds over time. You can withdraw the funds slowly by becoming a withdrawing partner.
If you can deny the owner from withdrawing funds when they call withdraw() (whilst the contract still has funds) you will win this level.
Contract
import 'openzeppelin-solidity/contracts/math/SafeMath.sol';
contract Denial {
using SafeMath for uint256;
address public partner; // withdrawal partner - pay the gas, split the withdraw
address payable public constant owner = address(0xA9E);
uint timeLastWithdrawn;
mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances
function setWithdrawPartner(address _partner) public {
partner = _partner;
}
// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint amountToSend = address(this).balance.div(100);
// perform a call without checking return
// The recipient can revert, the owner will still get their share
partner.call.value(amountToSend)("");
owner.transfer(amountToSend);
// keep track of last withdrawal time
timeLastWithdrawn = now;
withdrawPartnerBalances[partner] = withdrawPartnerBalances[partner].add(amountToSend);
}
// allow deposit of funds
function() external payable {}
// convenience function
function contractBalance() public view returns (uint) {
return address(this).balance;
}
}
Weakness
The withdraw
function uses call
to send ETH to an unknown address. This poses two threats:
- Reentrancy (see Level 10 - Reentrancy: the recipient can implement a malicious fallback that will call back ('reenter') the
withdraw
function - Out Of Gas (OOG) error:
call
forwards all gas. The recipient may consume it all to prevent the execution of the following instructions. ## Solidity Concepts: error handling
expression | syntax | effect | OPCODE | |
---|---|---|---|---|
throw | if (condition) { throw; } |
reverts all state changes and deplete gas | version<0.4.1: INVALID OPCODE - 0xfe, after: REVERT- 0xfd | deprecated in version 0.4.13 and removed in version 0.5.0 |
assert | assert(condition); |
reverts all state changes and depletes all gas | INVALID OPCODE - 0xfe | |
revert | if (condition) { revert(value) } |
reverts all state changes, allows returning a value, refunds remaining gas to caller | REVERT - 0xfd | |
require | require(condition, "comment") |
reverts all state changes, allows returning a value, refunds remaining gas to calle | REVERT - 0xfd |
So the main difference is that assert
depletes all gas while revert
and require
don't. require
is a less verbose version of revert
.
When to use which error handling method? According to the solidity documentation
The assert function should only be used to test for internal errors, and to check invariants. Properly functioning code should never reach a failing assert statement; if this happens there is a bug in your contract which you should fix.
The require function should be used to ensure valid conditions that cannot be detected until execution time. This includes conditions on inputs or return values from calls to external contracts.
Hack
We want to make the owner.transfer(amountToSend);
instruction fail right after the partner.call.value(amountToSend)("");
instruction. As call
forwards all gas, we will cause an Out Of Gas error.
- Deploy a malicious contract and set it as withdraw partner with
setWithdrawPartner
- Cause an Out Of Gas Error by implementing a malicious fallback (that receive the ETH sent by the
partner.call.value(amountToSend)("")
instruction)- Option 1: reenter in
denial.withdraw()
- Option 2:
assert
a false condition
- Option 1: reenter in
Takeaways
See Level 10 - Reentrancy takeaways.
Level 20 - Alien Codex
Target: claim ownership of the contract.
Contract
pragma solidity ^0.5.0;
import 'openzeppelin-solidity/contracts/ownership/Ownable.sol';
contract AlienCodex is Ownable {
bool public contact;
bytes32[] public codex;
modifier contacted() {
assert(contact);
_;
}
function make_contact() public {
contact = true;
}
function record(bytes32 _content) contacted public {
codex.push(_content);
}
function retract() contacted public {
codex.length--;
}
function revise(uint i, bytes32 _content) contacted public {
codex[i] = _content;
}
}
Weakness
codex
is stored as a dynamic array. retract()
reduces codex
length without checking against underflow. So it is actually possible to set the codex
array length to 2²⁵⁶ -1, which gives power to modify all storage slots.
Solidity Concepts: storage layout of dynamically sized variables
Each smart contract running on the Ethereum Virtual Machine maintains its own state using a key:value storage mapping. The number possible of keys is so huge that most keys actually contain empty values. Each key is called a slot. They are 2²⁵⁶ - 1 slots. Each slot can contain 32 bytes of data.
In the Level 8 -Vault, I listed the basic storage layout rules. Each statically sized variable gets a reserved slot which is defined at compilation time. But what about dynamically sized variables? As their size is not fixed beforehand, how to know which slots to reserve?
With regular hard drive space or RAM an allocation step to find free space to use exists, which is followed by a release step to put that space back into the pool of available storage. The number of storage locations of a smart contract is so huge that it manages its storage differently. It just needs to figure a way to define a storage location to start from. Indeed the likelihood of having location clashes is (not rigorously) 0.
Due to their unpredictable size, mapping and dynamically-sized array types use a Keccak-256 hash computation to find the starting position of the value or the array data. These starting positions are always full stack slots.
For dynamic arrays, [the] slot stores the number of elements in the array (byte arrays and strings are an exception, see below). For mappings, the slot is unused (but it is needed so that two equal mappings after each other will use a different hash distribution). Array data is located at keccak256(p) and the value corresponding to a mapping key k is located at keccak256(k . p) where . is concatenation.
Hack
Analyse storage layout
Slot # Variable 0 contact bool(1 bytes] & owner address (20 bytes), both fit on one slot 1 codex.length keccak256(1) codex[0] keccak256(1) + 1 codex[1] 2²⁵⁶ - 1 codex[2²⁵⁶ - 1 - uint(keccak256(1))] 0 codex[2²⁵⁶ - 1 - uint(keccak256(1)) + 1] --> can write slot 0! call
make_contact
to be able to pass thecontacted
modifercall
retract
: this provokes and underflow which leads tocode.length = 2^256 - 1
Compute codex
index
corresponding to slot 0:2²⁵⁶ - 1 - uint(keccak256(1)) + 1 = 2²⁵⁶ - uint(keccak256(1))
Call
reverse
passing itindex
and your address left padded with 0 to total 32 bytes ascontent
Takeaways
Modifying a dynamic array length without checking for over/underflow is very dangerous as it can expand the array's bounds to the entire storage area of 2256 - 1. This can possibly enable modifying the whole contract storage.
Level 21 - Shop
Target: get the item from the shop for less than the price asked.
Contract
pragma solidity ^0.5.0;
interface Buyer {
function price() external view returns (uint);
}
contract Shop {
uint public price = 100;
bool public isSold;
function buy() public {
Buyer _buyer = Buyer(msg.sender);
if (_buyer.price.gas(3000)() >= price && !isSold) {
isSold = true;
price = _buyer.price.gas(3000)();
}
}
}
Weakness
Like for the Level 11 - Elevator, Shop
never implements the price()
function from the Buyer
interface. An attacker can create a contract that implements its own version of this function.
Solidity Concepts
- Interfaces
- Inheritance
External function call with gas() option
When calling functions of other contracts, you can specify the amount of Wei or gas sent with the call with the special options .value() and .gas(), respectively.
Gas cost to modify storage
Hack
buy()
is callingprice()
twice:In the conditional check: the price returned must be higher than 100 to pass
To update the price: here is the opportunity to return a value lower than 100.
So we need to implement a malicious price
function that:
- returns a value higher than 100 on its first call
- returns a value lower than 100 on its second call
- costs less than 3000 gas to execute. So we can't write in storate. We will read
isSold
instead to perform a conditinal check:isSold() ? 1: 101
Security Takeaways
- Don't let interface function unimplemented.
- It is unsafe to approve some action by double calling even the same view function.