|
|
| 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.ts → buildMarketRegistrationMessage).
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:
-
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.
-
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)
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:
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:
- The same
keeper-register signature is accepted across 7 distinct minute-buckets.
- The same
(deployer, signature) is accepted for both the intended request body and a
body with attacker-chosen dexPoolAddress / payload values.
- 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.
playground@58db91921e6d1aab70b2dc50d61deeb41113b1e9app/app/api/playground/keeper-register/route.ts(auth primitive, lines 128–155, 307, 344)app/hooks/useCreateMarket.ts(lines 467–471, 829–831)1. Summary
keeper-registeris one of two authenticated market-registration paths in the app. Thenewer 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
It does not cover any of the parameters the request actually acts on —
dexPoolAddress,mainnetCA,dexType,symbol,label, or the fullpayloadobject.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/marketsalready does this correctly: it binds adomain-separated message over the complete canonical payload plus a server-issued,
single-use nonce (
app/lib/market-registration-auth.ts→buildMarketRegistrationMessage).keeper-registersimply does not apply the same discipline.dexPoolAddressis security-critical: it is the mainnet DEX pool the oracle keeper reads amarket's
AUTH_MARKsettlement price from. Because the registry entry is overwrittenby
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 == deployeron-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:The only cryptographic gate on the write path is
verifyStatelessDeployerProofatroute.ts:307. Its inputs are
(slabAddress, deployerPubkeyBytes, signatureBytes)— therequest body's
dexPoolAddress/payloadare read afterwards (route.ts:364, 529) andnever enter the verified message.
Client (proves this is the live signing input):
3. Root cause
Two independent gaps in the same primitive:
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 anyvalid
(deployer, signature)authorizes arbitrary values for those fields.Wide replay window, no single-use. A
±-window of[-5, +1]minute candidates plusa stateless design (no nonce consumed, nothing stored) means the identical signature is
accepted repeatedly for ~6 minutes. The
/api/marketspath avoids this with aserver-issued single-use nonce;
keeper-registerdeliberately removed the nonce and didnot 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:
(
upsertRegisteredMarketoverwrites byslabAddress) sopoolAddress/dexTypepoint atan attacker-influenced mainnet pool. The keeper polls this blob
(
GET /api/playground/registered-markets) and publishesAUTH_MARKfrom it — i.e. itcontrols the price the devnet market settles against. On the playground this lets an
attacker mis-settle open positions (test funds) on that market.
oracle_authority,initial_price_e6,lp_collateral,max_leverage,trading_fee_bps,symbol,name,logo_urlare takenfrom the unbound
payloadand written viaupsertRegisteredMarketRow, diverging the DBfrom on-chain reality and misleading any off-chain tooling that trusts these columns.
replayable for the whole window, re-triggering the mainnet-RPC classification + two store
writes per replay.
Honest constraints (do not over-state)
403unlessNEXT_PUBLIC_DEFAULT_NETWORK === "devnet".Blast radius is the devnet playground, not mainnet, and no mainnet funds are at risk.
attacker cannot forge the signature; they must capture or coerce one.
classifyPoolByOwnerrejectsmissing/unsupportedpools andraydium-clmm. The attacker must supply a genuineMeteora-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/payloadof 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 anypayload 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:
The server recomputes
payloadHashfrom the receivedpayload(canonical JSON, same helper/api/marketsuses) and verifies over the reconstructed message. Now any tampering withdexPoolAddressorpayloadinvalidates the signature — exactly the property PoC-3demonstrates the sibling route has.
Also recommended
[-1, +1](or tighter) — 6 minutes of skew tolerance is far morethan wallet-approval latency needs.
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.
/api/marketsnonce+full-payload envelope outright if statelessness is nota hard requirement here.
8. Validation checklist (methodology)
signer (useCreateMarket.ts:467–471, 829–831) both quoted from
playground@58db919.poc.tsreproduces the primitive verbatim, imports thereal 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.
(
playground-registered-markets.ts:318) and DB row write(
upsertRegisteredMarketRow) both consume the unbound fields.stated; rated Low with an explicit Medium condition.
tracks payload-binding/replay on
keeper-registerbefore filing. The route headerdocuments 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-chainoracle_authoritycross-check is intentionally skippedfor
oracle_mode === "admin"(route.ts ~1650), so an admin-mode creator can write anarbitrary
oracle_authorityinto their own market's DB row. This is self-scoped (only thelegitimate 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-registerstateless proof primitive, demonstrates the replay window andparameter non-binding, and contrasts the behavior with the payload-bound
POST /api/marketsauthorization envelope.Prerequisites
Place the PoC and helper below in the same directory as:
poc.tsreal-market-registration-auth.tsThen run:
Exit code
0means all PoC assertions passed.poc.ts11. Reference helper used by PoC
The PoC imports the repository's payload-bound authorization implementation used by
POST /api/marketsfor a side-by-side control. The source supplied with this report wascopied unmodified from
app/lib/market-registration-auth.ts.real-market-registration-auth.ts12. Captured PoC output
Result interpretation
The captured run demonstrates all three expected properties:
keeper-registersignature is accepted across 7 distinct minute-buckets.(deployer, signature)is accepted for both the intended request body and abody with attacker-chosen
dexPoolAddress/payloadvalues.POST /api/marketsmessage rejects the equivalent payload tampering,demonstrating that payload binding is already implemented elsewhere in the repository.
The test exits successfully with:
13. Suggested regression coverage
A fix should add regression tests that verify all of the following:
dexPoolAddressafter signing invalidates the proof.payloadfield after signing invalidates the proof.single-use replay protection is adopted.
admin == deployerremains independently enforced on-chain.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
playgroundcode snapshot, the deployer proves control of the slabadmin 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.