Ethernaut: Levels 10 to 12
April 15, 2020•1,183 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'.
Level 10 - Reentrancy
Target: steal all funds from the contract.
Contract
pragma solidity ^0.5.0;
import 'openzeppelin-solidity/contracts/math/SafeMath.sol';
contract Reentrance {
using SafeMath for uint256;
mapping(address => uint) public balances;
function donate(address _to) public payable {
balances[_to] = balances[_to].add(msg.value);
}
function balanceOf(address _who) public view returns (uint balance) {
return balances[_who];
}
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
(bool result, bytes memory data) = msg.sender.call.value(_amount)("");
if(result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
function() external payable {}
}
Weakness
Similarly to the attack in the Level 7 - Force, when sending directly funds to an address, one does not now if it is an POA or a contract, and how the contract the contract will handle the funds.
The fallback could "reenter" in the function that triggered it.
If the check effect interaction pattern is not followed, one could withdraw all the funds of a contract: e.g if a mapping that lists the users' balance is updated only at the end at the function!
Solidity Concepts: "reenter", calling back the contract that initiated the transaction and execute the same function again.
Check also the differences between call
, send
and transfer
seen in Level 7 - Force.
Especially by using call()
, gas is forwarded, so the effect would be to reenter multiple times until the gas is exhausted.
Hack
- Deploy an attacker contract
- Implement a payable fallback that "reenter" in the victim contract: the fallback calls
reentrance.withdraw()
- Donate an amount
donation
- "Reenter" by withdrawing
donation
: callreentrance.withdraw(donation)
from attacker contract - Read
remaining
balance of victim contract:remaining = reentrance.balance
- Withdraw
remaining
: callreentrance.withdraw(remaining)
from attacker contract
Takeaways
To protect smart contracts against reentrancy attacks, it used to be recommended to use transfer()
instead of send
or call
as it limits the gas forwarded. However gas costs are subject to change. Especially with EIP 1884 gas price changed.
So smart contracts logic should not depend on gas costs as it can potentially break contracts.
Therefore transfer
is then no longer recommended. Source 1 Source 2
Use call
instead. As it forwards all the gas, execution of your smart contract won't break.
But if we use call
and don't limit gas anymore to prevent ourselves from errors caused by running out of gas, we are then exposed to reentrancy attacks, aren't we?!
This is why one must:
- Respect the check-effect-interaction pattern.
- Perform checks
- who called?
msg.sender == ?
- how much is send?
msg.value == ?
- Are arguments in range
- Other conditions...
- who called?
- If checks are passed, perform effects to state variables
- Interact with other contracts or addresses
- external contract function calls
- send ethers ...
- Perform checks
- or use a use a reentrancy guard: a modifier that checks for the value of a
locked
bool
Level 11 - Elevator
Target: reach the top of the Building.
Contract
pragma solidity ^0.5.0;
interface Building {
function isLastFloor(uint) external returns (bool);
}
contract Elevator {
bool public top;
uint public floor;
function goTo(uint _floor) public {
Building building = Building(msg.sender);
if (! building.isLastFloor(_floor)) {
floor = _floor;
top = building.isLastFloor(floor);
}
}
}
Weakness
The Elevator
never implements the isLastFloor()
function from the Building
interface. An attacker can create a contract that implements this function as it pleases him.
Solidity Concepts: interfaces & inheritance
Interfaces are similar to abstract contracts, but they cannot have any functions implemented.
Contracts need to be marked asabstract when at least one of their functions is not implemented.
Contract Interfaces specifies the WHAT but not the HOW.
Interfaces allow different contract classes to talk to each other.
They force contracts to communicate in the same language/data structure. However interfaces do not prescribe the logic inside the functions, leaving the developer to implement it.
Interfaces are often used for token contracts. Different contracts can then work with the same language to handle the tokens.
Interfaces are also often used in conjunction with Inheritance.
When a contract inherits from other contracts, only a single contract is created on the blockchain, and the code from all the base contracts is compiled into the created contract.
Derived contracts can access all non-private members including internal functions and state variables. These cannot be accessed externally viathis
, though.
They cannot inherit from other contracts but they can inherit from other interfaces.Hack
- Write a malicious attacker contract that will implement the
isLastFloor
function of theBuilding
interface- implement
isLastFloor
Note thatisLastFloor
is called 2 times ingoTo
. The first time it has to returnTrue
, but the second time it has to returnFalse
- invoke
goTo()
from the malicious contract so that the malicious version of theisLastFloor
function is used in the context of our level’s Elevator instance!
Takeaways
Interfaces guarantee a shared language but not contract security. Just because another contract uses the same interface, doesn’t mean it will behave in the same way.
Level 12 - Privacy
Target: unlock contract.
Contract
pragma solidity ^0.5.0;
contract Privacy {
bool public locked = true;
uint256 public ID = block.timestamp;
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(now);
bytes32[3] private data;
constructor(bytes32[3] memory _data) public {
data = _data;
}
function unlock(bytes16 _key) public {
require(_key == bytes16(data[2]));
locked = false;
}
/*
A bunch of super advanced solidity algorithms...
,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`
.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,
*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^ ,---/V\
`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*. ~|__(o.o)
^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*' UU UU
*/
}
Weakness
Similarly to the Level 8 -Vault, the contract's security relies on the value of a variable defined as private. This variable is actually publicy visible
Solidity Concepts
The layout of storage data in slots and how to read data from storage with getStorageAt
were covered in Level 8 -Vault.
The slots are 32 bytes long.
1 byte = 8 bits = 4 nibbles = 4 hexadecimal digits.
In practice, when using e.g getStorageAt
we get string hashes of length 64 + 2 ('0x') = 66.
Hack
- Analyse storage layout: |slot|variable| |--|--| |0|bool (1 bit long)| |1|ID (256 bits long)| |2|awkwardness (16 bytes) - denomination (8 bytes) - flattening (8 bytes)| |3|data0| |4|data1| |5|data2|
The _key
variable is slot 5.
- Take the first 16 bytes of the get: take the first 2 ('0x') + 2 * 16 = 34 characters of the bytestring.
Takeaways
- Same as for Level 8 -Vault:
- All storage is publicly visible, even
private
variables - Don't store passwords or secret data on chain without hashing them first
- All storage is publicly visible, even
- Storage optimization
- Use
memory
instead of storage if persisting data in state is not necessary - Order variables in such way that slots occupdation is maximized.
- Use