feat: rveng M3 web app + M3.5 static-analysis engine depth

M3 React web app over the existing FastAPI: hex viewer, disassembly pane, section map, strings, challenge runner with reveal-after-solve, and progress. Scaffolded from the open no-auth react-scss template; components kept self-contained for CertGames extraction.

M3.5 static-analysis depth, all still no-execution and KAT-traced to the gate binary: PLT/GOT import resolution (plt.py), RIP-relative cross-references and callers (xref.py), basic-block control-flow graphs (cfg.py), and prologue-scan function discovery for stripped binaries (discover.py). Adds the 06-stripped-gate challenge. The API now resolves call names, exposes rip targets, and serves /cfg, /xrefs, and discovered functions.

M2 API hardening from a read-only audit: challenge-loader error isolation, a request body-size middleware, session length bounds, CORS, and the disasm gate annotation gated behind solved state so it no longer pre-solves the challenge.

96 backend tests green; frontend typecheck, build, and lint clean.
This commit is contained in:
CarterPerez-dev 2026-07-05 15:36:46 -04:00
parent 44f1b5bc99
commit 01dca71765
94 changed files with 7286 additions and 46 deletions

View File

@ -0,0 +1,7 @@
{
"id": "06-stripped-gate",
"module": "disassembly",
"title": "Gate in the dark",
"mission": "This binary is stripped: no symbol table, so there are no function names to look up. Discover the functions by their prologue, disassemble the one that checks a number, and read the constant it compares against. Answer in decimal or hex.",
"answer": { "category": "found_value", "expected": 1337 }
}

View File

@ -0,0 +1,23 @@
#include <stdio.h>
#include <stdlib.h>
int check(int n) {
if (n == 1337) {
return 1;
}
return 0;
}
int main(int argc, char **argv) {
char *secret = "the_flag_is_here";
int n = 0;
if (argc > 1) {
n = atoi(argv[1]);
}
if (check(n)) {
printf("unlocked: %s\n", secret);
} else {
printf("wrong number\n");
}
return 0;
}

Binary file not shown.

View File

@ -0,0 +1,191 @@
<!-- ©AngelaMos | 2026 -->
<!-- 05-static-analysis.md -->
# Static Analysis Depth Research
The M3.5 depth features that lift rveng from a hex-and-disasm viewer to a real
static reverse-engineering workbench: import resolution through the PLT, cross
references with RIP-relative operand math, control-flow-graph reconstruction,
and function discovery in stripped binaries. Every offset, opcode, and address
below is traced to the sample `gate` binary (`gcc -no-pie -fno-stack-protector
-O0`) and its stripped copy `gate_stripped` (`strip gate`), captured with
`readelf` and `objdump -M intel`. Nothing here executes a binary: PLT, GOT, and
relocation tables are inspected as data, exactly like the header and sections.
## Import resolution through the PLT
A dynamically linked call does not jump straight to libc. It jumps to a small
stub in `.plt` that reads a pointer from `.got.plt`, which the dynamic linker
fills in. To name a call the way objdump prints `call 401030 <puts@plt>`, the
engine resolves the stub back to the imported symbol.
Sections that carry the mapping, from `readelf -SW gate`:
- `.plt` PROGBITS at `0x401020`, entry size `0x10` (16 bytes).
- `.got.plt` PROGBITS at `0x403fe8`, entry size 8.
- `.rela.plt` RELA at `0x400520`, entry size `0x18` (24 bytes), 3 entries.
- `.dynsym` DYNSYM at `0x4003d0`, entry size `0x18`, its linked string table is
`.dynstr`.
The `.plt` layout from `objdump -d -j .plt`:
```
401020 <puts@plt-0x10>: push/jmp/nop the PLT0 resolver stub (not an import)
401030 <puts@plt>: ff 25 ca 2f 00 00 jmp QWORD PTR [rip+0x2fca] -> 404000
401040 <printf@plt>: ff 25 c2 2f 00 00 jmp QWORD PTR [rip+0x2fc2] -> 404008
401050 <atoi@plt>: ff 25 ba 2f 00 00 jmp QWORD PTR [rip+0x2fba] -> 404010
```
The first `.plt` slot (`0x401020`) is the resolver trampoline (PLT0), not an
import. Real entries begin at `0x401030` and step by 16.
Resolution algorithm, robust across PIE and non-PIE:
1. For a PLT entry, decode its leading `jmp QWORD PTR [rip+disp32]` (opcode
`ff 25`). The GOT slot it reads is `entry_addr + 6 + disp32`. For `0x401030`:
`0x401036 + 0x2fca = 0x404000`.
2. `.rela.plt` maps each GOT slot to a dynamic symbol. Each `Elf64_Rela` is
`{ r_offset: u64, r_info: u64, r_addend: i64 }`. The symbol index is
`r_info >> 32`; the type is `r_info & 0xffffffff` (7 = `R_X86_64_JUMP_SLOT`).
From `readelf -rW`:
```
r_offset 0x404000 sym 2 JUMP_SLOT puts
r_offset 0x404008 sym 3 JUMP_SLOT printf
r_offset 0x404010 sym 5 JUMP_SLOT atoi
```
3. Look the symbol index up in `.dynsym` (same 24-byte `Elf64_Sym` layout as
`.symtab`, names via `.dynstr`) to get `puts`, `printf`, `atoi`.
So `0x401030 -> 0x404000 -> dynsym[2] -> puts`. A `call` whose target is a `.plt`
entry is then annotated with that name.
KAT (call-site to import, from `main` in `objdump -d`):
- `401199: call 401050` resolves to `atoi`.
- `4011a6: call 401146` is `check`, a local function, not a PLT entry.
- `4011c5: call 401040` resolves to `printf`.
- `4011d6: call 401030` resolves to `puts`.
VERIFIED, and load-bearing for the stripped module: `.plt`, `.got.plt`,
`.rela.plt`, and `.dynsym` all SURVIVE `strip`. `readelf -SW gate_stripped`
still shows them; `readelf -rW gate_stripped` still lists the three JUMP_SLOT
relocations to `puts`, `printf`, `atoi`. Stripping removes `.symtab` and
`.strtab` (local names like `check`, `main`), never the dynamic-linking tables.
A stripped binary can still name its library calls. This is a real teaching
point, not an accident.
Robustness note an adversarial check flagged: binaries built with
`-fcf-protection` (default on many modern toolchains) grow an `endbr64` and a
`.plt.sec` section, so the `jmp [rip+x]` may live in `.plt.sec` rather than
`.plt`. Resolving by decoding the stub's `jmp [rip+disp]` wherever it sits (and
matching the GOT slot against `.rela.plt`) handles both layouts; resolving by
the PLT entry's pushed relocation index does not. The engine decodes the jmp.
Our curated `-no-pie -O0` samples use the classic single `.plt`.
## RIP-relative cross references
x86-64 addresses data PC-relative. `lea rax,[rip+disp]` computes an absolute
address as `address_of_next_instruction + disp`, where the next-instruction
address is `insn.address + insn.size`. This is how a function points at a string
without a relocation in a non-PIE binary.
KAT (from `main`, `objdump -d -j .text`):
- `401173: lea rax,[rip+0xe8a]` with size 7 resolves to `0x40117a + 0xe8a =
0x402004`, the `.rodata` string `the_flag_is_here`.
- `4011b6: lea rax,[rip+0xe58]` (size 7) resolves to `0x4011bd + 0xe58 =
0x402015`, the `unlocked: %s` format string.
- `4011cc: lea rax,[rip+0xe50]` (size 7) resolves to `0x4011d3 + 0xe50 =
0x402023`, the `wrong number` string.
capstone exposes the displacement on the memory operand whose base register is
RIP. The engine computes `insn.address + insn.size + disp` and, when that
address falls inside a known section, labels the reference (a `.rodata` hit is a
string; a `.text` hit is code).
Cross references are the inverse index: for a target address, the list of
instructions that reach it. Two reference kinds cover these challenges:
- control-flow refs, from `call`/`jmp`/`jcc` branch targets. Example: `check` at
`0x401146` is referenced by `4011a6: call 401146` in `main`.
- data refs, from resolved RIP-relative operands. Example: the flag string at
`0x402004` is referenced by `401173: lea` in `main`.
"What calls this function" and "what reads this string" are the two questions a
learner asks constantly; both are this same reverse map.
## Control-flow graph reconstruction
A basic block is a straight run of instructions with one entry and one exit: no
branch lands in the middle, no branch leaves except at the end. The standard
leader algorithm:
1. The first instruction of the function is a leader.
2. Any branch target inside the function is a leader.
3. The instruction following any conditional or unconditional jump is a leader.
A block runs from a leader up to the instruction before the next leader. `call`
does NOT end a block: it returns, so control falls through. Blocks end at
conditional jumps, unconditional jumps, and `ret`. This matches how IDA and
Ghidra draw function graphs.
KAT: `check` (`0x401146`..`0x401163`), from `objdump -d`:
```
401146 push rbp | B0 (entry)
401147 mov rbp,rsp |
40114a mov [rbp-4],edi |
40114d cmp [rbp-4],0x539 |
401154 jne 40115d | -> ends B0
401156 mov eax,1 | B1 (fallthrough of jne)
40115b jmp 401162 | -> ends B1
40115d mov eax,0 | B2 (jne taken target)
401162 pop rbp | B3 (jmp target, also B2 fallthrough)
401163 ret | -> ends B3
```
Leaders: `0x401146` (start), `0x401156` (after the `jne`), `0x40115d` (the `jne`
target), `0x401162` (the `jmp` target). Four basic blocks. Edges:
- B0 ends in `jne`: to B2 (taken, `0x40115d`) and to B1 (fallthrough, `0x401156`).
- B1 ends in `jmp`: to B3 (`0x401162`).
- B2 ends by fallthrough (its successor `0x401162` is a leader): to B3.
- B3 ends in `ret`: no successors.
A clean diamond, which is exactly the shape that teaches "the gate splits the
flow and both sides rejoin". Edge kinds worth labeling for the learner: taken,
fallthrough, unconditional.
## Function discovery in stripped binaries
`strip` removes `.symtab`, so `check` and `main` have no names and the symbol
table is empty. Disassembly must start from something other than a symbol.
For the curated `-O0` challenges, every C function opens with the frame-pointer
prologue `push rbp; mov rbp,rsp`, bytes `55 48 89 e5`. Scanning executable
sections for that pattern recovers function entry points. Verified in
`gate_stripped`: `0x401146` (was `check`) and `0x401164` (was `main`) both begin
`55 48 89 e5`; `_start` at `0x401060` does not (it has a different prologue), so
the scan cleanly separates user functions from the runtime start-up code.
Honest limits an adversarial check must record: this heuristic only finds
functions that keep a frame pointer. Anything built with `-O2`
(frame-pointer-omitted), or hand-written assembly, will be missed; real tools
add call-target recovery, symbol hints, and unwind-table (`.eh_frame`) parsing.
For rveng's curated, frame-pointer-preserving samples the prologue scan is
sufficient and honest, and it is the right first lesson in "how do you even find
a function when the names are gone". The stripped challenge disassembles a
discovered region by address, never by name, and grades a found-value answer, so
no symbol is ever leaked.
## Facts pulled forward for the KATs
- PLT entries: `0x401030 -> puts`, `0x401040 -> printf`, `0x401050 -> atoi`;
GOT slots `0x404000/8/10`; `.rela.plt` symbol index is `r_info >> 32`.
- RIP-relative: `target = insn.address + insn.size + disp`; `0x401173 lea ->
0x402004` (the flag string).
- `check` CFG: 4 basic blocks, leaders `0x401146/0x401156/0x40115d/0x401162`,
diamond edges as above.
- Stripped discovery: prologue `55 48 89 e5` finds `check` and `main`, not
`_start`; `.dynsym`/`.rela.plt`/`.plt` survive `strip` so imports stay named.

View File

@ -23,6 +23,7 @@ binaries safe, and it is a hard constraint, not a preference.
| `02-x86-64-capstone.md` | implementing `engine/disasm.py`: the minimal x86-64 instruction set these challenges use, the cmp/jcc gate pattern, capstone 5.0.9 API, the objdump-vs-capstone byte-for-byte agreement KAT, the Intel-syntax constant correction |
| `03-pedagogy.md` | implementing `engine/challenge.py` and the runner: the solve-then-reveal loop, the three answer-spec categories (found-value, identified-symbol, patched-bytes), module-to-category mapping, grading rules |
| `04-no-execution-posture.md` | any API or engine work touching binaries: the enumeration proving every operation is read-only, why patch grading is a static diff, the API input constraints, the standing no-execution rule |
| `05-static-analysis.md` | implementing the M3.5 depth engine (`plt.py`, `xref.py`, `cfg.py`, stripped discovery): PLT/GOT/.rela.plt/.dynsym import resolution, RIP-relative operand math, the basic-block leader algorithm, prologue-scan function discovery, all KAT-traced to `gate` and `gate_stripped` |
Key verified facts pulled forward:
- ELF header cross-checks exactly to raw bytes: entry `0x401060`, e_shoff

View File

@ -0,0 +1,15 @@
node_modules
build
dist
.git
.gitignore
*.md
.env*
.vscode
.idea
*.log
npm-debug.log*
pnpm-debug.log*
.DS_Store
coverage
.nyc_output

View File

@ -0,0 +1,6 @@
# ©AngelaMos | 2026
# .env.example
# Backend the Vite dev server proxies /api to.
# Run the API with: uv run uvicorn rveng.api.server:app --port 8000
VITE_API_TARGET=http://localhost:8000

View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.vite
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,22 @@
# ©AngelaMos | 2025
# .stylelintignore
# Dependencies
node_modules/
# Production builds
dist/
build/
out/
# JS/TS files
**/*.js
**/*.jsx
**/*.ts
**/*.tsx
# Generated files
*.min.css
# Error system styles - ignore from linting
src/core/app/_toastStyles.scss

View File

@ -0,0 +1,94 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 82,
"lineEnding": "lf"
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "double",
"semicolons": "asNeeded",
"trailingCommas": "es5",
"arrowParentheses": "always"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noExcessiveCognitiveComplexity": {
"level": "error",
"options": { "maxAllowedComplexity": 25 }
},
"noForEach": "off",
"useLiteralKeys": "off"
},
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error",
"useExhaustiveDependencies": "warn",
"useHookAtTopLevel": "error",
"noUndeclaredVariables": "error"
},
"style": {
"useImportType": "error",
"useConst": "error",
"useTemplate": "error",
"useSelfClosingElements": "error",
"useFragmentSyntax": "error",
"noNonNullAssertion": "error",
"useConsistentArrayType": {
"level": "error",
"options": { "syntax": "shorthand" }
},
"useNamingConvention": "off"
},
"suspicious": {
"noExplicitAny": "error",
"noDebugger": "error",
"noConsole": "warn",
"noArrayIndexKey": "warn",
"noAssignInExpressions": "error",
"noDoubleEquals": "error",
"noRedeclare": "error",
"noVar": "error"
},
"security": {
"noDangerouslySetInnerHtml": "error"
},
"a11y": {
"useAltText": "error",
"useAnchorContent": "error",
"useKeyWithClickEvents": "error",
"noStaticElementInteractions": "error",
"useButtonType": "error",
"useValidAnchor": "error"
}
}
},
"overrides": [
{
"includes": ["src/main.tsx"],
"linter": {
"rules": {
"style": {
"noNonNullAssertion": "off"
}
}
}
}
]
}

View File

@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<!--
rveng - reverse-engineering learning platform
Author(s): © AngelaMos, CarterPerez-dev
-->
<meta charset="UTF-8" />
<link
rel="icon"
type="image/x-icon"
href="/assets/favicon.ico"
/>
<link
rel="apple-touch-icon"
type="image/png"
href="/assets/apple-touch-icon.png"
/>
<link
rel="manifest"
href="/assets/site.webmanifest"
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; form-action 'self'; worker-src 'self' blob:;"
/>
<title>rveng - reverse-engineering lab</title>
<meta
name="description"
content="Interactive reverse-engineering learning platform: read the binary, reach the answer, reveal the source."
/>
<meta
name="author"
content=" ©AngelaMos | 2026 | CarterPerez-dev"
/>
</head>
<body>
<div id="root"></div>
<script
type="module"
src="/src/main.tsx"
></script>
</body>
</html>

View File

@ -0,0 +1,50 @@
{
"name": "rveng-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"typecheck": "tsc -b",
"lint:scss": "stylelint '**/*.scss'",
"lint:scss:fix": "stylelint '**/*.scss' --fix"
},
"dependencies": {
"@tanstack/react-query": "^5.90.12",
"axios": "^1.13.0",
"react": "^19.2.1",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
"react-icon": "^1.0.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.1.1",
"sonner": "^2.0.7",
"zod": "^4.1.13",
"zustand": "^5.0.9"
},
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@tanstack/react-query-devtools": "^5.91.1",
"@types/node": "^24.10.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"sass": "^1.95.0",
"stylelint": "^16.26.1",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-standard-scss": "^16.0.0",
"typescript": "~5.9.3",
"vite": "npm:rolldown-vite@7.2.5",
"vite-tsconfig-paths": "^5.1.0"
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
}
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@ -0,0 +1,36 @@
// ===========================
// ©AngelaMos | 2026
// App.tsx
// ===========================
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { RouterProvider } from 'react-router-dom'
import { Toaster } from 'sonner'
import { queryClient } from '@/core/api'
import { router } from '@/core/app/routers'
import '@/core/app/toast.module.scss'
export default function App(): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>
<div className="app">
<RouterProvider router={router} />
<Toaster
position="top-right"
duration={2000}
theme="dark"
toastOptions={{
style: {
background: 'hsl(0, 0%, 12.2%)',
border: '1px solid hsl(0, 0%, 18%)',
color: 'hsl(0, 0%, 98%)',
},
}}
/>
</div>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}

View File

@ -0,0 +1,107 @@
// ===================
// © AngelaMos | 2026
// client.ts
// ===================
import { API_ENDPOINTS } from '@/config'
import { apiClient } from '@/core/api'
import type {
CfgView,
ChallengeDetail,
ChallengeSummary,
DisasmView,
ElfView,
HexView,
ProgressView,
StringsView,
SubmitRequest,
SubmitResult,
Target,
XrefsView,
} from './types'
export async function fetchChallenges(): Promise<ChallengeSummary[]> {
const { data } = await apiClient.get<ChallengeSummary[]>(
API_ENDPOINTS.CHALLENGES
)
return data
}
export async function fetchChallenge(cid: string): Promise<ChallengeDetail> {
const { data } = await apiClient.get<ChallengeDetail>(
API_ENDPOINTS.CHALLENGE(cid)
)
return data
}
export async function fetchHex(
cid: string,
offset: number,
length: number
): Promise<HexView> {
const { data } = await apiClient.get<HexView>(API_ENDPOINTS.HEX(cid), {
params: { offset, length },
})
return data
}
export async function fetchElf(cid: string): Promise<ElfView> {
const { data } = await apiClient.get<ElfView>(API_ENDPOINTS.ELF(cid))
return data
}
export async function fetchDisasm(
cid: string,
target: Target,
session: string
): Promise<DisasmView> {
const { data } = await apiClient.get<DisasmView>(API_ENDPOINTS.DISASM(cid), {
params: { symbol: target.symbol, address: target.address, session },
})
return data
}
export async function fetchCfg(cid: string, target: Target): Promise<CfgView> {
const { data } = await apiClient.get<CfgView>(API_ENDPOINTS.CFG(cid), {
params: { symbol: target.symbol, address: target.address },
})
return data
}
export async function fetchXrefs(
cid: string,
target: number
): Promise<XrefsView> {
const { data } = await apiClient.get<XrefsView>(API_ENDPOINTS.XREFS(cid), {
params: { target },
})
return data
}
export async function fetchStrings(
cid: string,
minLength: number
): Promise<StringsView> {
const { data } = await apiClient.get<StringsView>(API_ENDPOINTS.STRINGS(cid), {
params: { min_length: minLength },
})
return data
}
export async function fetchProgress(session: string): Promise<ProgressView> {
const { data } = await apiClient.get<ProgressView>(API_ENDPOINTS.PROGRESS, {
params: { session },
})
return data
}
export async function submitAnswer(
cid: string,
body: SubmitRequest
): Promise<SubmitResult> {
const { data } = await apiClient.post<SubmitResult>(
API_ENDPOINTS.SUBMIT(cid),
body
)
return data
}

View File

@ -0,0 +1,119 @@
// ===================
// © AngelaMos | 2026
// index.ts
// ===================
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { QUERY_KEYS } from '@/config'
import { QUERY_STRATEGIES } from '@/core/api'
import { getSessionId } from '@/lib/session'
import {
fetchCfg,
fetchChallenge,
fetchChallenges,
fetchDisasm,
fetchElf,
fetchHex,
fetchProgress,
fetchStrings,
fetchXrefs,
submitAnswer,
} from '../client'
import type { SubmitResult, Target } from '../types'
export function useChallenges() {
return useQuery({
queryKey: QUERY_KEYS.CHALLENGES,
queryFn: fetchChallenges,
...QUERY_STRATEGIES.static,
})
}
export function useChallenge(cid: string) {
return useQuery({
queryKey: QUERY_KEYS.CHALLENGE(cid),
queryFn: () => fetchChallenge(cid),
enabled: cid.length > 0,
...QUERY_STRATEGIES.static,
})
}
export function useHex(cid: string, offset: number, length: number) {
return useQuery({
queryKey: QUERY_KEYS.HEX(cid, offset, length),
queryFn: () => fetchHex(cid, offset, length),
enabled: cid.length > 0,
...QUERY_STRATEGIES.static,
})
}
export function useElf(cid: string) {
return useQuery({
queryKey: QUERY_KEYS.ELF(cid),
queryFn: () => fetchElf(cid),
enabled: cid.length > 0,
...QUERY_STRATEGIES.static,
})
}
export function useDisasm(cid: string, target: Target | null) {
const session = getSessionId()
return useQuery({
queryKey: QUERY_KEYS.DISASM(cid, target?.label ?? '', session),
queryFn: () => fetchDisasm(cid, target as Target, session),
enabled: cid.length > 0 && target !== null,
...QUERY_STRATEGIES.standard,
})
}
export function useCfg(cid: string, target: Target | null) {
return useQuery({
queryKey: QUERY_KEYS.CFG(cid, target?.label ?? ''),
queryFn: () => fetchCfg(cid, target as Target),
enabled: cid.length > 0 && target !== null,
...QUERY_STRATEGIES.static,
})
}
export function useXrefs(cid: string, target: number | null) {
return useQuery({
queryKey: QUERY_KEYS.XREFS(cid, target ?? -1),
queryFn: () => fetchXrefs(cid, target as number),
enabled: cid.length > 0 && target !== null,
...QUERY_STRATEGIES.static,
})
}
export function useStrings(cid: string, minLength: number) {
return useQuery({
queryKey: QUERY_KEYS.STRINGS(cid, minLength),
queryFn: () => fetchStrings(cid, minLength),
enabled: cid.length > 0,
...QUERY_STRATEGIES.static,
})
}
export function useProgress() {
const session = getSessionId()
return useQuery({
queryKey: QUERY_KEYS.PROGRESS(session),
queryFn: () => fetchProgress(session),
...QUERY_STRATEGIES.standard,
})
}
export function useSubmit(cid: string) {
const queryClient = useQueryClient()
const session = getSessionId()
return useMutation<SubmitResult, Error, string>({
mutationFn: (answer: string) => submitAnswer(cid, { answer, session }),
onSuccess: (result) => {
if (result.correct) {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.PROGRESS(session) })
queryClient.invalidateQueries({
queryKey: ['challenges', cid, 'disasm'],
})
}
},
})
}

View File

@ -0,0 +1,7 @@
// ===================
// © AngelaMos | 2025
// index.ts
// ===================
export * from './hooks'
export * from './types'

View File

@ -0,0 +1,136 @@
// ===================
// © AngelaMos | 2026
// index.ts
// ===================
import type { Category } from '@/config'
export interface ChallengeSummary {
id: string
module: string
title: string
}
export interface ChallengeDetail {
id: string
module: string
title: string
mission: string
category: Category
size: number
}
export interface HexView {
base: number
length: number
lines: string[]
}
export interface FunctionView {
name: string
value: number
size: number
}
export interface DiscoveredFunction {
address: number
label: string
}
export interface SectionView {
index: number
name: string
type: string
addr: number
offset: number
size: number
flags: string
}
export interface ElfView {
type: number
machine: number
entry: number
sections: SectionView[]
functions: FunctionView[]
discovered: DiscoveredFunction[]
}
export interface InstructionView {
address: number
mnemonic: string
op_str: string
bytes: string
immediate: number | null
branch_target: number | null
rip_target: number | null
call_name: string | null
is_gate: boolean
}
export interface DisasmView {
symbol: string
instructions: InstructionView[]
gate_address: number | null
}
export interface CfgBlock {
start: number
end: number
instructions: number[]
}
export interface CfgEdge {
src: number
dst: number
kind: string
}
export interface CfgView {
symbol: string
blocks: CfgBlock[]
edges: CfgEdge[]
}
export interface XrefView {
from_addr: number
to_addr: number
kind: string
}
export interface XrefsView {
target: number
references: XrefView[]
}
export interface Target {
label: string
address: number
symbol?: string
}
export interface StringView {
offset: number
text: string
}
export interface StringsView {
strings: StringView[]
}
export interface SubmitRequest {
answer: string
session: string
}
export interface SubmitResult {
correct: boolean
message: string
revealed_source: string | null
}
export interface ProgressView {
session: string
solved: string[]
total: number
}

View File

@ -0,0 +1,77 @@
// ===================
// © AngelaMos | 2026
// AnswerRunner.module.scss
// ===================
@use '../styles/tokens' as *;
@use '../styles/fonts' as *;
.runner {
display: flex;
flex-direction: column;
gap: $space-3;
}
.field {
display: flex;
flex-direction: column;
gap: $space-2;
}
.hint {
font-size: $font-size-sm;
color: $color-text-muted;
}
.inputRow {
display: flex;
gap: $space-2;
}
.input {
flex: 1;
padding: $space-2 $space-3;
font-family: $font-mono;
font-size: $font-size-sm;
border: 1px solid $color-border;
border-radius: $radius-sm;
}
.inputRow button {
padding: $space-2 $space-4;
font-weight: $font-weight-medium;
color: $color-white;
background: $color-gray-900;
border: none;
border-radius: $radius-sm;
cursor: pointer;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.correct,
.incorrect {
padding: $space-2 $space-3;
font-size: $font-size-sm;
border-radius: $radius-sm;
}
.correct {
color: $color-gray-900;
background: $color-gray-200;
border: 1px solid $color-gray-400;
}
.incorrect {
color: $color-gray-700;
background: $color-gray-100;
border: 1px solid $color-border;
}
.solvedNote {
font-size: $font-size-sm;
color: $color-text-muted;
}

View File

@ -0,0 +1,75 @@
// ===================
// © AngelaMos | 2026
// AnswerRunner.tsx
// ===================
import { type FormEvent, useState } from 'react'
import { useSubmit } from '@/api/hooks'
import type { SubmitResult } from '@/api/types'
import { CATEGORY_HINT, type Category } from '@/config'
import styles from './AnswerRunner.module.scss'
import { SourceReveal } from './SourceReveal'
interface AnswerRunnerProps {
cid: string
category: Category
solved: boolean
}
export function AnswerRunner({
cid,
category,
solved,
}: AnswerRunnerProps): React.ReactElement {
const [answer, setAnswer] = useState('')
const [result, setResult] = useState<SubmitResult | null>(null)
const submit = useSubmit(cid)
const onSubmit = (event: FormEvent): void => {
event.preventDefault()
if (answer.trim().length === 0) {
return
}
submit.mutate(answer, { onSuccess: (data) => setResult(data) })
}
const pending = submit.isPending
const empty = answer.trim().length === 0
return (
<form className={styles.runner} onSubmit={onSubmit}>
<label className={styles.field}>
<span className={styles.hint}>{CATEGORY_HINT[category]}</span>
<div className={styles.inputRow}>
<input
className={styles.input}
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="Your answer"
spellCheck={false}
autoComplete="off"
/>
<button type="submit" disabled={pending || empty}>
{pending ? 'Checking...' : 'Submit'}
</button>
</div>
</label>
{result && (
<div className={result.correct ? styles.correct : styles.incorrect}>
{result.message}
</div>
)}
{result?.correct && result.revealed_source && (
<SourceReveal source={result.revealed_source} />
)}
{solved && result === null && (
<p className={styles.solvedNote}>
Already solved. Submit again to reveal the source.
</p>
)}
</form>
)
}

View File

@ -0,0 +1,95 @@
// ===================
// © AngelaMos | 2026
// CfgPane.module.scss
// ===================
@use '../styles/tokens' as *;
@use '../styles/fonts' as *;
.cfg {
display: flex;
flex-direction: column;
gap: $space-3;
}
.summary {
font-size: $font-size-sm;
color: $color-text-muted;
}
.blocks {
display: flex;
flex-direction: column;
gap: $space-3;
}
.block {
display: flex;
flex-direction: column;
gap: $space-2;
padding: $space-3 $space-4;
background: $color-surface;
border: 1px solid $color-border;
border-left: 3px solid $color-gray-700;
border-radius: $radius-sm;
}
.blockHead {
display: flex;
align-items: baseline;
gap: $space-3;
}
.blockName {
font-weight: $font-weight-semibold;
}
.blockRange {
font-family: $font-mono;
font-size: $font-size-sm;
color: $color-text-muted;
}
.blockBody {
font-size: $font-size-sm;
color: $color-text-muted;
}
.edgeRow {
display: flex;
flex-wrap: wrap;
gap: $space-2;
}
.edge {
padding: $space-1 $space-2;
font-family: $font-mono;
font-size: $font-size-xs;
border: 1px solid $color-border;
border-radius: $radius-full;
color: $color-text;
}
.taken {
border-color: $color-gray-600;
background: $color-gray-100;
}
.fallthrough {
color: $color-text-muted;
}
.jump {
border-color: $color-gray-500;
}
.terminal {
font-size: $font-size-xs;
color: $color-text-muted;
}
.state {
padding: $space-4;
color: $color-text-muted;
font-size: $font-size-sm;
}

View File

@ -0,0 +1,79 @@
// ===================
// © AngelaMos | 2026
// CfgPane.tsx
// ===================
import { useMemo } from 'react'
import { useCfg } from '@/api/hooks'
import type { Target } from '@/api/types'
import { formatAddr } from '@/lib/format'
import styles from './CfgPane.module.scss'
interface CfgPaneProps {
cid: string
target: Target | null
}
export function CfgPane({ cid, target }: CfgPaneProps): React.ReactElement {
const { data, isLoading, isError } = useCfg(cid, target)
const outEdges = useMemo(() => {
const map = new Map<number, { dst: number; kind: string }[]>()
for (const edge of data?.edges ?? []) {
const list = map.get(edge.src) ?? []
list.push({ dst: edge.dst, kind: edge.kind })
map.set(edge.src, list)
}
return map
}, [data])
if (target === null) {
return <div className={styles.state}>No function selected</div>
}
if (isLoading) {
return <div className={styles.state}>Building graph...</div>
}
if (isError || !data) {
return <div className={styles.state}>Failed to build graph</div>
}
return (
<div className={styles.cfg}>
<p className={styles.summary}>
{data.blocks.length} basic blocks, {data.edges.length} edges
</p>
<div className={styles.blocks}>
{data.blocks.map((block, index) => {
const edges = outEdges.get(block.start) ?? []
return (
<div className={styles.block} key={block.start}>
<div className={styles.blockHead}>
<span className={styles.blockName}>block {index}</span>
<span className={styles.blockRange}>
{formatAddr(block.start)} .. {formatAddr(block.end)}
</span>
</div>
<div className={styles.blockBody}>
{block.instructions.length} instructions
</div>
<div className={styles.edgeRow}>
{edges.length === 0 ? (
<span className={styles.terminal}>terminal</span>
) : (
edges.map((edge) => (
<span
className={`${styles.edge} ${styles[edge.kind] ?? ''}`}
key={`${block.start}-${edge.dst}-${edge.kind}`}
>
{edge.kind} to {formatAddr(edge.dst)}
</span>
))
)}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,60 @@
// ===================
// © AngelaMos | 2026
// ChallengeCard.module.scss
// ===================
@use '../styles/tokens' as *;
@use '../styles/fonts' as *;
.card {
display: flex;
flex-direction: column;
gap: $space-2;
padding: $space-4;
background: $color-bg;
border: 1px solid $color-border;
border-radius: $radius-md;
text-decoration: none;
color: inherit;
transition:
border-color $duration-fast $ease,
transform $duration-fast $ease;
&:hover {
border-color: $color-gray-400;
transform: translateY(-2px);
}
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
}
.module {
font-size: $font-size-xs;
text-transform: uppercase;
letter-spacing: 0.08em;
color: $color-text-muted;
}
.solved {
padding: $space-1 $space-2;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
color: $color-gray-900;
background: $color-gray-200;
border-radius: $radius-full;
}
.title {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
}
.id {
font-family: $font-mono;
font-size: $font-size-xs;
color: $color-text-muted;
}

View File

@ -0,0 +1,30 @@
// ===================
// © AngelaMos | 2026
// ChallengeCard.tsx
// ===================
import { Link } from 'react-router-dom'
import type { ChallengeSummary } from '@/api/types'
import { ROUTES } from '@/config'
import styles from './ChallengeCard.module.scss'
interface ChallengeCardProps {
challenge: ChallengeSummary
solved: boolean
}
export function ChallengeCard({
challenge,
solved,
}: ChallengeCardProps): React.ReactElement {
return (
<Link to={ROUTES.challenge(challenge.id)} className={styles.card}>
<div className={styles.top}>
<span className={styles.module}>{challenge.module}</span>
{solved && <span className={styles.solved}>Solved</span>}
</div>
<h3 className={styles.title}>{challenge.title}</h3>
<span className={styles.id}>{challenge.id}</span>
</Link>
)
}

View File

@ -0,0 +1,133 @@
// ===================
// © AngelaMos | 2026
// DisasmPane.module.scss
// ===================
@use '../styles/tokens' as *;
@use '../styles/fonts' as *;
.disasm {
display: flex;
flex-direction: column;
gap: $space-3;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $space-4;
}
.picker {
display: flex;
align-items: center;
gap: $space-2;
font-size: $font-size-sm;
color: $color-text-muted;
select {
padding: $space-1 $space-2;
font-family: $font-mono;
font-size: $font-size-sm;
border: 1px solid $color-border;
border-radius: $radius-sm;
background: $color-bg;
}
}
.gateBanner {
padding: $space-1 $space-3;
font-family: $font-mono;
font-size: $font-size-sm;
color: $color-gray-900;
background: $color-gray-200;
border: 1px solid $color-gray-400;
border-radius: $radius-sm;
cursor: pointer;
}
.listing {
overflow-x: auto;
padding: $space-2;
background: $color-gray-900;
border-radius: $radius-md;
font-family: $font-mono;
font-size: $font-size-sm;
}
.row {
display: grid;
grid-template-columns: 6rem 9rem 4rem 1fr auto;
gap: $space-3;
align-items: center;
padding: $space-1 $space-2;
white-space: pre;
border-radius: $radius-sm;
}
.gate {
background: $color-gray-700;
outline: 1px solid $color-gray-400;
}
.addr {
color: $color-gray-500;
}
.raw {
color: $color-gray-600;
overflow: hidden;
text-overflow: ellipsis;
}
.mnemonic {
color: $color-white;
font-weight: $font-weight-semibold;
}
.ops {
color: $color-gray-300;
}
.callName {
color: $color-gray-100;
font-weight: $font-weight-semibold;
}
.dataRef {
color: $color-gray-500;
}
.callers {
font-family: $font-mono;
font-size: $font-size-sm;
color: $color-text-muted;
}
.target {
justify-self: end;
padding: 0 $space-2;
font-family: $font-mono;
font-size: $font-size-xs;
color: $color-gray-400;
background: transparent;
border: 1px solid transparent;
border-radius: $radius-sm;
}
.linkable {
color: $color-gray-100;
border-color: $color-gray-600;
cursor: pointer;
&:hover {
background: $color-gray-800;
}
}
.state {
padding: $space-4;
color: $color-text-muted;
font-size: $font-size-sm;
}

View File

@ -0,0 +1,112 @@
// ===================
// © AngelaMos | 2026
// DisasmPane.tsx
// ===================
import { useMemo, useRef } from 'react'
import { useDisasm, useXrefs } from '@/api/hooks'
import type { Target } from '@/api/types'
import { formatAddr } from '@/lib/format'
import styles from './DisasmPane.module.scss'
interface DisasmPaneProps {
cid: string
target: Target | null
}
export function DisasmPane({ cid, target }: DisasmPaneProps): React.ReactElement {
const { data, isLoading, isError } = useDisasm(cid, target)
const xrefs = useXrefs(cid, target?.address ?? null)
const rowRefs = useRef<Map<number, HTMLDivElement | null>>(new Map())
const addresses = useMemo(
() => new Set((data?.instructions ?? []).map((i) => i.address)),
[data]
)
if (target === null) {
return <div className={styles.state}>No function selected</div>
}
const scrollTo = (address: number): void => {
rowRefs.current.get(address)?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}
const callers = (xrefs.data?.references ?? []).filter((r) => r.kind === 'call')
return (
<div className={styles.disasm}>
<div className={styles.toolbar}>
{data?.gate_address != null && (
<button
type="button"
className={styles.gateBanner}
onClick={() => scrollTo(data.gate_address as number)}
>
Gate at {formatAddr(data.gate_address)}
</button>
)}
{callers.length > 0 && (
<span className={styles.callers}>
called by {callers.map((c) => formatAddr(c.from_addr)).join(', ')}
</span>
)}
</div>
{isLoading && <div className={styles.state}>Disassembling...</div>}
{isError && <div className={styles.state}>Failed to disassemble</div>}
{data && (
<div className={styles.listing}>
{data.instructions.map((ins) => {
const linkable =
ins.branch_target != null && addresses.has(ins.branch_target)
return (
<div
key={ins.address}
ref={(el) => {
rowRefs.current.set(ins.address, el)
}}
className={`${styles.row} ${ins.is_gate ? styles.gate : ''}`}
>
<span className={styles.addr}>{formatAddr(ins.address)}</span>
<span className={styles.raw}>{ins.bytes}</span>
<span className={styles.mnemonic}>{ins.mnemonic}</span>
<span className={styles.ops}>
{ins.op_str}
{ins.call_name && (
<span className={styles.callName}>
{' '}
&lt;{ins.call_name}&gt;
</span>
)}
{ins.rip_target != null && (
<span className={styles.dataRef}>
{' '}
# {formatAddr(ins.rip_target)}
</span>
)}
</span>
{ins.branch_target != null && (
<button
type="button"
className={`${styles.target} ${linkable ? styles.linkable : ''}`}
onClick={() =>
linkable && scrollTo(ins.branch_target as number)
}
disabled={!linkable}
>
to {formatAddr(ins.branch_target)}
</button>
)}
</div>
)
})}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,108 @@
// ===================
// © AngelaMos | 2026
// HexViewer.module.scss
// ===================
@use '../styles/tokens' as *;
@use '../styles/fonts' as *;
.hex {
display: flex;
flex-direction: column;
gap: $space-3;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $space-4;
}
.pager {
display: flex;
gap: $space-2;
button {
padding: $space-1 $space-3;
font-size: $font-size-sm;
border: 1px solid $color-border;
border-radius: $radius-sm;
background: $color-surface;
color: $color-text;
cursor: pointer;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
}
.range {
font-family: $font-mono;
font-size: $font-size-sm;
color: $color-text;
}
.total {
color: $color-text-muted;
}
.jump {
display: flex;
gap: $space-2;
margin-left: auto;
input {
width: 12rem;
padding: $space-1 $space-2;
font-family: $font-mono;
font-size: $font-size-sm;
border: 1px solid $color-border;
border-radius: $radius-sm;
}
button {
padding: $space-1 $space-3;
font-size: $font-size-sm;
border: 1px solid $color-border;
border-radius: $radius-sm;
background: $color-surface;
cursor: pointer;
}
}
.dump {
overflow-x: auto;
padding: $space-3;
background: $color-gray-900;
border-radius: $radius-md;
font-family: $font-mono;
font-size: $font-size-sm;
line-height: $line-height-normal;
}
.row {
display: flex;
gap: $space-4;
white-space: pre;
}
.gutter {
color: $color-gray-500;
}
.bytes {
color: $color-gray-100;
}
.ascii {
color: $color-gray-400;
}
.state {
padding: $space-4;
color: $color-text-muted;
font-size: $font-size-sm;
}

View File

@ -0,0 +1,122 @@
// ===================
// © AngelaMos | 2026
// HexViewer.tsx
// ===================
import { type FormEvent, useState } from 'react'
import { useHex } from '@/api/hooks'
import { HEX } from '@/config'
import { formatOffset } from '@/lib/format'
import styles from './HexViewer.module.scss'
interface HexRow {
offset: string
bytes: string
ascii: string
raw: string
}
const LINE_RE = /^([0-9a-f]{8}) {2}(.+?) {2}\|(.*)\|$/
function parseLine(line: string): HexRow {
const match = LINE_RE.exec(line)
if (match === null) {
return { offset: '', bytes: '', ascii: '', raw: line }
}
return { offset: match[1], bytes: match[2], ascii: match[3], raw: line }
}
function parseTarget(text: string): number | null {
const token = text.trim().toLowerCase()
if (token.length === 0) {
return null
}
const value = token.startsWith('0x')
? Number.parseInt(token.slice(2), 16)
: Number.parseInt(token, 10)
return Number.isNaN(value) || value < 0 ? null : value
}
interface HexViewerProps {
cid: string
size: number
}
export function HexViewer({ cid, size }: HexViewerProps): React.ReactElement {
const [offset, setOffset] = useState(0)
const [jump, setJump] = useState('')
const length = HEX.PAGE_LENGTH
const { data, isLoading, isError } = useHex(cid, offset, length)
const canPrev = offset > 0
const canNext = offset + length < size
const gotoPrev = (): void => setOffset(Math.max(0, offset - length))
const gotoNext = (): void => {
if (canNext) {
setOffset(offset + length)
}
}
const onJump = (event: FormEvent): void => {
event.preventDefault()
const target = parseTarget(jump)
if (target === null) {
return
}
const aligned = Math.min(target, Math.max(0, size - 1))
setOffset(aligned - (aligned % HEX.BYTES_PER_LINE))
}
return (
<div className={styles.hex}>
<div className={styles.toolbar}>
<div className={styles.pager}>
<button type="button" onClick={gotoPrev} disabled={!canPrev}>
Prev
</button>
<button type="button" onClick={gotoNext} disabled={!canNext}>
Next
</button>
</div>
<span className={styles.range}>
{data
? `${formatOffset(data.base)} .. ${formatOffset(data.base + data.length)}`
: formatOffset(offset)}
<span className={styles.total}> of {size} bytes</span>
</span>
<form className={styles.jump} onSubmit={onJump}>
<input
type="text"
value={jump}
onChange={(e) => setJump(e.target.value)}
placeholder="offset e.g. 0x2000"
aria-label="Jump to offset"
/>
<button type="submit">Go</button>
</form>
</div>
{isLoading && <div className={styles.state}>Loading hex...</div>}
{isError && <div className={styles.state}>Failed to load hex</div>}
{data && (
<div className={styles.dump}>
{data.lines.map(parseLine).map((row) => (
<div className={styles.row} key={row.offset || row.raw}>
{row.offset ? (
<>
<span className={styles.gutter}>{row.offset}</span>
<span className={styles.bytes}>{row.bytes}</span>
<span className={styles.ascii}>{row.ascii}</span>
</>
) : (
<span className={styles.bytes}>{row.raw}</span>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,65 @@
// ===================
// © AngelaMos | 2026
// MissionPanel.module.scss
// ===================
@use '../styles/tokens' as *;
.panel {
display: flex;
flex-direction: column;
gap: $space-3;
padding: $space-5;
background: $color-surface;
border: 1px solid $color-border;
border-radius: $radius-md;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
}
.module {
font-size: $font-size-xs;
text-transform: uppercase;
letter-spacing: 0.08em;
color: $color-text-muted;
}
.solved {
padding: $space-1 $space-2;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
color: $color-gray-900;
background: $color-gray-200;
border-radius: $radius-full;
}
.title {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
}
.mission {
color: $color-text;
line-height: $line-height-relaxed;
}
.meta {
display: flex;
gap: $space-3;
font-size: $font-size-sm;
}
.category {
padding: $space-1 $space-2;
color: $color-text;
background: $color-gray-100;
border-radius: $radius-sm;
}
.size {
color: $color-text-muted;
}

View File

@ -0,0 +1,36 @@
// ===================
// © AngelaMos | 2026
// MissionPanel.tsx
// ===================
import type { ChallengeDetail } from '@/api/types'
import { CATEGORY_LABEL } from '@/config'
import { formatBytes } from '@/lib/format'
import styles from './MissionPanel.module.scss'
interface MissionPanelProps {
challenge: ChallengeDetail
solved: boolean
}
export function MissionPanel({
challenge,
solved,
}: MissionPanelProps): React.ReactElement {
return (
<div className={styles.panel}>
<div className={styles.head}>
<span className={styles.module}>{challenge.module}</span>
{solved && <span className={styles.solved}>Solved</span>}
</div>
<h2 className={styles.title}>{challenge.title}</h2>
<p className={styles.mission}>{challenge.mission}</p>
<div className={styles.meta}>
<span className={styles.category}>
{CATEGORY_LABEL[challenge.category]}
</span>
<span className={styles.size}>{formatBytes(challenge.size)}</span>
</div>
</div>
)
}

View File

@ -0,0 +1,33 @@
// ===================
// © AngelaMos | 2026
// ProgressBar.module.scss
// ===================
@use '../styles/tokens' as *;
.wrap {
display: flex;
flex-direction: column;
gap: $space-2;
}
.label {
display: flex;
justify-content: space-between;
font-size: $font-size-sm;
color: $color-text-muted;
}
.track {
height: 0.5rem;
background: $color-gray-200;
border-radius: $radius-full;
overflow: hidden;
}
.fill {
height: 100%;
background: $color-gray-700;
border-radius: $radius-full;
transition: width $duration-normal $ease;
}

View File

@ -0,0 +1,31 @@
// ===================
// © AngelaMos | 2026
// ProgressBar.tsx
// ===================
import styles from './ProgressBar.module.scss'
interface ProgressBarProps {
solved: number
total: number
}
export function ProgressBar({
solved,
total,
}: ProgressBarProps): React.ReactElement {
const pct = total > 0 ? Math.round((solved / total) * 100) : 0
return (
<div className={styles.wrap}>
<div className={styles.label}>
<span>Progress</span>
<span>
{solved} / {total}
</span>
</div>
<div className={styles.track}>
<div className={styles.fill} style={{ width: `${pct}%` }} />
</div>
</div>
)
}

View File

@ -0,0 +1,67 @@
// ===================
// © AngelaMos | 2026
// SectionMap.module.scss
// ===================
@use '../styles/tokens' as *;
@use '../styles/fonts' as *;
.wrap {
overflow-x: auto;
border: 1px solid $color-border;
border-radius: $radius-md;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: $font-size-sm;
th,
td {
padding: $space-2 $space-3;
text-align: left;
border-bottom: 1px solid $color-border;
}
th {
font-weight: $font-weight-semibold;
color: $color-text-muted;
background: $color-surface;
}
tbody tr:last-child td {
border-bottom: none;
}
}
.exec {
background: $color-gray-50;
.name {
font-weight: $font-weight-semibold;
}
}
.name {
font-family: $font-mono;
}
.mono {
font-family: $font-mono;
color: $color-text-muted;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.dim {
color: $color-text-muted;
}
.flags {
font-family: $font-mono;
letter-spacing: 0.1em;
}

View File

@ -0,0 +1,48 @@
// ===================
// © AngelaMos | 2026
// SectionMap.tsx
// ===================
import type { SectionView } from '@/api/types'
import { formatAddr, formatOffset } from '@/lib/format'
import styles from './SectionMap.module.scss'
export function SectionMap({
sections,
}: {
sections: SectionView[]
}): React.ReactElement {
return (
<div className={styles.wrap}>
<table className={styles.table}>
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Type</th>
<th>Addr</th>
<th>Offset</th>
<th className={styles.num}>Size</th>
<th>Flags</th>
</tr>
</thead>
<tbody>
{sections.map((section) => (
<tr
key={section.index}
className={section.flags.includes('X') ? styles.exec : ''}
>
<td className={styles.dim}>{section.index}</td>
<td className={styles.name}>{section.name || '(null)'}</td>
<td>{section.type}</td>
<td className={styles.mono}>{formatAddr(section.addr)}</td>
<td className={styles.mono}>{formatOffset(section.offset)}</td>
<td className={styles.num}>{section.size}</td>
<td className={styles.flags}>{section.flags || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@ -0,0 +1,39 @@
// ===================
// © AngelaMos | 2026
// SourceReveal.module.scss
// ===================
@use '../styles/tokens' as *;
@use '../styles/fonts' as *;
.reveal {
display: flex;
flex-direction: column;
gap: $space-2;
padding: $space-4;
background: $color-gray-900;
border-radius: $radius-md;
}
.heading {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: $color-gray-50;
}
.note {
font-size: $font-size-sm;
color: $color-gray-400;
}
.code {
overflow-x: auto;
padding: $space-3;
background: $color-black;
border-radius: $radius-sm;
font-family: $font-mono;
font-size: $font-size-sm;
line-height: $line-height-normal;
color: $color-gray-100;
white-space: pre;
}

View File

@ -0,0 +1,18 @@
// ===================
// © AngelaMos | 2026
// SourceReveal.tsx
// ===================
import styles from './SourceReveal.module.scss'
export function SourceReveal({ source }: { source: string }): React.ReactElement {
return (
<div className={styles.reveal}>
<h3 className={styles.heading}>Source revealed</h3>
<p className={styles.note}>
You reached the answer from the binary. Here is the C that produced it.
</p>
<pre className={styles.code}>{source}</pre>
</div>
)
}

View File

@ -0,0 +1,74 @@
// ===================
// © AngelaMos | 2026
// StringsPane.module.scss
// ===================
@use '../styles/tokens' as *;
@use '../styles/fonts' as *;
.strings {
display: flex;
flex-direction: column;
gap: $space-3;
}
.toolbar {
display: flex;
align-items: center;
gap: $space-4;
}
.control {
display: flex;
align-items: center;
gap: $space-3;
font-size: $font-size-sm;
color: $color-text-muted;
input {
width: 12rem;
}
}
.count {
margin-left: auto;
font-size: $font-size-sm;
color: $color-text-muted;
}
.list {
max-height: 28rem;
overflow-y: auto;
border: 1px solid $color-border;
border-radius: $radius-md;
font-family: $font-mono;
font-size: $font-size-sm;
}
.row {
display: grid;
grid-template-columns: 8rem 1fr;
gap: $space-4;
padding: $space-1 $space-3;
border-bottom: 1px solid $color-border;
&:last-child {
border-bottom: none;
}
}
.offset {
color: $color-text-muted;
}
.text {
color: $color-text;
white-space: pre-wrap;
word-break: break-all;
}
.state {
padding: $space-4;
color: $color-text-muted;
font-size: $font-size-sm;
}

View File

@ -0,0 +1,49 @@
// ===================
// © AngelaMos | 2026
// StringsPane.tsx
// ===================
import { useState } from 'react'
import { useStrings } from '@/api/hooks'
import { STRINGS } from '@/config'
import { formatOffset } from '@/lib/format'
import styles from './StringsPane.module.scss'
export function StringsPane({ cid }: { cid: string }): React.ReactElement {
const [minLength, setMinLength] = useState<number>(STRINGS.DEFAULT_MIN_LENGTH)
const { data, isLoading, isError } = useStrings(cid, minLength)
return (
<div className={styles.strings}>
<div className={styles.toolbar}>
<label className={styles.control}>
<span>Min length: {minLength}</span>
<input
type="range"
min={STRINGS.MIN_MIN_LENGTH}
max={STRINGS.MAX_MIN_LENGTH}
value={minLength}
onChange={(e) => setMinLength(Number(e.target.value))}
/>
</label>
{data && (
<span className={styles.count}>{data.strings.length} strings</span>
)}
</div>
{isLoading && <div className={styles.state}>Loading strings...</div>}
{isError && <div className={styles.state}>Failed to load strings</div>}
{data && (
<div className={styles.list}>
{data.strings.map((entry) => (
<div className={styles.row} key={`${entry.offset}-${entry.text}`}>
<span className={styles.offset}>{formatOffset(entry.offset)}</span>
<span className={styles.text}>{entry.text}</span>
</div>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,104 @@
// ===================
// © AngelaMos | 2026
// Workspace.module.scss
// ===================
@use '../styles/tokens' as *;
@use '../styles/mixins' as *;
.workspace {
display: grid;
grid-template-columns: minmax(20rem, 24rem) 1fr;
gap: $space-6;
align-items: start;
@include breakpoint-down('lg') {
grid-template-columns: 1fr;
}
}
.side {
display: flex;
flex-direction: column;
gap: $space-4;
position: sticky;
top: $space-4;
@include breakpoint-down('lg') {
position: static;
}
}
.runnerCard {
display: flex;
flex-direction: column;
gap: $space-3;
padding: $space-5;
background: $color-bg;
border: 1px solid $color-border;
border-radius: $radius-md;
}
.runnerTitle {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
}
.analysis {
display: flex;
flex-direction: column;
gap: $space-4;
min-width: 0;
}
.tabs {
display: flex;
gap: $space-1;
border-bottom: 1px solid $color-border;
}
.tab {
padding: $space-2 $space-4;
font-size: $font-size-sm;
color: $color-text-muted;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
&:hover {
color: $color-text;
}
}
.activeTab {
color: $color-text;
font-weight: $font-weight-medium;
border-bottom-color: $color-gray-900;
}
.picker {
display: flex;
align-items: center;
gap: $space-2;
font-size: $font-size-sm;
color: $color-text-muted;
select {
padding: $space-1 $space-2;
font-size: $font-size-sm;
border: 1px solid $color-border;
border-radius: $radius-sm;
background: $color-bg;
}
}
.pane {
min-width: 0;
}
.state {
padding: $space-6;
color: $color-text-muted;
font-size: $font-size-sm;
}

View File

@ -0,0 +1,139 @@
// ===================
// © AngelaMos | 2026
// Workspace.tsx
// ===================
import { useMemo, useState } from 'react'
import { useChallenge, useElf, useProgress } from '@/api/hooks'
import type { Target } from '@/api/types'
import { AnswerRunner } from './AnswerRunner'
import { CfgPane } from './CfgPane'
import { DisasmPane } from './DisasmPane'
import { HexViewer } from './HexViewer'
import { MissionPanel } from './MissionPanel'
import { SectionMap } from './SectionMap'
import { StringsPane } from './StringsPane'
import styles from './Workspace.module.scss'
const TABS = ['hex', 'disasm', 'graph', 'sections', 'strings'] as const
type Tab = (typeof TABS)[number]
const TAB_LABEL: Record<Tab, string> = {
hex: 'Hex',
disasm: 'Disassembly',
graph: 'Graph',
sections: 'Sections',
strings: 'Strings',
}
const PREFERRED = ['check', 'main', '_start']
function pickDefault(targets: Target[]): Target | null {
for (const name of PREFERRED) {
const hit = targets.find((t) => t.label === name)
if (hit) {
return hit
}
}
return targets[0] ?? null
}
export function Workspace({ cid }: { cid: string }): React.ReactElement {
const [tab, setTab] = useState<Tab>('hex')
const [targetKey, setTargetKey] = useState('')
const challenge = useChallenge(cid)
const elf = useElf(cid)
const progress = useProgress()
const targets = useMemo<Target[]>(() => {
const named = (elf.data?.functions ?? [])
.filter((f) => f.size > 0 && f.value > 0)
.map((f) => ({ label: f.name, address: f.value, symbol: f.name }))
if (named.length > 0) {
return named
}
return (elf.data?.discovered ?? []).map((d) => ({
label: d.label,
address: d.address,
}))
}, [elf.data])
if (challenge.isLoading) {
return <div className={styles.state}>Loading challenge...</div>
}
if (challenge.isError || !challenge.data) {
return <div className={styles.state}>Challenge not found</div>
}
const detail = challenge.data
const solved = progress.data?.solved.includes(cid) ?? false
const selected =
targets.find((t) => t.label === targetKey) ?? pickDefault(targets)
const needsTarget = tab === 'disasm' || tab === 'graph'
return (
<div className={styles.workspace}>
<aside className={styles.side}>
<MissionPanel challenge={detail} solved={solved} />
<div className={styles.runnerCard}>
<h3 className={styles.runnerTitle}>Submit answer</h3>
<AnswerRunner cid={cid} category={detail.category} solved={solved} />
</div>
</aside>
<section className={styles.analysis}>
<div className={styles.tabs}>
{TABS.map((item) => (
<button
key={item}
type="button"
className={`${styles.tab} ${tab === item ? styles.activeTab : ''}`}
onClick={() => setTab(item)}
>
{TAB_LABEL[item]}
</button>
))}
</div>
{needsTarget && targets.length > 0 && (
<label className={styles.picker}>
<span>Function</span>
<select
value={selected?.label ?? ''}
onChange={(e) => setTargetKey(e.target.value)}
>
{targets.map((t) => (
<option key={t.label} value={t.label}>
{t.label}
</option>
))}
</select>
</label>
)}
<div className={styles.pane}>
{tab === 'hex' && <HexViewer cid={cid} size={detail.size} />}
{tab === 'disasm' &&
(elf.isLoading ? (
<div className={styles.state}>Loading ELF...</div>
) : (
<DisasmPane cid={cid} target={selected} />
))}
{tab === 'graph' &&
(elf.isLoading ? (
<div className={styles.state}>Loading ELF...</div>
) : (
<CfgPane cid={cid} target={selected} />
))}
{tab === 'sections' &&
(elf.isLoading ? (
<div className={styles.state}>Loading ELF...</div>
) : (
<SectionMap sections={elf.data?.sections ?? []} />
))}
{tab === 'strings' && <StringsPane cid={cid} />}
</div>
</section>
</div>
)
}

View File

@ -0,0 +1,16 @@
// ===================
// © AngelaMos | 2026
// index.tsx
// ===================
export * from './AnswerRunner'
export * from './CfgPane'
export * from './ChallengeCard'
export * from './DisasmPane'
export * from './HexViewer'
export * from './MissionPanel'
export * from './ProgressBar'
export * from './SectionMap'
export * from './SourceReveal'
export * from './StringsPane'
export * from './Workspace'

View File

@ -0,0 +1,102 @@
// ===================
// © AngelaMos | 2026
// config.ts
// ===================
export const API_ENDPOINTS = {
CHALLENGES: '/challenges',
CHALLENGE: (cid: string) => `/challenges/${cid}`,
HEX: (cid: string) => `/challenges/${cid}/hex`,
ELF: (cid: string) => `/challenges/${cid}/elf`,
DISASM: (cid: string) => `/challenges/${cid}/disasm`,
CFG: (cid: string) => `/challenges/${cid}/cfg`,
XREFS: (cid: string) => `/challenges/${cid}/xrefs`,
STRINGS: (cid: string) => `/challenges/${cid}/strings`,
SUBMIT: (cid: string) => `/challenges/${cid}/submit`,
PROGRESS: '/progress',
} as const
export const QUERY_KEYS = {
CHALLENGES: ['challenges'] as const,
CHALLENGE: (cid: string) => ['challenges', cid] as const,
HEX: (cid: string, offset: number, length: number) =>
['challenges', cid, 'hex', offset, length] as const,
ELF: (cid: string) => ['challenges', cid, 'elf'] as const,
DISASM: (cid: string, label: string, session: string) =>
['challenges', cid, 'disasm', label, session] as const,
CFG: (cid: string, label: string) => ['challenges', cid, 'cfg', label] as const,
XREFS: (cid: string, target: number) =>
['challenges', cid, 'xrefs', target] as const,
STRINGS: (cid: string, minLength: number) =>
['challenges', cid, 'strings', minLength] as const,
PROGRESS: (session: string) => ['progress', session] as const,
} as const
export const ROUTES = {
HOME: '/',
CHALLENGE: '/c/:cid',
challenge: (cid: string) => `/c/${cid}`,
} as const
export const STORAGE_KEYS = {
UI: 'rveng-ui',
SESSION: 'rveng-session',
} as const
export const CATEGORY = {
FOUND_VALUE: 'found_value',
IDENTIFIED_SYMBOL: 'identified_symbol',
PATCHED_BYTES: 'patched_bytes',
} as const
export type Category = (typeof CATEGORY)[keyof typeof CATEGORY]
export const CATEGORY_LABEL: Record<Category, string> = {
[CATEGORY.FOUND_VALUE]: 'Find a value',
[CATEGORY.IDENTIFIED_SYMBOL]: 'Name a symbol',
[CATEGORY.PATCHED_BYTES]: 'Patch bytes',
}
export const CATEGORY_HINT: Record<Category, string> = {
[CATEGORY.FOUND_VALUE]: 'Submit the value in decimal or hex (0x...)',
[CATEGORY.IDENTIFIED_SYMBOL]: 'Submit the exact symbol name',
[CATEGORY.PATCHED_BYTES]: 'Submit the replacement bytes as hex (e.g. 9090)',
}
export const HEX = {
DEFAULT_LENGTH: 256,
PAGE_LENGTH: 256,
MAX_LENGTH: 4096,
BYTES_PER_LINE: 16,
} as const
export const STRINGS = {
DEFAULT_MIN_LENGTH: 4,
MIN_MIN_LENGTH: 1,
MAX_MIN_LENGTH: 64,
} as const
export const QUERY_CONFIG = {
STALE_TIME: {
STATIC: Number.POSITIVE_INFINITY,
DEFAULT: 1000 * 60 * 5,
FREQUENT: 1000 * 30,
},
GC_TIME: {
DEFAULT: 1000 * 60 * 30,
LONG: 1000 * 60 * 60,
},
RETRY: {
DEFAULT: 2,
NONE: 0,
},
} as const
export const HTTP_STATUS = {
OK: 200,
BAD_REQUEST: 400,
NOT_FOUND: 404,
PAYLOAD_TOO_LARGE: 413,
UNPROCESSABLE: 422,
INTERNAL_SERVER: 500,
} as const

View File

@ -0,0 +1,22 @@
// ===================
// © AngelaMos | 2026
// api.config.ts
// ===================
import axios, { type AxiosInstance } from 'axios'
import { transformAxiosError } from './errors'
const getBaseURL = (): string => {
return import.meta.env.VITE_API_URL ?? '/api'
}
export const apiClient: AxiosInstance = axios.create({
baseURL: getBaseURL(),
timeout: 15000,
headers: { 'Content-Type': 'application/json' },
})
apiClient.interceptors.response.use(
(response) => response,
(error) => Promise.reject(transformAxiosError(error))
)

View File

@ -0,0 +1,116 @@
/**
* ©AngelaMos | 2025
* errors.ts
*/
import type { AxiosError } from 'axios'
export const ApiErrorCode = {
NETWORK_ERROR: 'NETWORK_ERROR',
VALIDATION_ERROR: 'VALIDATION_ERROR',
AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR',
AUTHORIZATION_ERROR: 'AUTHORIZATION_ERROR',
NOT_FOUND: 'NOT_FOUND',
CONFLICT: 'CONFLICT',
RATE_LIMITED: 'RATE_LIMITED',
SERVER_ERROR: 'SERVER_ERROR',
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
} as const
export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode]
export class ApiError extends Error {
readonly code: ApiErrorCode
readonly statusCode: number
readonly details?: Record<string, string[]>
constructor(
message: string,
code: ApiErrorCode,
statusCode: number,
details?: Record<string, string[]>
) {
super(message)
this.name = 'ApiError'
this.code = code
this.statusCode = statusCode
this.details = details
}
getUserMessage(): string {
const messages: Record<ApiErrorCode, string> = {
[ApiErrorCode.NETWORK_ERROR]:
'Unable to connect. Please check your internet connection.',
[ApiErrorCode.VALIDATION_ERROR]: 'Please check your input and try again.',
[ApiErrorCode.AUTHENTICATION_ERROR]:
'Your session has expired. Please log in again.',
[ApiErrorCode.AUTHORIZATION_ERROR]:
'You do not have permission to perform this action.',
[ApiErrorCode.NOT_FOUND]: 'The requested resource was not found.',
[ApiErrorCode.CONFLICT]:
'This operation conflicts with an existing resource.',
[ApiErrorCode.RATE_LIMITED]:
'Too many requests. Please wait a moment and try again.',
[ApiErrorCode.SERVER_ERROR]:
'Something went wrong on our end. Please try again later.',
[ApiErrorCode.UNKNOWN_ERROR]:
'An unexpected error occurred. Please try again.',
}
return messages[this.code]
}
}
interface ApiErrorResponse {
detail?: string | { msg: string; type: string }[]
message?: string
}
export function transformAxiosError(error: AxiosError<unknown>): ApiError {
if (!error.response) {
return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0)
}
const { status } = error.response
const data = error.response.data as ApiErrorResponse | undefined
let message = 'An error occurred'
let details: Record<string, string[]> | undefined
if (data?.detail) {
if (typeof data.detail === 'string') {
message = data.detail
} else if (Array.isArray(data.detail)) {
details = { validation: [] }
data.detail.forEach((err) => {
details?.validation.push(err.msg)
})
message = 'Validation error'
}
} else if (data?.message) {
message = data.message
}
const codeMap: Record<number, ApiErrorCode> = {
400: ApiErrorCode.VALIDATION_ERROR,
401: ApiErrorCode.AUTHENTICATION_ERROR,
403: ApiErrorCode.AUTHORIZATION_ERROR,
404: ApiErrorCode.NOT_FOUND,
409: ApiErrorCode.CONFLICT,
413: ApiErrorCode.VALIDATION_ERROR,
422: ApiErrorCode.VALIDATION_ERROR,
429: ApiErrorCode.RATE_LIMITED,
500: ApiErrorCode.SERVER_ERROR,
502: ApiErrorCode.SERVER_ERROR,
503: ApiErrorCode.SERVER_ERROR,
504: ApiErrorCode.SERVER_ERROR,
}
const code = codeMap[status] || ApiErrorCode.UNKNOWN_ERROR
return new ApiError(message, code, status, details)
}
declare module '@tanstack/react-query' {
interface Register {
defaultError: ApiError
}
}

View File

@ -0,0 +1,8 @@
// ===================
// © AngelaMos | 2025
// index.ts
// ===================
export * from './api.config'
export * from './errors'
export * from './query.config'

View File

@ -0,0 +1,93 @@
// ===================
// © AngelaMos | 2026
// query.config.ts
// ===================
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { QUERY_CONFIG } from '@/config'
import { ApiError, ApiErrorCode } from './errors'
const NO_RETRY_ERROR_CODES: readonly ApiErrorCode[] = [
ApiErrorCode.NOT_FOUND,
ApiErrorCode.VALIDATION_ERROR,
] as const
const shouldRetryQuery = (failureCount: number, error: Error): boolean => {
if (error instanceof ApiError) {
if (NO_RETRY_ERROR_CODES.includes(error.code)) {
return false
}
}
return failureCount < QUERY_CONFIG.RETRY.DEFAULT
}
const calculateRetryDelay = (attemptIndex: number): number => {
const baseDelay = 500
const maxDelay = 5000
return Math.min(baseDelay * 2 ** attemptIndex, maxDelay)
}
const handleQueryCacheError = (
error: Error,
query: { state: { data: unknown } }
): void => {
if (query.state.data !== undefined) {
const message =
error instanceof ApiError
? error.getUserMessage()
: 'Background update failed'
toast.error(message)
}
}
const handleMutationCacheError = (
error: Error,
_variables: unknown,
_context: unknown,
mutation: { options: { onError?: unknown } }
): void => {
if (mutation.options.onError === undefined) {
const message =
error instanceof ApiError ? error.getUserMessage() : 'Operation failed'
toast.error(message)
}
}
export const QUERY_STRATEGIES = {
standard: {
staleTime: QUERY_CONFIG.STALE_TIME.DEFAULT,
gcTime: QUERY_CONFIG.GC_TIME.DEFAULT,
},
static: {
staleTime: QUERY_CONFIG.STALE_TIME.STATIC,
gcTime: QUERY_CONFIG.GC_TIME.LONG,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
} as const
export type QueryStrategy = keyof typeof QUERY_STRATEGIES
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: QUERY_CONFIG.STALE_TIME.DEFAULT,
gcTime: QUERY_CONFIG.GC_TIME.DEFAULT,
retry: shouldRetryQuery,
retryDelay: calculateRetryDelay,
refetchOnWindowFocus: false,
refetchOnMount: true,
refetchOnReconnect: true,
},
mutations: {
retry: QUERY_CONFIG.RETRY.NONE,
},
},
queryCache: new QueryCache({
onError: handleQueryCacheError,
}),
mutationCache: new MutationCache({
onError: handleMutationCacheError,
}),
})

View File

@ -0,0 +1,28 @@
// ===================
// © AngelaMos | 2026
// routers.tsx
// ===================
import { createBrowserRouter } from 'react-router-dom'
import { ROUTES } from '@/config'
import { Shell } from './shell'
export const router = createBrowserRouter([
{
element: <Shell />,
children: [
{
path: ROUTES.HOME,
lazy: () => import('@/pages/browser'),
},
{
path: ROUTES.CHALLENGE,
lazy: () => import('@/pages/workspace'),
},
{
path: '*',
lazy: () => import('@/pages/browser'),
},
],
},
])

View File

@ -0,0 +1,62 @@
// ===================
// © AngelaMos | 2026
// shell.module.scss
// ===================
@use '../../styles/tokens' as *;
@use '../../styles/fonts' as *;
.shell {
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100dvh;
}
.header {
display: flex;
align-items: baseline;
gap: $space-3;
padding: $space-4 $space-6;
border-bottom: 1px solid $color-border;
background: $color-bg;
}
.brand {
font-family: $font-mono;
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $color-text;
text-decoration: none;
}
.tag {
font-size: $font-size-sm;
color: $color-text-muted;
}
.content {
flex: 1;
padding: $space-6;
}
.loading {
padding: $space-8;
color: $color-text-muted;
}
.error {
display: flex;
flex-direction: column;
gap: $space-3;
padding: $space-6;
pre {
padding: $space-3;
background: $color-gray-100;
border-radius: $radius-sm;
font-family: $font-mono;
font-size: $font-size-sm;
overflow-x: auto;
}
}

View File

@ -0,0 +1,44 @@
/**
* ©AngelaMos | 2026
* shell.tsx
*/
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { Link, Outlet } from 'react-router-dom'
import { ROUTES } from '@/config'
import styles from './shell.module.scss'
function ShellErrorFallback({ error }: { error: Error }): React.ReactElement {
return (
<div className={styles.error}>
<h2>Something went wrong</h2>
<pre>{error.message}</pre>
</div>
)
}
function ShellLoading(): React.ReactElement {
return <div className={styles.loading}>Loading...</div>
}
export function Shell(): React.ReactElement {
return (
<div className={styles.shell}>
<header className={styles.header}>
<Link to={ROUTES.HOME} className={styles.brand}>
rveng
</Link>
<span className={styles.tag}>reverse-engineering lab</span>
</header>
<main className={styles.content}>
<ErrorBoundary FallbackComponent={ShellErrorFallback}>
<Suspense fallback={<ShellLoading />}>
<Outlet />
</Suspense>
</ErrorBoundary>
</main>
</div>
)
}

View File

@ -0,0 +1,4 @@
// ===================
// © AngelaMos | 2026
// toast.module.scss
// ===================

View File

@ -0,0 +1,6 @@
// ===================
// © AngelaMos | 2026
// index.ts
// ===================
export * from './shell.ui.store'

View File

@ -0,0 +1,63 @@
/**
* ©AngelaMos | 2025
* ui.store.ts
*/
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
type Theme = 'light' | 'dark' | 'system'
interface UIState {
theme: Theme
sidebarOpen: boolean
sidebarCollapsed: boolean
setTheme: (theme: Theme) => void
toggleSidebar: () => void
setSidebarOpen: (open: boolean) => void
toggleSidebarCollapsed: () => void
}
export const useUIStore = create<UIState>()(
devtools(
persist(
(set) => ({
theme: 'dark',
sidebarOpen: false,
sidebarCollapsed: false,
setTheme: (theme) => set({ theme }, false, 'ui/setTheme'),
toggleSidebar: () =>
set(
(state) => ({ sidebarOpen: !state.sidebarOpen }),
false,
'ui/toggleSidebar'
),
setSidebarOpen: (open) =>
set({ sidebarOpen: open }, false, 'ui/setSidebarOpen'),
toggleSidebarCollapsed: () =>
set(
(state) => ({ sidebarCollapsed: !state.sidebarCollapsed }),
false,
'ui/toggleSidebarCollapsed'
),
}),
{
name: 'ui-storage',
partialize: (state) => ({
theme: state.theme,
sidebarCollapsed: state.sidebarCollapsed,
}),
}
),
{ name: 'UIStore' }
)
)
export const useTheme = (): Theme => useUIStore((s) => s.theme)
export const useSidebarOpen = (): boolean => useUIStore((s) => s.sidebarOpen)
export const useSidebarCollapsed = (): boolean =>
useUIStore((s) => s.sidebarCollapsed)

View File

@ -0,0 +1,48 @@
// ===================
// © AngelaMos | 2026
// format.ts
// ===================
export function toHex(value: number, pad = 0): string {
const body = value.toString(16).padStart(pad, '0')
return `0x${body}`
}
export function formatAddr(value: number): string {
return toHex(value, 6)
}
export function formatOffset(value: number): string {
return toHex(value, 8)
}
const ELF_TYPES: Record<number, string> = {
0: 'NONE',
1: 'REL',
2: 'EXEC',
3: 'DYN',
4: 'CORE',
}
const ELF_MACHINES: Record<number, string> = {
3: 'x86',
40: 'ARM',
62: 'x86-64',
183: 'AArch64',
243: 'RISC-V',
}
export function elfTypeName(value: number): string {
return ELF_TYPES[value] ?? toHex(value)
}
export function elfMachineName(value: number): string {
return ELF_MACHINES[value] ?? toHex(value)
}
export function formatBytes(count: number): string {
if (count < 1024) {
return `${count} B`
}
return `${(count / 1024).toFixed(1)} KiB`
}

View File

@ -0,0 +1,35 @@
// ===================
// © AngelaMos | 2026
// session.ts
// ===================
import { STORAGE_KEYS } from '@/config'
function createId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
}
return `s-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`
}
let cached: string | null = null
export function getSessionId(): string {
if (cached !== null) {
return cached
}
try {
const existing = localStorage.getItem(STORAGE_KEYS.SESSION)
if (existing) {
cached = existing
return existing
}
const fresh = createId()
localStorage.setItem(STORAGE_KEYS.SESSION, fresh)
cached = fresh
return fresh
} catch {
cached = createId()
return cached
}
}

View File

@ -0,0 +1,15 @@
// ===========================
// ©AngelaMos | 2025
// main.tsx
// ===========================
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.scss'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@ -0,0 +1,44 @@
// ===================
// © AngelaMos | 2026
// browser.module.scss
// ===================
@use '../../styles/tokens' as *;
@use '../../styles/mixins' as *;
.page {
display: flex;
flex-direction: column;
gap: $space-6;
max-width: $breakpoint-xl;
margin: 0 auto;
}
.header {
display: flex;
flex-direction: column;
gap: $space-2;
}
.title {
font-size: $font-size-3xl;
font-weight: $font-weight-bold;
}
.subtitle {
color: $color-text-muted;
}
.progress {
max-width: 24rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: $space-4;
}
.state {
color: $color-text-muted;
}

View File

@ -0,0 +1,53 @@
// ===================
// © AngelaMos | 2026
// index.tsx
// ===================
import { useChallenges, useProgress } from '@/api/hooks'
import { ChallengeCard, ProgressBar } from '@/components'
import styles from './browser.module.scss'
export function Component(): React.ReactElement {
const challenges = useChallenges()
const progress = useProgress()
const solvedSet = new Set(progress.data?.solved ?? [])
return (
<div className={styles.page}>
<header className={styles.header}>
<h1 className={styles.title}>Challenges</h1>
<p className={styles.subtitle}>
Read the binary. Reach the answer. Reveal the source.
</p>
</header>
{progress.data && (
<div className={styles.progress}>
<ProgressBar
solved={progress.data.solved.length}
total={progress.data.total}
/>
</div>
)}
{challenges.isLoading && <p className={styles.state}>Loading...</p>}
{challenges.isError && (
<p className={styles.state}>Failed to load challenges</p>
)}
{challenges.data && (
<div className={styles.grid}>
{challenges.data.map((challenge) => (
<ChallengeCard
key={challenge.id}
challenge={challenge}
solved={solvedSet.has(challenge.id)}
/>
))}
</div>
)}
</div>
)
}
Component.displayName = 'Browser'

View File

@ -0,0 +1,28 @@
// ===================
// © AngelaMos | 2026
// index.tsx
// ===================
import { Link, useParams } from 'react-router-dom'
import { Workspace } from '@/components'
import { ROUTES } from '@/config'
import styles from './workspace.module.scss'
export function Component(): React.ReactElement {
const { cid } = useParams<{ cid: string }>()
if (!cid) {
return <div className={styles.page}>No challenge selected</div>
}
return (
<div className={styles.page}>
<Link to={ROUTES.HOME} className={styles.back}>
Back to challenges
</Link>
<Workspace cid={cid} />
</div>
)
}
Component.displayName = 'ChallengeWorkspace'

View File

@ -0,0 +1,24 @@
// ===================
// © AngelaMos | 2026
// workspace.module.scss
// ===================
@use '../../styles/tokens' as *;
.page {
display: flex;
flex-direction: column;
gap: $space-4;
}
.back {
align-self: flex-start;
font-size: $font-size-sm;
color: $color-text-muted;
text-decoration: none;
&:hover {
color: $color-text;
text-decoration: underline;
}
}

View File

@ -0,0 +1,31 @@
// ===================
// © AngelaMos | 2026
// styles.scss
// ===================
@forward 'styles/tokens';
@forward 'styles/fonts';
@forward 'styles/mixins';
@use 'styles/reset';
@use 'styles/tokens' as *;
@use 'styles/fonts' as *;
body {
font-family: $font-sans;
color: $color-text;
background-color: $color-bg;
}
#root {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
.app {
flex: 1;
display: flex;
flex-direction: column;
}

View File

@ -0,0 +1,9 @@
// ===================
// © AngelaMos | 2026
// _fonts.scss
// ===================
$font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica,
Arial, sans-serif;
$font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono',
monospace;

View File

@ -0,0 +1,8 @@
// ===================
// © AngelaMos | 2026
// _index.scss
// ===================
@forward 'tokens';
@forward 'fonts';
@forward 'mixins';

View File

@ -0,0 +1,42 @@
// ===================
// © AngelaMos | 2026
// _mixins.scss
// ===================
@use 'sass:map';
@use 'tokens' as *;
$breakpoints: (
'xs': $breakpoint-xs,
'sm': $breakpoint-sm,
'md': $breakpoint-md,
'lg': $breakpoint-lg,
'xl': $breakpoint-xl,
'2xl': $breakpoint-2xl,
);
@mixin breakpoint-up($size) {
@media (min-width: map.get($breakpoints, $size)) { @content; }
}
@mixin breakpoint-down($size) {
@media (max-width: map.get($breakpoints, $size)) { @content; }
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}

View File

@ -0,0 +1,75 @@
// ===================
// © AngelaMos | 2026
// _reset.scss
// ===================
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
padding: 0;
-webkit-tap-highlight-color: transparent;
}
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
}
body {
min-height: 100vh;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
background: none;
border: none;
}
a {
color: inherit;
text-decoration: none;
}
ul,
ol {
list-style: none;
}
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
#root {
isolation: isolate;
}

View File

@ -0,0 +1,85 @@
// ===================
// © AngelaMos | 2026
// _tokens.scss
// ===================
//
// Neutral baseline. No brand identity -- grayscale palette, system type, plain
// spacing scale. Design on top of these; nothing here implies a look.
// Spacing (8px base)
$space-0: 0;
$space-1: 0.25rem;
$space-2: 0.5rem;
$space-3: 0.75rem;
$space-4: 1rem;
$space-5: 1.25rem;
$space-6: 1.5rem;
$space-8: 2rem;
$space-10: 2.5rem;
$space-12: 3rem;
$space-16: 4rem;
$space-20: 5rem;
$space-24: 6rem;
// Font sizes
$font-size-xs: 0.75rem;
$font-size-sm: 0.875rem;
$font-size-base: 1rem;
$font-size-lg: 1.125rem;
$font-size-xl: 1.25rem;
$font-size-2xl: 1.5rem;
$font-size-3xl: 1.875rem;
$font-size-4xl: 2.25rem;
// Weights / line-height / radius
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$font-weight-bold: 700;
$line-height-tight: 1.25;
$line-height-normal: 1.5;
$line-height-relaxed: 1.75;
$radius-sm: 0.25rem;
$radius-md: 0.5rem;
$radius-lg: 0.75rem;
$radius-full: 9999px;
// Neutral grayscale
$color-white: #ffffff;
$color-black: #000000;
$color-gray-50: #fafafa;
$color-gray-100: #f4f4f5;
$color-gray-200: #e4e4e7;
$color-gray-300: #d4d4d8;
$color-gray-400: #a1a1aa;
$color-gray-500: #71717a;
$color-gray-600: #52525b;
$color-gray-700: #3f3f46;
$color-gray-800: #27272a;
$color-gray-900: #18181b;
// Neutral semantic aliases
$color-bg: $color-white;
$color-surface: $color-gray-50;
$color-border: $color-gray-200;
$color-text: $color-gray-900;
$color-text-muted: $color-gray-500;
// Breakpoints
$breakpoint-xs: 480px;
$breakpoint-sm: 640px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;
$breakpoint-2xl: 1536px;
// Z-index
$z-base: 1;
$z-dropdown: 1000;
$z-modal: 1300;
$z-toast: 1400;
// Motion
$duration-fast: 150ms;
$duration-normal: 250ms;
$ease: cubic-bezier(0.4, 0, 0.2, 1);

View File

@ -0,0 +1,107 @@
// ©AngelaMos | 2025
// stylelint.config.js
/** @type {import('stylelint').Config} */
export default {
extends: ['stylelint-config-standard-scss', 'stylelint-config-prettier-scss'],
rules: {
'block-no-empty': true,
'declaration-no-important': true,
'color-no-invalid-hex': true,
'property-no-unknown': true,
'selector-pseudo-class-no-unknown': [
true,
{
ignorePseudoClasses: ['global'],
},
],
'selector-class-pattern': [
'^[a-z]([a-z0-9-]+)?(__[a-z0-9]([a-z0-9-]+)?)?(--[a-z0-9]([a-z0-9-]+)?)?$|^[a-z][a-zA-Z0-9]*$',
{
message:
'Selector should be in BEM format (e.g., .block__element--modifier) or CSS Modules camelCase (e.g., .testButton)',
},
],
'value-keyword-case': [
'lower',
{
camelCaseSvgKeywords: true,
ignoreKeywords: [
'BlinkMacSystemFont',
'SFMono-Regular',
'Menlo',
'Monaco',
'Consolas',
'Roboto',
'Arial',
'Helvetica',
'Times',
'Georgia',
'Verdana',
'Tahoma',
'Trebuchet',
'Impact',
'Comic',
],
},
],
'property-no-vendor-prefix': [
true,
{
ignoreProperties: ['text-size-adjust', 'appearance', 'backdrop-filter'],
},
],
'value-no-vendor-prefix': true,
'selector-no-vendor-prefix': true,
'property-no-deprecated': [
true,
{
ignoreProperties: ['clip'],
},
],
'container-name-pattern': null,
'layer-name-pattern': null,
'scss/at-rule-no-unknown': true,
'scss/declaration-nested-properties-no-divided-groups': true,
'scss/dollar-variable-no-missing-interpolation': true,
'scss/dollar-variable-empty-line-before': null,
'declaration-empty-line-before': null,
'custom-property-empty-line-before': null,
'no-descending-specificity': null,
'media-feature-name-no-unknown': [
true,
{
ignoreMediaFeatureNames: ['map'],
},
],
'color-function-notation': null,
'hue-degree-notation': null,
},
ignoreFiles: [
'node_modules/**',
'dist/**',
'build/**',
'**/*.js',
'**/*.ts',
'**/*.tsx',
],
overrides: [
{
files: ['**/styles/_reset.scss', '**/styles/_fonts.scss'],
rules: {
'declaration-no-important': null,
'scss/comment-no-empty': null,
},
},
],
}

View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,70 @@
/**
* ©AngelaMos | 2026
* vite.config.ts
*/
import path from 'node:path'
import react from '@vitejs/plugin-react'
import { defineConfig, loadEnv } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, __dirname, '')
const isDev = mode === 'development'
return {
plugins: [react(), tsconfigPaths()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
css: {
preprocessorOptions: {
scss: {},
},
},
server: {
port: 5173,
host: '0.0.0.0',
proxy: {
'/api': {
target: env.VITE_API_TARGET || 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
target: 'esnext',
cssTarget: 'chrome100',
sourcemap: isDev ? true : 'hidden',
minify: 'oxc',
rollupOptions: {
output: {
manualChunks(id: string): string | undefined {
if (id.includes('node_modules')) {
if (id.includes('react-dom') || id.includes('react-router')) {
return 'vendor-react'
}
if (id.includes('@tanstack/react-query')) {
return 'vendor-query'
}
if (id.includes('zustand')) {
return 'vendor-state'
}
}
return undefined
},
},
},
},
preview: {
port: 4173,
},
}
})

View File

@ -6,22 +6,30 @@ app.py
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from rveng.api import schemas
from rveng.api.limits import (
DEFAULT_SESSION,
DEV_ORIGIN_REGEX,
MAX_BODY_BYTES,
MAX_DISASM_BYTES,
MAX_HEX_BYTES,
MAX_SESSION_LEN,
)
from rveng.api.middleware import BodySizeLimitMiddleware
from rveng.api.store import (
ChallengeStore,
InMemoryProgress,
ProgressStore,
load_store,
)
from rveng.engine import disasm, hex as hexmod, strings
from rveng.engine import cfg as cfgmod
from rveng.engine import disasm, hex as hexmod, strings, xref
from rveng.engine.challenge import Challenge, grade
from rveng.engine.discover import discover_functions
from rveng.engine.elf import ElfImage, NotAnElf
from rveng.engine.plt import plt_map
CHALLENGES_ROOT = Path(__file__).resolve().parents[3] / "challenges"
@ -35,6 +43,13 @@ def create_app(
store = store or load_store(CHALLENGES_ROOT)
progress = progress or InMemoryProgress()
app = FastAPI(title="rveng")
app.add_middleware(BodySizeLimitMiddleware, max_bytes=MAX_BODY_BYTES)
app.add_middleware(
CORSMiddleware,
allow_origin_regex=DEV_ORIGIN_REGEX,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
def require(cid: str) -> Challenge:
challenge = store.get(cid)
@ -48,6 +63,27 @@ def create_app(
except NotAnElf as exc:
raise HTTPException(422, f"not an ELF: {exc}")
def instructions_for(
image: ElfImage,
symbol: str | None,
address: int | None) -> tuple[list[disasm.Instruction], str]:
if symbol:
sym = image.symbol(symbol)
if sym is None:
raise HTTPException(404, "symbol not found")
if sym.size > MAX_DISASM_BYTES:
raise HTTPException(413, "symbol too large to disassemble")
try:
return disasm.disassemble_symbol(image, sym), symbol
except ValueError as exc:
raise HTTPException(422, str(exc))
if address is not None:
try:
return disasm.disassemble_at(image, address), f"sub_{address:x}"
except ValueError as exc:
raise HTTPException(422, str(exc))
raise HTTPException(422, "provide a symbol or an address")
@app.get("/api/challenges")
def list_challenges() -> list[schemas.ChallengeSummary]:
return [
@ -96,26 +132,30 @@ def create_app(
schemas.FunctionView(
name=f.name, value=f.value, size=f.size)
for f in image.functions() if f.name
],
discovered=[
schemas.DiscoveredFunctionView(
address=d.address, label=d.label)
for d in discover_functions(image)
])
@app.get("/api/challenges/{cid}/disasm")
def disasm_view(
cid: str, symbol: str = Query(...)) -> schemas.DisasmView:
cid: str,
symbol: str | None = Query(None),
address: int | None = Query(None, ge=0),
session: str = Query(
DEFAULT_SESSION, max_length=MAX_SESSION_LEN)
) -> schemas.DisasmView:
c = require(cid)
image = elf_of(c)
sym = image.symbol(symbol)
if sym is None:
raise HTTPException(404, "symbol not found")
try:
instructions = disasm.disassemble_symbol(image, sym)
except ValueError as exc:
raise HTTPException(422, str(exc))
if sum(i.size for i in instructions) > MAX_DISASM_BYTES:
raise HTTPException(413, "symbol too large to disassemble")
gate = disasm.find_gate(instructions)
instructions, label = instructions_for(image, symbol, address)
imports = plt_map(image)
solved = c.id in progress.solved(session)
gate = disasm.find_gate(instructions) if solved else None
gate_addr = gate.address if gate else None
return schemas.DisasmView(
symbol=symbol,
symbol=label,
gate_address=gate_addr,
instructions=[
schemas.InstructionView(
@ -123,10 +163,49 @@ def create_app(
op_str=i.op_str, bytes=i.raw.hex(),
immediate=i.immediate,
branch_target=i.branch_target,
rip_target=i.rip_target,
call_name=(imports.get(i.branch_target)
if i.mnemonic == "call" else None),
is_gate=(i.address == gate_addr))
for i in instructions
])
@app.get("/api/challenges/{cid}/cfg")
def cfg_view(
cid: str,
symbol: str | None = Query(None),
address: int | None = Query(None, ge=0)) -> schemas.CfgView:
c = require(cid)
image = elf_of(c)
instructions, label = instructions_for(image, symbol, address)
graph = cfgmod.build_cfg(instructions)
return schemas.CfgView(
symbol=label,
blocks=[
schemas.CfgBlockView(
start=b.start, end=b.end,
instructions=list(b.instructions))
for b in graph.blocks
],
edges=[
schemas.CfgEdgeView(src=e.src, dst=e.dst, kind=e.kind)
for e in graph.edges
])
@app.get("/api/challenges/{cid}/xrefs")
def xrefs_view(
cid: str, target: int = Query(..., ge=0)) -> schemas.XrefsView:
c = require(cid)
image = elf_of(c)
refs = xref.xrefs_to(disasm.disassemble_text(image), target)
return schemas.XrefsView(
target=target,
references=[
schemas.XrefView(
from_addr=r.from_addr, to_addr=r.to_addr, kind=r.kind)
for r in refs
])
@app.get("/api/challenges/{cid}/strings")
def strings_view(
cid: str,
@ -154,7 +233,9 @@ def create_app(
@app.get("/api/progress")
def get_progress(
session: str = Query(DEFAULT_SESSION)) -> schemas.ProgressView:
session: str = Query(
DEFAULT_SESSION,
max_length=MAX_SESSION_LEN)) -> schemas.ProgressView:
solved = progress.solved(session)
return schemas.ProgressView(
session=session,

View File

@ -6,4 +6,8 @@ limits.py
MAX_HEX_BYTES = 4096
MAX_DISASM_BYTES = 65536
MAX_ANSWER_LEN = 8192
MAX_SESSION_LEN = 128
MAX_BODY_BYTES = 65536
DEFAULT_SESSION = "local"
DEV_ORIGIN_REGEX = r"^http://(localhost|127\.0\.0\.1)(:\d+)?$"

View File

@ -0,0 +1,63 @@
"""
©AngelaMos | 2026
middleware.py
"""
import json
CONTENT_LENGTH = b"content-length"
PAYLOAD_TOO_LARGE = 413
class BodySizeLimitMiddleware:
"""
Reject request bodies larger than a byte cap before they are parsed
"""
def __init__(self, app, max_bytes: int):
self.app = app
self.max_bytes = max_bytes
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
if self._declared_length(scope) > self.max_bytes:
await self._reject(send)
return
await self.app(scope, self._bounded(receive), send)
def _declared_length(self, scope) -> int:
for key, value in scope.get("headers", []):
if key.lower() == CONTENT_LENGTH:
try:
return int(value)
except ValueError:
return 0
return 0
def _bounded(self, receive):
seen = 0
async def bounded_receive():
nonlocal seen
message = await receive()
if message["type"] == "http.request":
seen += len(message.get("body", b""))
if seen > self.max_bytes:
return {"type": "http.disconnect"}
return message
return bounded_receive
async def _reject(self, send) -> None:
body = json.dumps({"detail": "request body too large"}).encode()
await send({
"type": "http.response.start",
"status": PAYLOAD_TOO_LARGE,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode()),
],
})
await send({"type": "http.response.body", "body": body})

View File

@ -3,9 +3,9 @@
schemas.py
"""
from pydantic import BaseModel
from pydantic import BaseModel, Field
from rveng.api.limits import DEFAULT_SESSION, MAX_ANSWER_LEN
from rveng.api.limits import DEFAULT_SESSION, MAX_ANSWER_LEN, MAX_SESSION_LEN
class ChallengeSummary(BaseModel):
@ -35,6 +35,11 @@ class FunctionView(BaseModel):
size: int
class DiscoveredFunctionView(BaseModel):
address: int
label: str
class SectionView(BaseModel):
index: int
name: str
@ -51,6 +56,7 @@ class ElfView(BaseModel):
entry: int
sections: list[SectionView]
functions: list[FunctionView]
discovered: list[DiscoveredFunctionView]
class InstructionView(BaseModel):
@ -60,6 +66,8 @@ class InstructionView(BaseModel):
bytes: str
immediate: int | None
branch_target: int | None
rip_target: int | None
call_name: str | None
is_gate: bool
@ -69,6 +77,35 @@ class DisasmView(BaseModel):
gate_address: int | None
class CfgBlockView(BaseModel):
start: int
end: int
instructions: list[int]
class CfgEdgeView(BaseModel):
src: int
dst: int
kind: str
class CfgView(BaseModel):
symbol: str
blocks: list[CfgBlockView]
edges: list[CfgEdgeView]
class XrefView(BaseModel):
from_addr: int
to_addr: int
kind: str
class XrefsView(BaseModel):
target: int
references: list[XrefView]
class StringView(BaseModel):
offset: int
text: str
@ -80,7 +117,7 @@ class StringsView(BaseModel):
class SubmitRequest(BaseModel):
answer: str
session: str = DEFAULT_SESSION
session: str = Field(DEFAULT_SESSION, max_length=MAX_SESSION_LEN)
def within_limits(self) -> bool:
return len(self.answer) <= MAX_ANSWER_LEN

View File

@ -4,6 +4,7 @@ store.py
"""
import json
import logging
from pathlib import Path
from typing import Protocol
@ -15,6 +16,8 @@ from rveng.engine.challenge import (
PatchedBytes,
)
log = logging.getLogger(__name__)
MANIFEST = "challenge.json"
TARGET = "target"
SOURCE = "source.c"
@ -32,15 +35,18 @@ class ChallengeError(ValueError):
def _build_answer(spec: dict, binary: bytes):
category = spec.get("category")
if category == CAT_FOUND_VALUE:
return FoundValue(spec["expected"])
if category == CAT_IDENTIFIED_SYMBOL:
return IdentifiedSymbol(spec["name"])
if category == CAT_PATCHED_BYTES:
offset = spec["offset"]
replacement = bytes.fromhex(spec["patch"])
known_good = patch.apply(binary, offset, replacement)
return PatchedBytes(offset=offset, known_good=known_good)
try:
if category == CAT_FOUND_VALUE:
return FoundValue(spec["expected"])
if category == CAT_IDENTIFIED_SYMBOL:
return IdentifiedSymbol(spec["name"])
if category == CAT_PATCHED_BYTES:
offset = spec["offset"]
replacement = bytes.fromhex(spec["patch"])
known_good = patch.apply(binary, offset, replacement)
return PatchedBytes(offset=offset, known_good=known_good)
except (KeyError, ValueError) as exc:
raise ChallengeError(f"bad {category} answer spec: {exc}") from exc
raise ChallengeError(f"unknown answer category: {category}")
@ -51,18 +57,21 @@ def load_challenge(directory: Path) -> Challenge:
manifest_path = directory / MANIFEST
if not manifest_path.is_file():
raise ChallengeError(f"no manifest in {directory}")
manifest = json.loads(manifest_path.read_text())
binary = (directory / TARGET).read_bytes()
source = (directory / SOURCE).read_text()
return Challenge(
id=manifest["id"],
module=manifest["module"],
title=manifest["title"],
mission=manifest["mission"],
binary=binary,
source=source,
answer=_build_answer(manifest["answer"], binary),
)
try:
manifest = json.loads(manifest_path.read_text())
binary = (directory / TARGET).read_bytes()
source = (directory / SOURCE).read_text()
return Challenge(
id=manifest["id"],
module=manifest["module"],
title=manifest["title"],
mission=manifest["mission"],
binary=binary,
source=source,
answer=_build_answer(manifest["answer"], binary),
)
except (json.JSONDecodeError, KeyError, OSError) as exc:
raise ChallengeError(f"malformed challenge in {directory}: {exc}") from exc
class ChallengeStore:
@ -86,8 +95,12 @@ def load_store(root: Path) -> ChallengeStore:
"""
challenges = []
for directory in sorted(root.iterdir()):
if (directory / MANIFEST).is_file():
if not (directory / MANIFEST).is_file():
continue
try:
challenges.append(load_challenge(directory))
except ChallengeError as exc:
log.warning("skipping challenge: %s", exc)
return ChallengeStore(challenges)

View File

@ -0,0 +1,106 @@
"""
©AngelaMos | 2026
cfg.py
"""
from dataclasses import dataclass
from rveng.engine.disasm import Instruction
RET = "ret"
JMP = "jmp"
EDGE_TAKEN = "taken"
EDGE_FALLTHROUGH = "fallthrough"
EDGE_JUMP = "jump"
@dataclass(frozen=True)
class BasicBlock:
"""
A straight run of instructions with a single entry and single exit
"""
start: int
end: int
instructions: tuple[int, ...]
@dataclass(frozen=True)
class Edge:
"""
A control-flow edge between two basic blocks, keyed by their starts
"""
src: int
dst: int
kind: str
@dataclass(frozen=True)
class ControlFlowGraph:
"""
The basic blocks and edges of a single function
"""
blocks: list[BasicBlock]
edges: list[Edge]
def _is_terminator(ins: Instruction) -> bool:
return (ins.is_conditional_branch
or ins.mnemonic == JMP
or ins.mnemonic == RET)
def _leaders(instructions: list[Instruction], within: set[int]) -> list[int]:
addrs = [i.address for i in instructions]
leaders = {addrs[0]}
for idx, ins in enumerate(instructions):
if not _is_terminator(ins):
continue
if idx + 1 < len(instructions):
leaders.add(addrs[idx + 1])
if ins.branch_target is not None and ins.branch_target in within:
leaders.add(ins.branch_target)
return sorted(leaders)
def build_cfg(instructions: list[Instruction]) -> ControlFlowGraph:
"""
Split a function's instructions into basic blocks and connect them
"""
if not instructions:
return ControlFlowGraph([], [])
addrs = [i.address for i in instructions]
by_addr = {i.address: i for i in instructions}
within = set(addrs)
starts = _leaders(instructions, within)
blocks = []
for pos, start in enumerate(starts):
stop = starts[pos + 1] if pos + 1 < len(starts) else None
body = tuple(
a for a in addrs if a >= start and (stop is None or a < stop))
blocks.append(BasicBlock(start=start, end=body[-1], instructions=body))
block_starts = [b.start for b in blocks]
edges = []
for pos, block in enumerate(blocks):
last = by_addr[block.end]
following = block_starts[pos + 1] if pos + 1 < len(blocks) else None
if last.mnemonic == RET:
continue
if last.is_conditional_branch:
if last.branch_target in within:
edges.append(Edge(block.start, last.branch_target, EDGE_TAKEN))
if following is not None:
edges.append(Edge(block.start, following, EDGE_FALLTHROUGH))
elif last.mnemonic == JMP:
if last.branch_target in within:
edges.append(Edge(block.start, last.branch_target, EDGE_JUMP))
elif following is not None:
edges.append(Edge(block.start, following, EDGE_FALLTHROUGH))
return ControlFlowGraph(blocks, edges)

View File

@ -6,7 +6,7 @@ disasm.py
from dataclasses import dataclass
from capstone import CS_ARCH_X86, CS_MODE_64, CS_OPT_SYNTAX_INTEL, Cs
from capstone.x86 import X86_OP_IMM
from capstone.x86 import X86_OP_IMM, X86_OP_MEM, X86_REG_RIP
from rveng.engine.elf import ElfImage, Symbol
@ -31,6 +31,7 @@ class Instruction:
raw: bytes
immediate: int | None
branch_target: int | None = None
rip_target: int | None = None
@property
def size(self) -> int:
@ -68,6 +69,13 @@ def _immediate(ins) -> int | None:
return value
def _rip_target(ins) -> int | None:
for operand in ins.operands:
if operand.type == X86_OP_MEM and operand.mem.base == X86_REG_RIP:
return ins.address + ins.size + operand.mem.disp
return None
def disassemble(code: bytes, base: int = 0) -> list[Instruction]:
"""
Decode a byte range into annotated instructions at virtual base
@ -85,10 +93,54 @@ def disassemble(code: bytes, base: int = 0) -> list[Instruction]:
raw=bytes(ins.bytes),
immediate=None if flow else imm,
branch_target=imm if flow else None,
rip_target=_rip_target(ins),
))
return out
def disassemble_text(image: ElfImage) -> list[Instruction]:
"""
Disassemble the whole .text section for whole-binary analysis
"""
section = image.section(".text")
if section is None:
return []
code = image.data[section.offset:section.offset + section.size]
return disassemble(code, section.addr)
RET = "ret"
def _section_at(image: ElfImage, vaddr: int):
for section in image.sections:
if section.addr == 0 or section.is_nobits:
continue
if section.addr <= vaddr < section.addr + section.size:
return section
return None
def disassemble_at(
image: ElfImage,
vaddr: int,
max_bytes: int = 4096) -> list[Instruction]:
"""
Disassemble from a raw virtual address until the first ret
"""
section = _section_at(image, vaddr)
if section is None:
raise ValueError("address is not in a mapped section")
start = vaddr - section.addr + section.offset
end = min(start + max_bytes, section.offset + section.size)
out = []
for ins in disassemble(image.data[start:end], vaddr):
out.append(ins)
if ins.mnemonic == RET:
break
return out
def disassemble_symbol(
image: ElfImage, symbol: Symbol) -> list[Instruction]:
"""

View File

@ -0,0 +1,41 @@
"""
©AngelaMos | 2026
discover.py
"""
from dataclasses import dataclass
from rveng.engine.elf import SHF_EXECINSTR, ElfImage
PROLOGUE = b"\x55\x48\x89\xe5"
@dataclass(frozen=True)
class DiscoveredFunction:
"""
A candidate function entry found without a symbol table
"""
address: int
section: str
@property
def label(self) -> str:
return f"sub_{self.address:x}"
def discover_functions(image: ElfImage) -> list[DiscoveredFunction]:
"""
Find function entries by scanning executable sections for the prologue
"""
found = []
for section in image.sections:
if section.is_nobits or not section.flags & SHF_EXECINSTR:
continue
blob = image.data[section.offset:section.offset + section.size]
pos = blob.find(PROLOGUE)
while pos != -1:
found.append(DiscoveredFunction(
address=section.addr + pos, section=section.name))
pos = blob.find(PROLOGUE, pos + 1)
return sorted(found, key=lambda f: f.address)

View File

@ -0,0 +1,101 @@
"""
©AngelaMos | 2026
plt.py
"""
import struct
from rveng.engine.elf import ElfImage
DYNSYM = ".dynsym"
DYNSTR = ".dynstr"
RELA_PLT = ".rela.plt"
PLT = ".plt"
SYM_ENTRY = 24
SYM_NAME = 0x00
RELA_ENTRY = 24
RELA_OFFSET = 0x00
RELA_INFO = 0x08
PLT_ENTRY = 16
JMP_RIP = b"\xff\x25"
JMP_RIP_LEN = 6
def _cstring(blob: bytes, offset: int) -> str:
end = blob.find(b"\x00", offset)
if end == -1:
end = len(blob)
return blob[offset:end].decode("utf-8", "replace")
def _dynamic_names(image: ElfImage) -> list[str]:
dynsym = image.section(DYNSYM)
dynstr = image.section(DYNSTR)
if dynsym is None or dynstr is None or dynsym.entsize == 0:
return []
names = image.data[dynstr.offset:dynstr.offset + dynstr.size]
body = image.data[dynsym.offset:dynsym.offset + dynsym.size]
en = image.header.endian
out = []
for base in range(0, len(body) - SYM_ENTRY + 1, SYM_ENTRY):
name_off = struct.unpack_from(en + "I", body, base + SYM_NAME)[0]
out.append(_cstring(names, name_off))
return out
def _got_to_name(image: ElfImage, names: list[str]) -> dict[int, str]:
rela = image.section(RELA_PLT)
if rela is None or rela.entsize == 0:
return {}
body = image.data[rela.offset:rela.offset + rela.size]
en = image.header.endian
mapping = {}
for base in range(0, len(body) - RELA_ENTRY + 1, RELA_ENTRY):
r_offset = struct.unpack_from(en + "Q", body, base + RELA_OFFSET)[0]
r_info = struct.unpack_from(en + "Q", body, base + RELA_INFO)[0]
sym_index = r_info >> 32
if 0 <= sym_index < len(names):
mapping[r_offset] = names[sym_index]
return mapping
def _stub_got_slot(entry: bytes, entry_addr: int, endian: str) -> int | None:
jmp = entry.find(JMP_RIP)
if jmp == -1 or jmp + JMP_RIP_LEN > len(entry):
return None
disp = struct.unpack_from(endian + "i", entry, jmp + 2)[0]
return entry_addr + jmp + JMP_RIP_LEN + disp
def plt_map(image: ElfImage) -> dict[int, str]:
"""
Map each PLT stub address to the imported symbol it jumps to
"""
plt = image.section(PLT)
if plt is None:
return {}
got_names = _got_to_name(image, _dynamic_names(image))
if not got_names:
return {}
body = image.data[plt.offset:plt.offset + plt.size]
en = image.header.endian
result = {}
for base in range(0, len(body) - PLT_ENTRY + 1, PLT_ENTRY):
entry = body[base:base + PLT_ENTRY]
got_slot = _stub_got_slot(entry, plt.addr + base, en)
if got_slot is None:
continue
name = got_names.get(got_slot)
if name is not None:
result[plt.addr + base] = name
return result
def resolve_call(image: ElfImage, target: int) -> str | None:
"""
Return the import a call target resolves to, or None when it is local
"""
return plt_map(image).get(target)

View File

@ -0,0 +1,55 @@
"""
©AngelaMos | 2026
xref.py
"""
from dataclasses import dataclass
from rveng.engine.disasm import Instruction
CALL = "call"
KIND_CALL = "call"
KIND_BRANCH = "branch"
KIND_DATA = "data"
@dataclass(frozen=True)
class Reference:
"""
One cross reference from an instruction to a target address
"""
from_addr: int
to_addr: int
kind: str
def references(instructions: list[Instruction]) -> list[Reference]:
"""
Every control-flow and data reference the instructions emit
"""
out = []
for ins in instructions:
if ins.branch_target is not None:
kind = KIND_CALL if ins.mnemonic == CALL else KIND_BRANCH
out.append(Reference(ins.address, ins.branch_target, kind))
if ins.rip_target is not None:
out.append(Reference(ins.address, ins.rip_target, KIND_DATA))
return out
def build_xrefs(instructions: list[Instruction]) -> dict[int, list[Reference]]:
"""
Group every reference by the address it points at
"""
table: dict[int, list[Reference]] = {}
for ref in references(instructions):
table.setdefault(ref.to_addr, []).append(ref)
return table
def xrefs_to(instructions: list[Instruction], target: int) -> list[Reference]:
"""
Every reference that points at target
"""
return [ref for ref in references(instructions) if ref.to_addr == target]

View File

@ -18,3 +18,13 @@ def gate_path() -> Path:
@pytest.fixture(scope="session")
def gate_bytes(gate_path: Path) -> bytes:
return gate_path.read_bytes()
@pytest.fixture(scope="session")
def gate_stripped_path() -> Path:
return FIXTURES / "gate_stripped"
@pytest.fixture(scope="session")
def gate_stripped_bytes(gate_stripped_path: Path) -> bytes:
return gate_stripped_path.read_bytes()

Binary file not shown.

View File

@ -3,11 +3,17 @@
test_api.py
"""
import shutil
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from rveng.api.app import create_app
from rveng.api.limits import MAX_HEX_BYTES
from rveng.api.limits import MAX_HEX_BYTES, MAX_SESSION_LEN
from rveng.api.store import load_store
CHALLENGES = Path(__file__).resolve().parents[1] / "challenges"
ELF_MAGIC_LINE = (
"00000000 7f 45 4c 46 02 01 01 00 "
@ -20,11 +26,12 @@ def client() -> TestClient:
return TestClient(create_app())
def test_list_has_three_seed_challenges(client: TestClient):
def test_list_has_the_seed_challenges(client: TestClient):
body = client.get("/api/challenges").json()
ids = {c["id"] for c in body}
assert ids == {
"03-flip-the-gate", "04-name-the-function", "05-find-the-gate"}
"03-flip-the-gate", "04-name-the-function", "05-find-the-gate",
"06-stripped-gate"}
def test_detail_does_not_leak_source_or_answer(client: TestClient):
@ -58,9 +65,25 @@ def test_elf_view_reports_entry_and_functions(client: TestClient):
assert "check" in names and "main" in names
def test_disasm_marks_the_gate(client: TestClient):
def test_disasm_hides_gate_before_solve(client: TestClient):
body = client.get(
"/api/challenges/05-find-the-gate/disasm?symbol=check").json()
"/api/challenges/05-find-the-gate/disasm"
"?symbol=check&session=fresh").json()
assert body["gate_address"] is None
assert all(i["is_gate"] is False for i in body["instructions"])
cmp_ins = next(
i for i in body["instructions"] if i["mnemonic"] == "cmp")
assert "0x539" in cmp_ins["op_str"]
def test_disasm_reveals_gate_after_solve(client: TestClient):
session = "solver"
client.post(
"/api/challenges/05-find-the-gate/submit",
json={"answer": "1337", "session": session})
body = client.get(
"/api/challenges/05-find-the-gate/disasm"
f"?symbol=check&session={session}").json()
assert body["gate_address"] == 0x40114D
gate = next(i for i in body["instructions"] if i["is_gate"])
assert gate["mnemonic"] == "cmp"
@ -129,4 +152,95 @@ def test_progress_tracks_solves(client: TestClient):
json={"answer": "1337", "session": "s1"})
body = client.get("/api/progress?session=s1").json()
assert body["solved"] == ["05-find-the-gate"]
assert body["total"] == 3
assert body["total"] == 4
def test_disasm_resolves_import_call_names(client: TestClient):
body = client.get(
"/api/challenges/05-find-the-gate/disasm?symbol=main").json()
names = {i["call_name"] for i in body["instructions"] if i["call_name"]}
assert "atoi" in names and "puts" in names
def test_disasm_exposes_rip_target(client: TestClient):
body = client.get(
"/api/challenges/05-find-the-gate/disasm?symbol=main").json()
targets = {i["rip_target"] for i in body["instructions"]}
assert 0x402004 in targets
def test_elf_lists_discovered_functions(client: TestClient):
body = client.get("/api/challenges/05-find-the-gate/elf").json()
labels = {d["label"] for d in body["discovered"]}
assert "sub_401146" in labels
def test_cfg_of_check_is_a_diamond(client: TestClient):
body = client.get(
"/api/challenges/05-find-the-gate/cfg?symbol=check").json()
assert len(body["blocks"]) == 4
assert len(body["edges"]) == 4
def test_xrefs_find_caller_of_check(client: TestClient):
body = client.get(
"/api/challenges/05-find-the-gate/xrefs?target=4198726").json()
assert any(
r["from_addr"] == 0x4011A6 and r["kind"] == "call"
for r in body["references"])
def test_stripped_challenge_disassembles_by_address(client: TestClient):
elf = client.get("/api/challenges/06-stripped-gate/elf").json()
assert elf["functions"] == []
assert any(d["address"] == 0x401146 for d in elf["discovered"])
dis = client.get(
"/api/challenges/06-stripped-gate/disasm?address=4198726").json()
assert dis["instructions"][-1]["mnemonic"] == "ret"
def test_stripped_challenge_grades_found_value(client: TestClient):
r = client.post(
"/api/challenges/06-stripped-gate/submit",
json={"answer": "0x539"}).json()
assert r["correct"] is True
def test_load_store_skips_malformed_dir(tmp_path: Path):
root = tmp_path / "challenges"
root.mkdir()
shutil.copytree(
CHALLENGES / "04-name-the-function",
root / "04-name-the-function")
broken = root / "99-broken"
broken.mkdir()
(broken / "challenge.json").write_text("{ not valid json")
store = load_store(root)
assert {c.id for c in store.list()} == {"04-name-the-function"}
def test_answer_over_answer_len_is_413(client: TestClient):
r = client.post(
"/api/challenges/05-find-the-gate/submit",
json={"answer": "9" * 10000})
assert r.status_code == 413
def test_body_over_size_cap_is_413(client: TestClient):
r = client.post(
"/api/challenges/05-find-the-gate/submit",
json={"answer": "9" * 100000})
assert r.status_code == 413
def test_session_over_max_len_is_422(client: TestClient):
long_session = "s" * (MAX_SESSION_LEN + 1)
r = client.get(f"/api/progress?session={long_session}")
assert r.status_code == 422
def test_cors_allows_localhost_dev_origin(client: TestClient):
r = client.get(
"/api/challenges", headers={"origin": "http://localhost:5173"})
assert r.headers.get("access-control-allow-origin") == (
"http://localhost:5173")

View File

@ -0,0 +1,45 @@
"""
©AngelaMos | 2026
test_cfg.py
"""
from rveng.engine.cfg import EDGE_FALLTHROUGH, EDGE_JUMP, EDGE_TAKEN, build_cfg
from rveng.engine.disasm import disassemble_symbol
from rveng.engine.elf import ElfImage
B0 = 0x401146
B1 = 0x401156
B2 = 0x40115D
B3 = 0x401162
def _check_cfg(gate_bytes: bytes):
image = ElfImage(gate_bytes)
symbol = image.symbol("check")
return build_cfg(disassemble_symbol(image, symbol))
def test_check_has_four_basic_blocks(gate_bytes: bytes):
cfg = _check_cfg(gate_bytes)
assert [b.start for b in cfg.blocks] == [B0, B1, B2, B3]
def test_check_block_zero_ends_at_the_gate_jne(gate_bytes: bytes):
cfg = _check_cfg(gate_bytes)
assert cfg.blocks[0].end == 0x401154
def test_check_cfg_is_a_diamond(gate_bytes: bytes):
cfg = _check_cfg(gate_bytes)
edges = {(e.src, e.dst, e.kind) for e in cfg.edges}
assert edges == {
(B0, B2, EDGE_TAKEN),
(B0, B1, EDGE_FALLTHROUGH),
(B1, B3, EDGE_JUMP),
(B2, B3, EDGE_FALLTHROUGH),
}
def test_terminal_block_has_no_out_edges(gate_bytes: bytes):
cfg = _check_cfg(gate_bytes)
assert all(e.src != B3 for e in cfg.edges)

View File

@ -0,0 +1,42 @@
"""
©AngelaMos | 2026
test_discover.py
"""
from rveng.engine.disasm import disassemble_at, find_gate
from rveng.engine.discover import discover_functions
from rveng.engine.elf import ElfImage
CHECK = 0x401146
MAIN = 0x401164
START = 0x401060
def test_prologue_scan_finds_check_and_main(gate_stripped_bytes: bytes):
image = ElfImage(gate_stripped_bytes)
addrs = {f.address for f in discover_functions(image)}
assert CHECK in addrs
assert MAIN in addrs
def test_prologue_scan_skips_the_runtime_start(gate_stripped_bytes: bytes):
image = ElfImage(gate_stripped_bytes)
addrs = {f.address for f in discover_functions(image)}
assert START not in addrs
def test_discovered_function_has_an_address_label(gate_stripped_bytes: bytes):
image = ElfImage(gate_stripped_bytes)
functions = discover_functions(image)
check = next(f for f in functions if f.address == CHECK)
assert check.label == "sub_401146"
def test_disassemble_at_recovers_the_gate_without_symbols(
gate_stripped_bytes: bytes):
image = ElfImage(gate_stripped_bytes)
instructions = disassemble_at(image, CHECK)
assert instructions[-1].mnemonic == "ret"
gate = find_gate(instructions)
assert gate is not None
assert gate.immediate == 1337

View File

@ -0,0 +1,51 @@
"""
©AngelaMos | 2026
test_plt.py
"""
from rveng.engine.elf import ElfImage
from rveng.engine.plt import _stub_got_slot, plt_map, resolve_call
PUTS = 0x401030
PRINTF = 0x401040
ATOI = 0x401050
def test_plt_map_names_the_three_imports(gate_bytes: bytes):
image = ElfImage(gate_bytes)
assert plt_map(image) == {PUTS: "puts", PRINTF: "printf", ATOI: "atoi"}
def test_plt0_stub_is_not_treated_as_an_import(gate_bytes: bytes):
image = ElfImage(gate_bytes)
assert 0x401020 not in plt_map(image)
def test_resolve_call_maps_target_to_name(gate_bytes: bytes):
image = ElfImage(gate_bytes)
assert resolve_call(image, ATOI) == "atoi"
assert resolve_call(image, PUTS) == "puts"
def test_resolve_call_returns_none_for_local_target(gate_bytes: bytes):
image = ElfImage(gate_bytes)
assert resolve_call(image, 0x401146) is None
def test_plt_resolution_survives_stripping(gate_stripped_bytes: bytes):
image = ElfImage(gate_stripped_bytes)
assert plt_map(image) == {PUTS: "puts", PRINTF: "printf", ATOI: "atoi"}
def test_stub_decode_of_classic_entry():
entry = bytes.fromhex("ff25ca2f0000") + b"\x00" * 10
assert _stub_got_slot(entry, 0x401030, "<") == 0x404000
def test_stub_decode_tolerates_truncated_jmp_pattern():
entry = b"\x90" * 11 + b"\xff\x25\x00\x00\x00"
assert _stub_got_slot(entry, 0x401030, "<") is None
def test_stub_decode_returns_none_without_jmp():
assert _stub_got_slot(b"\x90" * 16, 0x401030, "<") is None

View File

@ -0,0 +1,40 @@
"""
©AngelaMos | 2026
test_xref.py
"""
from rveng.engine.disasm import disassemble_text
from rveng.engine.elf import ElfImage
from rveng.engine.xref import build_xrefs, xrefs_to
CHECK = 0x401146
FLAG = 0x402004
MAIN_CALLS_CHECK = 0x4011A6
MAIN_LEA_FLAG = 0x401173
def test_rip_relative_resolves_to_flag_string(gate_bytes: bytes):
image = ElfImage(gate_bytes)
by_addr = {i.address: i for i in disassemble_text(image)}
assert by_addr[MAIN_LEA_FLAG].rip_target == FLAG
def test_xref_finds_caller_of_check(gate_bytes: bytes):
image = ElfImage(gate_bytes)
refs = xrefs_to(disassemble_text(image), CHECK)
assert any(
r.from_addr == MAIN_CALLS_CHECK and r.kind == "call" for r in refs)
def test_xref_finds_data_reference_to_flag(gate_bytes: bytes):
image = ElfImage(gate_bytes)
refs = xrefs_to(disassemble_text(image), FLAG)
assert any(
r.from_addr == MAIN_LEA_FLAG and r.kind == "data" for r in refs)
def test_build_xrefs_groups_by_target(gate_bytes: bytes):
image = ElfImage(gate_bytes)
table = build_xrefs(disassemble_text(image))
assert CHECK in table
assert FLAG in table