Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/engine/ctf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
19 changes: 16 additions & 3 deletions src/engine/ctf/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 });
}
Expand Down
133 changes: 133 additions & 0 deletions src/hooks/useAnalysis.test.ts
Original file line number Diff line number Diff line change
@@ -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 <html> as it loads, and there is no DOM here.
Object.assign(globalThis, { document: { documentElement: { dataset: {} } } });
});

vi.mock('../engine', async (importOriginal) => {
const engine = await importOriginal<typeof import('../engine')>();
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<void> {
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();
});
});
40 changes: 28 additions & 12 deletions src/hooks/useAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | 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(), []);
}
24 changes: 23 additions & 1 deletion src/store/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<State>((set, get) => ({
input: '',
inputEncoding: read(ENCODING_KEY, 'UTF-8'),
Expand Down Expand Up @@ -599,10 +614,17 @@ export const useStore = create<State>((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 });
},

Expand Down Expand Up @@ -691,7 +713,7 @@ export const useStore = create<State>((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) => {
Expand Down