Skip to content

[Security] keeper-register deployer signature does not bind registration parameters and is replayable #2505

Description

@Bayyan16
Branch / commit playground @ 58db91921e6d1aab70b2dc50d61deeb41113b1e9
Component app/app/api/playground/keeper-register/route.ts (auth primitive, lines 128–155, 307, 344)
Client counterpart app/hooks/useCreateMarket.ts (lines 467–471, 829–831)
Severity Low (defense-in-depth / signature hygiene) — rises to Medium under a threat model that includes signature exposure or a hostile front-end (see §6)
Class CWE-347 (Improper Verification of Cryptographic Signature — insufficient message coverage) + CWE-294 (Authentication Bypass by Capture-Replay)
Status Confirmed against live code; local PoC passes. Non-duplicate check against the issue tracker still pending (see §8). Devnet-only route.

1. Summary

keeper-register is one of two authenticated market-registration paths in the app. The
newer H1v2 "stateless deployer proof" replaced an earlier nonce+challenge scheme to
avoid a serverless race. In doing so it also dropped payload binding: the signature the
route verifies covers only the string

keeper-register:<slabAddress>:<unix-minute>

It does not cover any of the parameters the request actually acts on —
dexPoolAddress, mainnetCA, dexType, symbol, label, or the full payload object.
The verification also succeeds for any of seven consecutive minute-buckets and is
stateless / not single-use, so a given signature is a replayable bearer token for a
~6-minute wall-clock window.

The sibling path POST /api/markets already does this correctly: it binds a
domain-separated message over the complete canonical payload plus a server-issued,
single-use nonce (app/lib/market-registration-auth.tsbuildMarketRegistrationMessage).
keeper-register simply does not apply the same discipline.

dexPoolAddress is security-critical: it is the mainnet DEX pool the oracle keeper reads a
market's AUTH_MARK settlement price from. Because the registry entry is overwritten
by slabAddress (upsertRegisteredMarket, lib/playground-registered-markets.ts:318),
a caller who can present one valid admin signature can repoint a market's price source
(and rewrite its off-chain metadata row) with tampered parameters, without re-signing.

This is not an authentication bypass — the caller must still present a valid signature
from the slab's on-chain admin, and the route re-checks admin == deployer on-chain
(route.ts:344). The weakness is that the signature does not commit to what it authorizes,
and stays valid long enough to be captured and replayed with different parameters.


2. Affected code (verbatim)

app/app/api/playground/keeper-register/route.ts:

// lines 130–132
const STATELESS_PROOF_PREFIX = "keeper-register";
const STATELESS_PROOF_WINDOW_BACK_MIN = 5;
const STATELESS_PROOF_WINDOW_FWD_MIN = 1;

// lines 134–136
function statelessProofMessage(slabAddress: string, unixMinute: number): Uint8Array {
  return new TextEncoder().encode(`${STATELESS_PROOF_PREFIX}:${slabAddress}:${unixMinute}`);
}

// lines 142–155
function verifyStatelessDeployerProof(
  slabAddress: string,
  deployerPubkeyBytes: Uint8Array,
  signatureBytes: Uint8Array,
): boolean {
  const nowMinute = Math.floor(Date.now() / 60_000);
  for (let d = -STATELESS_PROOF_WINDOW_BACK_MIN; d <= STATELESS_PROOF_WINDOW_FWD_MIN; d++) {
    const msg = statelessProofMessage(slabAddress, nowMinute + d);
    try {
      if (nacl.sign.detached.verify(msg, signatureBytes, deployerPubkeyBytes)) return true;
    } catch { /* try next candidate */ }
  }
  return false;
}

The only cryptographic gate on the write path is verifyStatelessDeployerProof at
route.ts:307. Its inputs are (slabAddress, deployerPubkeyBytes, signatureBytes) — the
request body's dexPoolAddress / payload are read afterwards (route.ts:364, 529) and
never enter the verified message.

Client (proves this is the live signing input):

// app/hooks/useCreateMarket.ts:467–471 and :829–831
const unixMinute = Math.floor(Date.now() / 60_000);
const proofMsg = new TextEncoder().encode(`keeper-register:${slabPk.toBase58()}:${unixMinute}`);
const sig = await wallet.signMessage(proofMsg);

3. Root cause

Two independent gaps in the same primitive:

  1. No message→parameter binding. The signed message is a function of
    (slabAddress, minute) only. The registration's effect is a function of
    (dexPoolAddress, mainnetCA, dexType, payload). Nothing ties the two together, so any
    valid (deployer, signature) authorizes arbitrary values for those fields.

  2. Wide replay window, no single-use. A ±-window of [-5, +1] minute candidates plus
    a stateless design (no nonce consumed, nothing stored) means the identical signature is
    accepted repeatedly for ~6 minutes. The /api/markets path avoids this with a
    server-issued single-use nonce; keeper-register deliberately removed the nonce and did
    not replace its anti-replay property.

The header comment frames statelessness purely as a fix for a lambda-instance race. That is
correct, but statelessness is orthogonal to binding — the route could be stateless and
bind the payload (e.g. include a canonical payload hash in the signed message). The trade
that was actually made silently gave up parameter binding.


4. Impact

Given one valid in-window admin signature, an attacker can, without any re-signing:

  • Repoint the market's price feed. Overwrite the blob registry entry for the slab
    (upsertRegisteredMarket overwrites by slabAddress) so poolAddress/dexType point at
    an attacker-influenced mainnet pool. The keeper polls this blob
    (GET /api/playground/registered-markets) and publishes AUTH_MARK from it — i.e. it
    controls the price the devnet market settles against. On the playground this lets an
    attacker mis-settle open positions (test funds) on that market.
  • Corrupt the market's off-chain metadata row. oracle_authority, initial_price_e6,
    lp_collateral, max_leverage, trading_fee_bps, symbol, name, logo_url are taken
    from the unbound payload and written via upsertRegisteredMarketRow, diverging the DB
    from on-chain reality and misleading any off-chain tooling that trusts these columns.
  • Amplification / griefing. Because the proof is not single-use, each capture is
    replayable for the whole window, re-triggering the mainnet-RPC classification + two store
    writes per replay.

Honest constraints (do not over-state)

  • Devnet only. The route returns 403 unless NEXT_PUBLIC_DEFAULT_NETWORK === "devnet".
    Blast radius is the devnet playground, not mainnet, and no mainnet funds are at risk.
  • Not an auth bypass. The signer must be the slab's on-chain admin (route.ts:344). The
    attacker cannot forge the signature; they must capture or coerce one.
  • The repointed pool must be real & supported. classifyPoolByOwner rejects
    missing / unsupported pools and raydium-clmm. The attacker must supply a genuine
    Meteora-DLMM or PumpSwap mainnet pool they can move the price of — feasible with a
    low-liquidity pool, but it is a real cost, not free.

5. Attack scenarios (pre-conditions made explicit)

S1 — Signature capture + replay. The proof travels in the POST body. Any exposure —
request logs, a saved HAR, product analytics, a Sentry breadcrumb, a browser extension, a
compromised/instrumented client, or a network position without cert pinning — yields a
6-minute, replayable, unbound bearer token. The attacker resubmits it with
dexPoolAddress/payload of their choosing before the window closes.

S2 — Blind-signing / hostile front-end. The signed text
keeper-register:<slab>:<minute> is not human-meaningful: it does not name the pool or any
payload field. A user prompted to sign it (e.g. on a cloned playground, or by a
compromised script on the real one) cannot tell what they are authorizing. The attacker then
submits the registration with arbitrary parameters. (The signer must still be that slab's
admin, so S2 targets a specific admin.)


6. Severity rationale

Rated Low as it stands: devnet-scoped, requires an admin signature (capture or coercion),
and the repoint requires a real attacker-controlled pool. It is a genuine signature-hygiene
defect, not a direct fund-loss bug.

It becomes Medium if the maintainers' threat model for a public playground admits either
(a) signature exposure through any of the S1 channels, or (b) a hostile/compromised
front-end (S2) — both realistic for a browser-driven, publicly reachable signing flow whose
signed message is opaque. The clinching argument for treating this as more than cosmetic is
that the correct pattern already exists one file over and was simply not applied here.


7. Remediation

Bind the signature to the request and remove replayability. Reuse the existing discipline
from app/lib/market-registration-auth.ts.

Minimal fix — extend the signed message to cover the security-critical parameters and a
version/domain tag:

// message the client signs AND the server reconstructs
function statelessProofMessage(
  slabAddress: string,
  unixMinute: number,
  dexPoolAddress: string,
  payloadHash: string, // sha256 of canonicalized payload (encodeCanonicalJson)
): Uint8Array {
  return new TextEncoder().encode(
    `keeper-register:v2:${slabAddress}:${dexPoolAddress}:${payloadHash}:${unixMinute}`,
  );
}

The server recomputes payloadHash from the received payload (canonical JSON, same helper
/api/markets uses) and verifies over the reconstructed message. Now any tampering with
dexPoolAddress or payload invalidates the signature — exactly the property PoC-3
demonstrates the sibling route has.

Also recommended

  • Narrow the window to [-1, +1] (or tighter) — 6 minutes of skew tolerance is far more
    than wallet-approval latency needs.
  • Add replay protection. Even bound signatures should be single-use for the write. A
    small durable "seen (slab, minute, sigHash)" set (the same blob-store pattern already used
    for nonces) closes in-window replay without reintroducing the lambda race, because it is a
    post-verification idempotency guard, not a pre-issued challenge.
  • Prefer the /api/markets nonce+full-payload envelope outright if statelessness is not
    a hard requirement here.

8. Validation checklist (methodology)

  • Traced to live code — server primitive (route.ts:128–155, 307, 344) and client
    signer (useCreateMarket.ts:467–471, 829–831) both quoted from
    playground@58db919.
  • PoC-verified locallypoc.ts reproduces the primitive verbatim, imports the
    real sibling envelope, and asserts all three properties (replay window = 7 buckets;
    one signature validates two different pool/payload bodies; sibling rejects the same
    tamper). All assertions pass.
  • Impact chain confirmed in code — overwrite-by-slab in the blob
    (playground-registered-markets.ts:318) and DB row write
    (upsertRegisteredMarketRow) both consume the unbound fields.
  • Severity not inflated — devnet-only, signature required, pool constraint all
    stated; rated Low with an explicit Medium condition.
  • Non-duplicate vs issue trackerpending. Confirm no existing issue/PR already
    tracks payload-binding/replay on keeper-register before filing. The route header
    documents the H1v1→H1v2 nonce-race history but does not mention parameter binding
    or replay, and the sibling route binds the payload, which suggests this specific gap is
    untracked — but verify against open/closed issues and recent PRs.

9. Secondary observation (informational, not filed as a finding)

In POST /api/markets, the on-chain oracle_authority cross-check is intentionally skipped
for oracle_mode === "admin" (route.ts ~1650), so an admin-mode creator can write an
arbitrary oracle_authority into their own market's DB row. This is self-scoped (only the
legitimate admin, only their own market) and is a metadata-divergence nuisance, not a
privilege issue — noted here only for completeness.


10. Reproduction — standalone PoC

The following PoC consolidates the original runnable test into this report. It reproduces
the keeper-register stateless proof primitive, demonstrates the replay window and
parameter non-binding, and contrasts the behavior with the payload-bound
POST /api/markets authorization envelope.

Prerequisites

npm install tweetnacl@1.0.3 @solana/web3.js@1.98.0 tsx@4.19.2

Place the PoC and helper below in the same directory as:

  • poc.ts
  • real-market-registration-auth.ts

Then run:

npx tsx poc.ts

Exit code 0 means all PoC assertions passed.

poc.ts

/**
 * PoC — Percolator playground POST /api/playground/keeper-register
 * Finding: the H1v2 "stateless deployer proof" signature authorizes a
 * registration WITHOUT binding any of the registration parameters
 * (dexPoolAddress, payload, ...) and is replayable for a ~6-minute window.
 *
 * This file:
 *   (A) reproduces the server's auth primitive VERBATIM from
 *       app/app/api/playground/keeper-register/route.ts (lines 128-155), and
 *   (B) imports the REAL sibling envelope buildMarketRegistrationMessage from
 *       app/lib/market-registration-auth.ts (the correctly-bound /api/markets path)
 *
 * to prove, side by side, that keeper-register omits parameter binding that the
 * sibling route already implements.
 *
 * Run:  npx tsx poc.ts
 */
import nacl from "tweetnacl";
import { Keypair, PublicKey } from "@solana/web3.js";
import { buildMarketRegistrationMessage } from "./real-market-registration-auth";

// ─────────────────────────────────────────────────────────────────────────────
// (A) VERBATIM copy of the vulnerable auth primitive from keeper-register/route.ts
//     (lines 128-155 — only the strings/loop bounds matter, copied exactly)
// ─────────────────────────────────────────────────────────────────────────────
const STATELESS_PROOF_PREFIX = "keeper-register";
const STATELESS_PROOF_WINDOW_BACK_MIN = 5;
const STATELESS_PROOF_WINDOW_FWD_MIN = 1;

function statelessProofMessage(slabAddress: string, unixMinute: number): Uint8Array {
  return new TextEncoder().encode(`${STATELESS_PROOF_PREFIX}:${slabAddress}:${unixMinute}`);
}

// Verbatim, but parameterised on `nowMinute` so we can simulate the server clock
// advancing (the real code uses Math.floor(Date.now()/60_000)).
function verifyStatelessDeployerProof(
  slabAddress: string,
  deployerPubkeyBytes: Uint8Array,
  signatureBytes: Uint8Array,
  nowMinute: number, // == Math.floor(Date.now()/60_000) in the real route
): boolean {
  for (let d = -STATELESS_PROOF_WINDOW_BACK_MIN; d <= STATELESS_PROOF_WINDOW_FWD_MIN; d++) {
    const msg = statelessProofMessage(slabAddress, nowMinute + d);
    try {
      if (nacl.sign.detached.verify(msg, signatureBytes, deployerPubkeyBytes)) return true;
    } catch {
      /* try next candidate */
    }
  }
  return false;
}

// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
const line = () => console.log("─".repeat(78));
const ok = (b: boolean) => (b ? "PASS ✓" : "FAIL ✗");

// A legitimate market admin (in a real run this is the on-chain marketauth of the slab)
const admin = Keypair.generate();
const adminPk = admin.publicKey.toBase58();
const slab = Keypair.generate().publicKey.toBase58(); // the victim market slab

// The client signs EXACTLY this (app/hooks/useCreateMarket.ts:467-471, :829-831):
//   `keeper-register:${slabAddress}:${Math.floor(Date.now()/60000)}`
const signMinute = Math.floor(Date.now() / 60_000);
const signedMsg = statelessProofMessage(slab, signMinute);
const signature = nacl.sign.detached(signedMsg, admin.secretKey);
const deployerBytes = new PublicKey(adminPk).toBytes();

console.log("\nSETUP");
line();
console.log("admin/deployer :", adminPk);
console.log("slab (market)  :", slab);
console.log("signed message :", `keeper-register:${slab}:${signMinute}`);
console.log("signature (b64):", Buffer.from(signature).toString("base64").slice(0, 32) + "…");

// ─────────────────────────────────────────────────────────────────────────────
// PoC-1 — REPLAY WINDOW: one signature stays valid as the server clock advances
// ─────────────────────────────────────────────────────────────────────────────
console.log("\nPoC-1  Replay window (a single signature is a ~6-min bearer token)");
line();
const acceptedMinutes: number[] = [];
for (let serverMinute = signMinute - 2; serverMinute <= signMinute + 8; serverMinute++) {
  const accepted = verifyStatelessDeployerProof(slab, deployerBytes, signature, serverMinute);
  if (accepted) acceptedMinutes.push(serverMinute);
  console.log(
    `  server clock = signMinute${serverMinute - signMinute >= 0 ? "+" : ""}${serverMinute - signMinute} → accepted: ${accepted}`,
  );
}
const windowMinutes = acceptedMinutes.length;
console.log(`  ⇒ same signature accepted across ${windowMinutes} distinct minute-buckets`);
console.log(`  ${ok(windowMinutes >= 6)}  expected a multi-minute replay window (no single-use / no nonce)`);

// ─────────────────────────────────────────────────────────────────────────────
// PoC-2 — PARAMETER NON-BINDING: the same sig authorizes two different pools
// ─────────────────────────────────────────────────────────────────────────────
console.log("\nPoC-2  Parameter non-binding (sig authorizes ANY dexPoolAddress/payload)");
line();

// The registration body the honest client intended:
const honestBody = {
  slabAddress: slab,
  deployer: adminPk,
  signature: Buffer.from(signature).toString("base64"),
  dexPoolAddress: "HonestPoo1111111111111111111111111111111111", // the real SOL pool
  mainnetCA: "So11111111111111111111111111111111111111112",
  dexType: "meteora",
  payload: { symbol: "SOL", max_leverage: 10, trading_fee_bps: 10 },
};

// The attacker replays the SAME deployer+signature but swaps the security-critical
// fields. Nothing in these fields was ever signed.
const tamperedBody = {
  ...honestBody,
  dexPoolAddress: "EvilPoo1AttackerControlled11111111111111111", // attacker's pool
  mainnetCA: "Evi1CA1111111111111111111111111111111111111",
  payload: { symbol: "SOL", max_leverage: 100, trading_fee_bps: 9999, oracle_authority: "EvilAuth111111111111111111111111111111111" },
};

// The server's ONLY cryptographic gate is verifyStatelessDeployerProof over the
// (slab, minute) message — which is identical for both bodies.
const serverMinute = signMinute; // request arrives in-window
const honestAccepted = verifyStatelessDeployerProof(
  honestBody.slabAddress,
  new PublicKey(honestBody.deployer).toBytes(),
  Buffer.from(honestBody.signature, "base64"),
  serverMinute,
);
const tamperedAccepted = verifyStatelessDeployerProof(
  tamperedBody.slabAddress,
  new PublicKey(tamperedBody.deployer).toBytes(),
  Buffer.from(tamperedBody.signature, "base64"),
  serverMinute,
);

console.log("  honest   dexPoolAddress:", honestBody.dexPoolAddress);
console.log("  tampered dexPoolAddress:", tamperedBody.dexPoolAddress, "  ← attacker-chosen");
console.log("  honest   payload       :", JSON.stringify(honestBody.payload));
console.log("  tampered payload       :", JSON.stringify(tamperedBody.payload), " ← attacker-chosen");
console.log();
console.log("  auth gate accepts honest body   :", honestAccepted);
console.log("  auth gate accepts tampered body :", tamperedAccepted);
console.log(
  `  ${ok(honestAccepted && tamperedAccepted)}  the SAME signature validates BOTH bodies — dexPoolAddress/payload unbound`,
);
console.log(
  "  ⇒ downstream, keeper-register overwrites the slab's registry entry (route.ts:529,",
);
console.log(
  "     upsertRegisteredMarket findIndex-by-slabAddress) → the keeper prices this market",
);
console.log("     off the attacker's pool. No re-signing, no fresh nonce, no admin re-consent.");

// ─────────────────────────────────────────────────────────────────────────────
// PoC-3 — CONTRAST: the sibling /api/markets binds the FULL payload (the fix pattern)
// ─────────────────────────────────────────────────────────────────────────────
console.log("\nPoC-3  Contrast: sibling POST /api/markets binds the full payload (already correct)");
line();
const nonce = "b3b1c0de-0000-4000-8000-000000000000"; // server-issued single-use nonce
const marketsPayloadHonest = {
  slab_address: slab,
  deployer: adminPk,
  mint_address: "So11111111111111111111111111111111111111112",
  dex_pool_address: honestBody.dexPoolAddress,
  symbol: "SOL",
};
const marketsMsgHonest = buildMarketRegistrationMessage({
  nonce,
  deployer: adminPk,
  payload: marketsPayloadHonest,
});
const marketsSig = nacl.sign.detached(marketsMsgHonest, admin.secretKey);

// Attacker flips dex_pool_address in the /api/markets body and replays the same sig:
const marketsPayloadTampered = { ...marketsPayloadHonest, dex_pool_address: tamperedBody.dexPoolAddress };
const marketsMsgTampered = buildMarketRegistrationMessage({
  nonce,
  deployer: adminPk,
  payload: marketsPayloadTampered,
});
const marketsHonestValid = nacl.sign.detached.verify(marketsMsgHonest, marketsSig, deployerBytes);
const marketsTamperedValid = nacl.sign.detached.verify(marketsMsgTampered, marketsSig, deployerBytes);

console.log("  /api/markets sig valid for honest   dex_pool_address:", marketsHonestValid);
console.log("  /api/markets sig valid for tampered dex_pool_address:", marketsTamperedValid, " ← rejected");
console.log(
  `  ${ok(marketsHonestValid && !marketsTamperedValid)}  flipping ANY field breaks the sig — this is the discipline keeper-register lacks`,
);

// ─────────────────────────────────────────────────────────────────────────────
console.log("\nSUMMARY");
line();
console.log(`PoC-1 replay window (min-buckets accepted) : ${windowMinutes}  ${ok(windowMinutes >= 6)}`);
console.log(`PoC-2 same sig authorizes attacker pool    : ${honestAccepted && tamperedAccepted}  ${ok(honestAccepted && tamperedAccepted)}`);
console.log(`PoC-3 sibling /api/markets rejects tamper   : ${!marketsTamperedValid}  ${ok(!marketsTamperedValid)}`);
line();
const allPass = windowMinutes >= 6 && honestAccepted && tamperedAccepted && marketsHonestValid && !marketsTamperedValid;
console.log(allPass ? "ALL POC ASSERTIONS PASSED ✓" : "SOME ASSERTIONS FAILED ✗");
process.exit(allPass ? 0 : 1);

11. Reference helper used by PoC

The PoC imports the repository's payload-bound authorization implementation used by
POST /api/markets for a side-by-side control. The source supplied with this report was
copied unmodified from app/lib/market-registration-auth.ts.

real-market-registration-auth.ts

/**
 * Payload-bound authorization for POST /api/markets.
 *
 * The browser client and server must build exactly the same
 * domain-separated message from the challenge nonce and complete
 * registration payload.
 */

export const MARKET_REGISTRATION_AUTH_DOMAIN =
  "percolator-launch:markets:create:v1";

export const MARKET_REGISTRATION_AUTH_METHOD =
  "POST";

export const MARKET_REGISTRATION_AUTH_PATH =
  "/api/markets";

export type MarketRegistrationPayload =
  Record<string, unknown>;

export interface MarketRegistrationAuthorization {
  nonce: string;
  deployer: string;
  payload: MarketRegistrationPayload;
}

const AUTHENTICATION_FIELDS = new Set([
  "nonce",
  "signature",
]);

/**
 * Deterministic JSON encoding.
 *
 * Object keys are sorted lexicographically. Undefined object
 * properties are omitted, while undefined array entries become null,
 * matching JSON.stringify behavior.
 */
function encodeCanonicalJson(
  value: unknown,
  arrayEntry = false,
): string | undefined {
  if (value === null) {
    return "null";
  }

  if (typeof value === "string") {
    return JSON.stringify(value);
  }

  if (typeof value === "boolean") {
    return value ? "true" : "false";
  }

  if (typeof value === "number") {
    return Number.isFinite(value)
      ? JSON.stringify(value)
      : "null";
  }

  if (
    value === undefined ||
    typeof value === "function" ||
    typeof value === "symbol"
  ) {
    return arrayEntry ? "null" : undefined;
  }

  if (typeof value === "bigint") {
    throw new TypeError(
      "BigInt is not supported in market registration payloads",
    );
  }

  if (Array.isArray(value)) {
    const entries = value.map(
      (entry) =>
        encodeCanonicalJson(entry, true) ?? "null",
    );

    return `[${entries.join(",")}]`;
  }

  if (typeof value === "object") {
    const prototype = Object.getPrototypeOf(value);

    if (
      prototype !== Object.prototype &&
      prototype !== null
    ) {
      throw new TypeError(
        "Market registration payload must contain plain JSON objects",
      );
    }

    const record =
      value as Record<string, unknown>;

    const entries: string[] = [];

    for (const key of Object.keys(record).sort()) {
      const encoded =
        encodeCanonicalJson(record[key]);

      if (encoded === undefined) {
        continue;
      }

      entries.push(
        `${JSON.stringify(key)}:${encoded}`,
      );
    }

    return `{${entries.join(",")}}`;
  }

  throw new TypeError(
    "Unsupported market registration payload value",
  );
}

/**
 * Remove authentication-envelope fields before canonicalization.
 *
 * The client signs the registration object before nonce/signature are
 * appended. The server receives them in the same JSON request, so only
 * these envelope fields are excluded.
 */
export function canonicalizeMarketRegistrationPayload(
  payload: MarketRegistrationPayload,
): string {
  if (
    payload === null ||
    typeof payload !== "object" ||
    Array.isArray(payload)
  ) {
    throw new TypeError(
      "Market registration payload must be an object",
    );
  }

  const registrationPayload:
    MarketRegistrationPayload = {};

  for (const [key, value] of Object.entries(
    payload,
  )) {
    if (AUTHENTICATION_FIELDS.has(key)) {
      continue;
    }

    registrationPayload[key] = value;
  }

  const canonical =
    encodeCanonicalJson(registrationPayload);

  if (canonical === undefined) {
    throw new TypeError(
      "Unable to canonicalize market registration payload",
    );
  }

  return canonical;
}

/**
 * Signing message:
 *
 * percolator-launch:markets:create:v1
 * POST
 * /api/markets
 * <nonce>
 * <deployer>
 * <canonical registration JSON>
 */
export function buildMarketRegistrationMessage(
  authorization: MarketRegistrationAuthorization,
): Uint8Array {
  const {
    nonce,
    deployer,
    payload,
  } = authorization;

  if (!nonce || !deployer) {
    throw new Error(
      "Market registration nonce and deployer are required",
    );
  }

  if (payload.deployer !== deployer) {
    throw new Error(
      "Market registration payload deployer does not match authorization deployer",
    );
  }

  const canonicalPayload =
    canonicalizeMarketRegistrationPayload(
      payload,
    );

  const message = [
    MARKET_REGISTRATION_AUTH_DOMAIN,
    MARKET_REGISTRATION_AUTH_METHOD,
    MARKET_REGISTRATION_AUTH_PATH,
    nonce,
    deployer,
    canonicalPayload,
  ].join("\n");

  /*
   * TextEncoder can return a Uint8Array associated with another
   * JavaScript realm in some test/browser environments. TweetNaCl
   * performs a strict instanceof Uint8Array check, so copy the encoded
   * bytes into the active runtime's Uint8Array constructor.
   */
  return Uint8Array.from(
    new TextEncoder().encode(message),
  );
}

12. Captured PoC output

SETUP
──────────────────────────────────────────────────────────────────────────────
admin/deployer : 9jL3p2UiN8mJPd5or5snfdyrVDFDMH6azUB8yA2Mc5pZ
slab (market)  : 7ZumntHycnTPX4wmL1UNp3T3tmrEAPeE16QMjdw7qkjy
signed message : keeper-register:7ZumntHycnTPX4wmL1UNp3T3tmrEAPeE16QMjdw7qkjy:29772341
signature (b64): nfSWgFSVR7yYRdPjrcahf9C6JCs0nOIn…

PoC-1  Replay window (a single signature is a ~6-min bearer token)
──────────────────────────────────────────────────────────────────────────────
  server clock = signMinute-2 → accepted: false
  server clock = signMinute-1 → accepted: true
  server clock = signMinute+0 → accepted: true
  server clock = signMinute+1 → accepted: true
  server clock = signMinute+2 → accepted: true
  server clock = signMinute+3 → accepted: true
  server clock = signMinute+4 → accepted: true
  server clock = signMinute+5 → accepted: true
  server clock = signMinute+6 → accepted: false
  server clock = signMinute+7 → accepted: false
  server clock = signMinute+8 → accepted: false
  ⇒ same signature accepted across 7 distinct minute-buckets
  PASS ✓  expected a multi-minute replay window (no single-use / no nonce)

PoC-2  Parameter non-binding (sig authorizes ANY dexPoolAddress/payload)
──────────────────────────────────────────────────────────────────────────────
  honest   dexPoolAddress: HonestPoo1111111111111111111111111111111111
  tampered dexPoolAddress: EvilPoo1AttackerControlled11111111111111111   ← attacker-chosen
  honest   payload       : {"symbol":"SOL","max_leverage":10,"trading_fee_bps":10}
  tampered payload       : {"symbol":"SOL","max_leverage":100,"trading_fee_bps":9999,"oracle_authority":"EvilAuth111111111111111111111111111111111"}  ← attacker-chosen

  auth gate accepts honest body   : true
  auth gate accepts tampered body : true
  PASS ✓  the SAME signature validates BOTH bodies — dexPoolAddress/payload unbound
  ⇒ downstream, keeper-register overwrites the slab's registry entry (route.ts:529,
     upsertRegisteredMarket findIndex-by-slabAddress) → the keeper prices this market
     off the attacker's pool. No re-signing, no fresh nonce, no admin re-consent.

PoC-3  Contrast: sibling POST /api/markets binds the full payload (already correct)
──────────────────────────────────────────────────────────────────────────────
  /api/markets sig valid for honest   dex_pool_address: true
  /api/markets sig valid for tampered dex_pool_address: false  ← rejected
  PASS ✓  flipping ANY field breaks the sig — this is the discipline keeper-register lacks

SUMMARY
──────────────────────────────────────────────────────────────────────────────
PoC-1 replay window (min-buckets accepted) : 7  PASS ✓
PoC-2 same sig authorizes attacker pool    : true  PASS ✓
PoC-3 sibling /api/markets rejects tamper   : true  PASS ✓
──────────────────────────────────────────────────────────────────────────────
ALL POC ASSERTIONS PASSED ✓

Result interpretation

The captured run demonstrates all three expected properties:

  1. The same keeper-register signature is accepted across 7 distinct minute-buckets.
  2. The same (deployer, signature) is accepted for both the intended request body and a
    body with attacker-chosen dexPoolAddress / payload values.
  3. The sibling POST /api/markets message rejects the equivalent payload tampering,
    demonstrating that payload binding is already implemented elsewhere in the repository.

The test exits successfully with:

ALL POC ASSERTIONS PASSED ✓

13. Suggested regression coverage

A fix should add regression tests that verify all of the following:

  • Changing dexPoolAddress after signing invalidates the proof.
  • Changing any security-relevant payload field after signing invalidates the proof.
  • The server and client construct byte-identical domain-separated messages.
  • A proof cannot be reused after its intended authorization has been consumed, if
    single-use replay protection is adopted.
  • Clock skew tolerance is explicitly bounded and tested.
  • Existing valid market-registration flows continue to succeed.
  • admin == deployer remains independently enforced on-chain.
  • Unsupported / missing DEX pools remain rejected by the existing classification logic.

14. Final assessment

This finding should be treated as a signature authorization-binding defect, not as a
signature forgery or direct authentication bypass.

At the referenced playground code snapshot, the deployer proves control of the slab
admin key, but the signed message does not commit to the registration parameters that the
route later consumes. Because the proof is also stateless and accepted across a multi-minute
window, one valid signature can be reused with different request parameters during that
window.

The practical blast radius remains constrained by the conditions already stated in this
report: the affected route is devnet-only, a valid slab-admin signature is still required,
and a repointed pool must pass the route's supported-pool checks. Those constraints are why
the report keeps the base severity at Low, with Medium only under the explicit
signature-exposure / hostile-front-end threat model described above.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions