Ethernaut: Levels 13 to 15
April 15, 2020•1,333 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:
Gatekeeper Two
Naught Coin
Level 13 - Gatekeeper One
Target: make it past the gatekeeper.
Contract
pragma solidity ^0.5.0;
import 'openzeppelin-solidity/contracts/math/SafeMath.sol';
contract GatekeeperOne {
using SafeMath for uint256;
address public entrant;
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
modifier gateTwo() {
require(gasleft().mod(8191) == 0);
_;
}
modifier gateThree(bytes8 _gateKey) {
require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
require(uint32(uint64(_gateKey)) == uint16(tx.origin), "GatekeeperOne: invalid gateThree part three");
_;
}
function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
}
Weakness
- Contract relies on
tx.origin
. - - Being able to read the public contract logic teaches how to pass gateTwo and gateThree.
Solidity Concepts: explicit conversions and masking
Explicity type conversions
Be careful, conversion of integers and bytes behave differently!
conversion to | uint | bytes |
---|---|---|
shorter type | left-truncate: uint8(273 = 0000 0001 0001 0001) = 00001 0001 = 17 |
right-truncate: bytes4(0x1111111122222222) = 0x11111111 |
larger type | left-padded with 0: uint16(17 = 0001 0001) = 0000 0000 0001 0001 = 17 |
right-padded with 0: bytes8(0x11111111) = 0x1111111100000000 |
Masking
Masking means using a particular sequence of bits to turn some bits of another sequence "on" or "off" via a bitwise operation.
For example to "mask off" part of a sequence, we perform an AND
bitwise operation with:
0
for the bits to mask1
for the bits to keep
10101010
AND 00001111
= 00001010
Hack
- Pass
gateOne
: deploy an attacker contract that will call the victim contract'senter
function to ensuremsg.sender != tx.origin
. This is similar to what we've accomplished for the Level 4 - Telephone - Pass
gateTwo
- Pass
gateThree
Note that we need to pass a 8 bytes long_gateKey
. It is then explicitly converted to a 64 bits long integer.- Part one
uint16(uint64(_gateKey))
: uint64 _gateKey is converted to a shorter type (uint16) so we keep the last 16 bits of _gateKey.uint32(uint64(_gateKey))
: uint64 _gateKey is converted to a shorter type (uint32) so we keep the last 32 bits of _gateKeyuint32(uint64(_gateKey)) == uint16(uint64(_gateKey))
: we convert uint16 to a larger type (uint32), so we pad the last 16 bits of gateKey with 16*0 on the left. This concatenation should equal the last 32 bits of gateKey.- Mask to apply on the last 32 bits of _gateKey:
0000 0000 0000 0000 1111 1111 1111 1111 = 0x0000FFFF
- Part two
uint32(uint64(_gateKey)
: last 32 bits of _gateKeyuint32(uint64(_gateKey)) != uint64(_gateKey)
: the last 32 bits of gateKey are converted to a larger type (uint64), so we pad them with 320 on the left. This concanetation (320-last32bitsofGateKey) should not equal _gateKey: so we need to keep the first bits of _gateKey- Mask to apply to keep the first 32 bits:
0xFFFFFFFF
- We then concatenate both masks:
0xFFFF FFFF 0000 FFFF
Requires keeping the first 32 bits, mask with 0xFFFFFFFF. Concatenated with the first part: mask = 0xFFFF FFFF 0000 FFFF - Part three:
uint32(uint64(_gateKey)) == uint16(tx.origin)
- we need to take _gatekey = tx.origin
- we then apply the mask on tx.origin to ensure part one and two are correct
- Part one
Takeaways
- Abstain from asserting gas consumption in your smart contracts, as different compiler settings will yield different results.
- Be careful about data corruption when converting data types into different sizes.
- Save gas by not storing unnecessary values.
- Save gas by using appropriate modifiers to get functions calls for free, i.e. external pure or external view function calls are free!
- Save gas by masking values (less operations), rather than typecasting
Level 14 - Gatekeeper Two
Target: make through the gatekeeper.
Contract
pragma solidity ^0.5.0;
contract GatekeeperTwo {
address public entrant;
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
modifier gateTwo() {
uint x;
assembly { x := extcodesize(caller) }
require(x == 0);
_;
}
modifier gateThree(bytes8 _gateKey) {
require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1);
_;
}
function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
}
Weakness
- gateOne relies on
tx.origin
. - Being able to reading the public contract logic teaches how to pass gateTwo and gateThree.
Solidity Concepts: inline assembly & contract creation/initialization
From the Ethereum yellow paper section 7.1 - subtleties we learn:
while the initialisation code is executing, the newly created address exists but with no intrinsic body code⁴.
4.During initialization code execution, EXTCODESIZE on the address should return zero [...]
Hack
- gateOne: similar to the gateOne of Level 13 - Gatekeeper One or to the hack of Level 4 - Telephone
- gateTwo: call the
enter
function during contract initialization, i.e from withinconstructor
to ensureEXTCODESIZE = 0
- gateThree
uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey)
noteda ^ b
meansa XOR b
uint64(0) - 1
: underflow, this is equals touint64(1)
So we need to take_gatekey = ~a
(Bitwise NOT) to ensure that the XOR product of each bit ofa
andb
will be 1.
Takeaways
During contract initialization, the contract has no intrinsic body code and its extcodesize
is 0.
Level 15 - Naughtcoin
Target: transfer your tokens to another address.
Contract
pragma solidity ^0.5.0;
import 'openzeppelin-solidity/contracts/token/ERC20/ERC20Detailed.sol';
import 'openzeppelin-solidity/contracts/token/ERC20/ERC20.sol';
contract NaughtCoin is ERC20, ERC20Detailed {
// string public constant name = 'NaughtCoin';
// string public constant symbol = '0x0';
// uint public constant decimals = 18;
uint public timeLock = now + 10 * 365 days;
uint256 public INITIAL_SUPPLY;
address public player;
constructor(address _player)
ERC20Detailed('NaughtCoin', '0x0', 18)
ERC20()
public {
player = _player;
INITIAL_SUPPLY = 1000000 * (10**uint256(decimals()));
// _totalSupply = INITIAL_SUPPLY;
// _balances[player] = INITIAL_SUPPLY;
_mint(player, INITIAL_SUPPLY);
emit Transfer(address(0), player, INITIAL_SUPPLY);
}
function transfer(address _to, uint256 _value) lockTokens public returns(bool) {
super.transfer(_to, _value);
}
// Prevent the initial owner from transferring tokens until the timelock has passed
modifier lockTokens() {
if (msg.sender == player) {
require(now > timeLock);
_;
} else {
_;
}
}
}
Weakness
NaughCoin
inherits from the ERC20 contract.
Looking at this contract, we notice that transfer()
is not the only function to transfer tokens.
Indeed transferFrom(address sender, address recipient, uint256 amount)
can be used instead: provided that a 3rd user (spender
) was allowed beforehand by the owner
of the tokens to spend a given amount
of the total owner
's balance, spender
can transfer amount
to recipient
in the name of owner
.
Successfully executing transferFrom
requires the caller to have allowance for sender
's tokens of at least amount
. The allowance can be set with the approve
or increaseAllowance
functions inherited from ERC20.
Concepts: ERC20 token contract
The ERC20 token contract is related to the EIP 20 - ERC20 token standard. It is the most widespread token standard for fungible assets.
Any one token is exactly equal to any other token; no tokens have special rights or behavior associated with them. This makes ERC20 tokens useful for things like a medium of exchange currency, voting rights, staking, and more.
Hack
Architecture
transferFrom
calls _transfer
and _approve
. _approve
calls allowance
and checks whether the caller was allowed to spend the amount
by sender
.
Workflow
We want to set the player's allowance for the attack contract. For this we need to callapprove()
which calls _approve(msg.sender, spender, amount)
. In this call we need msg.sender == player
, so we can't call victim.approve()
from the attacker contract. If we would, then msg.sender == attackerContractAddress
. This would set the attack contract's allowance instead of the player's one.
Finally we let the attacker call transferFrom()
to transfer to itself the player's tokens.
Security Takeaways
Get familiar with contracts you didn't write, especially with imported and inherited contracts. Check how they implement authorization controls.