From e3706da11d3e6466a74d8781e5cdf8f1180ceb20 Mon Sep 17 00:00:00 2001 From: m4sh-wacker Date: Thu, 22 Oct 2026 01:58:01 -0500 Subject: [PATCH] fix: stop the Search view answering for a half-typed query Opening the Search view started the deeper pass, and the debounce in useAnalysis started a second one 220 ms later because it re-ran on view changes. That second pass read the search box mid-typing, so typing "flag{" a key at a time could leave the report answering for "f". And findFlags took "f" as a declared flag format and found it in the middle of the hex input. - Only input and auto-run changes schedule a re-run (watchInput, now a plain store subscription so the timing is testable). - Opening the view starts a pass unless the running one is over the same input; runCtf drops a result whose input was replaced while it ran. - A declared format must look like a flag prefix (optional brace), and the fallback search only matches where a word starts. --- src/engine/ctf.test.ts | 31 ++++++++ src/engine/ctf/flags.ts | 19 ++++- src/hooks/useAnalysis.test.ts | 133 ++++++++++++++++++++++++++++++++++ src/hooks/useAnalysis.ts | 40 +++++++--- src/store/useStore.ts | 24 +++++- 5 files changed, 231 insertions(+), 16 deletions(-) create mode 100644 src/hooks/useAnalysis.test.ts diff --git a/src/engine/ctf.test.ts b/src/engine/ctf.test.ts index 42c6b59..ddfd14b 100644 --- a/src/engine/ctf.test.ts +++ b/src/engine/ctf.test.ts @@ -73,6 +73,22 @@ describe('flags', () => { expect(findFlags('if (x) { doSomething(); }', 0, '')).toHaveLength(0); expect(findFlags('function f() {}', 0, '')).toHaveLength(0); }); + + it('accepts a declared format typed with its opening brace', () => { + expect(findFlags('weirdname{value}', 0, '', 'weirdname{')[0]?.known).toBe(true); + }); + + /* + * The format is whatever is in the search box when the deeper pass runs, so + * it can be the first letter of "flag{". "f" is also a hex digit. + */ + it('does not take a lone letter for a declared format', () => { + expect(findFlags('0d3f36367a3e35343f767a23352f7a3c', 0, '', 'f')).toHaveLength(0); + }); + + it('looks for a declared format only where a word starts', () => { + expect(findFlags('0d3f36c0ffee7a3e35343f767a23', 0, '', 'fe')).toHaveLength(0); + }); }); describe('index of coincidence', () => { @@ -263,6 +279,21 @@ describe('CTF mode', () => { expect(report.hints).toHaveLength(0); expect(report.flags).toHaveLength(0); }); + + it('finds the same one flag whatever part of its format is in the box', async () => { + const plain = + 'Well done, you found it. The flag is flag{x0r_is_not_encryption} and the next stage is open.'; + const hex = Buffer.from(Buffer.from(plain, 'latin1').map((b) => b ^ 0x5a)).toString('hex'); + + for (const format of ['', 'f', 'fl', 'flag', 'flag{']) { + const report = await hints(hex, { format }); + expect(report.flags.map((f) => f.text), format).toEqual(['flag{x0r_is_not_encryption}']); + expect( + report.hints.filter((h) => h.kind === 'flag').map((h) => h.title), + format, + ).toEqual(['flag{x0r_is_not_encryption}']); + } + }); }); describe('how deep the CTF workspace looks', () => { diff --git a/src/engine/ctf/flags.ts b/src/engine/ctf/flags.ts index b9fc4f0..64fbe52 100644 --- a/src/engine/ctf/flags.ts +++ b/src/engine/ctf/flags.ts @@ -27,13 +27,25 @@ const KNOWN = new Set( const SHAPE = /\b([A-Za-z][A-Za-z0-9_-]{1,23})\{([^{}\r\n]{1,256})\}/g; +/* + * A declared format has to be a prefix the shape above could have matched, + * with or without its opening brace. + * + * The format is whatever is in the search box when the deeper pass runs, so it + * can be the first letter of a word still being typed. Nobody declares "f" as + * a flag format, but it is a hex digit, and searching for it found the middle + * of a hex input and reported that as a flag. + */ +const DECLARED = /^([A-Za-z][A-Za-z0-9_-]{1,23})\{?$/; + function literal(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } export function findFlags(text: string, depth: number, path: string, format = ''): FoundFlag[] { const found: FoundFlag[] = []; - const wanted = format.trim().toLowerCase(); + const declared = DECLARED.exec(format.trim()); + const wanted = declared?.[1]?.toLowerCase(); for (const match of text.matchAll(SHAPE)) { const prefix = match[1]!.toLowerCase(); @@ -45,8 +57,9 @@ export function findFlags(text: string, depth: number, path: string, format = '' }); } - if (wanted && !found.some((f) => f.text.toLowerCase().startsWith(wanted))) { - const loose = new RegExp(`${literal(format.trim())}[^\\s\\r\\n]{2,256}`, 'gi'); + if (declared && !found.some((f) => f.text.toLowerCase().startsWith(declared[0].toLowerCase()))) { + // Anchored the way the shape is: a prefix starts a word rather than sitting inside one. + const loose = new RegExp(`\\b${literal(declared[0])}[^\\s\\r\\n]{2,256}`, 'gi'); for (const match of text.matchAll(loose)) { found.push({ text: match[0], depth, path, known: true }); } diff --git a/src/hooks/useAnalysis.test.ts b/src/hooks/useAnalysis.test.ts new file mode 100644 index 0000000..dcb6b4b --- /dev/null +++ b/src/hooks/useAnalysis.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CtfReport } from '../engine'; + +vi.hoisted(() => { + // The store puts the saved theme on as it loads, and there is no DOM here. + Object.assign(globalThis, { document: { documentElement: { dataset: {} } } }); +}); + +vi.mock('../engine', async (importOriginal) => { + const engine = await importOriginal(); + return { ...engine, hints: vi.fn(engine.hints) }; +}); + +import { hints } from '../engine'; +import { useStore } from '../store/useStore'; +import { watchInput } from './useAnalysis'; + + +const PLAIN = + 'Well done, you found it. The flag is flag{x0r_is_not_encryption} and the next stage is open.'; +const HEX = Buffer.from(Buffer.from(PLAIN, 'latin1').map((b) => b ^ 0x5a)).toString('hex'); + +const passes = vi.mocked(hints); +let stop: () => void; + +function settled(): Promise { + return vi.waitFor(() => expect(useStore.getState().ctfRunning).toBe(false)); +} + +describe('when the deeper search pass runs', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + useStore.setState({ + input: '', + searchTerm: '', + view: 'workspace', + activity: 'operations', + ctf: null, + ctfRunning: false, + }); + stop = watchInput(); + passes.mockClear(); + }); + + afterEach(() => { + stop(); + vi.useRealTimers(); + }); + + /* + * What a screen recording caught: open the Search view, type "flag{" a key + * at a time, and sometimes the report answered for "f" instead — with the + * hex input itself listed as a second flag. + */ + it('starts no pass of its own while a query is typed after the view opens', async () => { + useStore.getState().setInput(HEX); + await vi.advanceTimersByTimeAsync(1000); + + useStore.getState().setActivity('search'); + await vi.advanceTimersByTimeAsync(160); + for (const typed of ['f', 'fl', 'fla', 'flag', 'flag{']) { + useStore.getState().setSearchTerm(typed); + await vi.advanceTimersByTimeAsync(90); + } + await settled(); + + expect(passes.mock.calls.map(([, options]) => options?.format)).toEqual(['']); + expect(useStore.getState().ctf?.flags.map((f) => f.text)).toEqual([ + 'flag{x0r_is_not_encryption}', + ]); + }); + + it('still runs a pass once the input settles while the view is open', async () => { + useStore.setState({ view: 'ctf', activity: 'search' }); + useStore.getState().setInput(HEX); + + await vi.advanceTimersByTimeAsync(219); + expect(passes).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(passes).toHaveBeenCalledTimes(1); + await settled(); + }); + + it('does not let a pass over an older input answer for the new one', async () => { + let release: (report: CtfReport) => void = () => {}; + passes.mockImplementationOnce(() => new Promise((resolve) => (release = resolve))); + + useStore.setState({ view: 'ctf', activity: 'search' }); + useStore.getState().setInput('aGVsbG8gd29ybGQ='); + await vi.advanceTimersByTimeAsync(220); + + useStore.getState().setActivity('operations'); + useStore.getState().setInput(HEX); + await vi.advanceTimersByTimeAsync(220); + useStore.getState().setActivity('search'); + await vi.advanceTimersByTimeAsync(220); + + release({ + flags: [{ text: 'flag{from_the_old_input}', depth: 0, path: '', known: true }], + hints: [], + layers: 1, + truncated: false, + durationMs: 0, + }); + await settled(); + + expect(useStore.getState().ctf?.flags.map((f) => f.text)).toEqual([ + 'flag{x0r_is_not_encryption}', + ]); + }); + + it('drops a pass that finishes after its input was replaced', async () => { + let release: (report: CtfReport) => void = () => {}; + passes.mockImplementationOnce(() => new Promise((resolve) => (release = resolve))); + + useStore.setState({ view: 'ctf', activity: 'search' }); + useStore.getState().setInput('aGVsbG8gd29ybGQ='); + await vi.advanceTimersByTimeAsync(220); + + useStore.getState().setActivity('operations'); + useStore.getState().setInput(HEX); + release({ + flags: [{ text: 'flag{from_the_old_input}', depth: 0, path: '', known: true }], + hints: [], + layers: 1, + truncated: false, + durationMs: 0, + }); + await settled(); + + expect(useStore.getState().ctf).toBeNull(); + }); +}); diff --git a/src/hooks/useAnalysis.ts b/src/hooks/useAnalysis.ts index 4b24abb..efb8503 100644 --- a/src/hooks/useAnalysis.ts +++ b/src/hooks/useAnalysis.ts @@ -3,23 +3,39 @@ import { useStore } from '../store/useStore'; const DEBOUNCE_MS = 220; -export function useAnalysis() { - const input = useStore((s) => s.input); - const autoBake = useStore((s) => s.autoBake); - const view = useStore((s) => s.view); - const analyse = useStore((s) => s.analyse); - const runRecipe = useStore((s) => s.runRecipe); - const runCtf = useStore((s) => s.runCtf); +/* + * Re-runs everything once the input stops changing. + * + * Switching views is not a change. It used to be, and opening the Search view + * then started a second deeper pass 220 ms after the one the view starts for + * itself — reading the search box at whatever point the typing had reached. A + * screen recording caught it answering for "f" while the box said "flag{". + * The view is read when the input changes, not when the timer fires, so opening + * the view inside those 220 ms does not bring the second pass back. + */ +export function watchInput(): () => void { + let timer: ReturnType | undefined; + + const unsubscribe = useStore.subscribe((state, previous) => { + if (state.input === previous.input && state.autoBake === previous.autoBake) return; - useEffect(() => { - if (input.trim().length === 0) return; + clearTimeout(timer); + if (state.input.trim().length === 0) return; - const timer = setTimeout(() => { + const { autoBake, view, analyse, runRecipe, runCtf } = state; + timer = setTimeout(() => { void analyse(); if (autoBake) void runRecipe(); if (view === 'ctf') void runCtf(); }, DEBOUNCE_MS); + }); - return () => clearTimeout(timer); - }, [input, autoBake, view, analyse, runRecipe, runCtf]); + return () => { + clearTimeout(timer); + unsubscribe(); + }; +} + +export function useAnalysis() { + useEffect(() => watchInput(), []); } diff --git a/src/store/useStore.ts b/src/store/useStore.ts index 0530ced..b38260e 100644 --- a/src/store/useStore.ts +++ b/src/store/useStore.ts @@ -219,6 +219,21 @@ const MAX_DECODE_DEPTH = MAX_AUTO_DEPTH; let analysisToken = 0; let ctfToken = 0; +/* + * What the deeper pass in flight was started on. Opening the Search view + * starts a pass of its own unless the running one is over the same input. + */ +let ctfStartedOn: { input: string; inputEncoding: string } | null = null; + +function ctfRunningOn(state: State): boolean { + return ( + state.ctfRunning && + ctfStartedOn !== null && + ctfStartedOn.input === state.input && + ctfStartedOn.inputEncoding === state.inputEncoding + ); +} + export const useStore = create((set, get) => ({ input: '', inputEncoding: read(ENCODING_KEY, 'UTF-8'), @@ -599,10 +614,17 @@ export const useStore = create((set, get) => ({ } const token = ++ctfToken; + ctfStartedOn = { input, inputEncoding }; set({ ctfRunning: true }); const report = await hints(encodeInput(input, inputEncoding), { format: searchTerm }); if (token !== ctfToken) return; + ctfStartedOn = null; + // Unwrapped from an input that has since been replaced, so it describes nothing on screen. + if (get().input !== input || get().inputEncoding !== inputEncoding) { + set({ ctfRunning: false }); + return; + } set({ ctf: report, ctfRunning: false }); }, @@ -691,7 +713,7 @@ export const useStore = create((set, get) => ({ set({ activity, view }); write('decodebox-activity', activity); - if (view === 'ctf' && !get().ctfRunning) void get().runCtf(); + if (view === 'ctf' && !ctfRunningOn(get())) void get().runCtf(); }, setSidebarOpen: (open) => {