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:
parent
44f1b5bc99
commit
01dca71765
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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.
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 |
|
|
@ -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"}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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'],
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './hooks'
|
||||
export * from './types'
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}>
|
||||
{' '}
|
||||
<{ins.call_name}>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './api.config'
|
||||
export * from './errors'
|
||||
export * from './query.config'
|
||||
|
|
@ -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,
|
||||
}),
|
||||
})
|
||||
|
|
@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// toast.module.scss
|
||||
// ===================
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './shell.ui.store'
|
||||
|
|
@ -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)
|
||||
|
|
@ -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`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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'
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// _index.scss
|
||||
// ===================
|
||||
|
||||
@forward 'tokens';
|
||||
@forward 'fonts';
|
||||
@forward 'mixins';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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+)?$"
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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]
|
||||
|
|
@ -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.
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue