Merge pull request #301 from CarterPerez-dev/project/reverse-engineering-learning

Project/reverse engineering learning
This commit is contained in:
Carter Perez 2026-07-06 10:11:15 -04:00 committed by GitHub
commit 22a2f52bd6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
153 changed files with 11771 additions and 0 deletions

View File

@ -0,0 +1,18 @@
# ©AngelaMos | 2026
# .dockerignore
.git
.venv
data
**/__pycache__
**/*.pyc
.pytest_cache
**/.pytest_cache
frontend/node_modules
frontend/dist
frontend/.vite
frontend/.biome_cache
docs/plans
docs/context

12
PROJECTS/advanced/rveng/.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
# ©AngelaMos | 2026
# .gitignore
docs/plans/
docs/context/
__pycache__/
.pytest_cache/
.venv/
*.pyc
data/

View File

@ -0,0 +1 @@
3.13

View File

@ -0,0 +1,160 @@
<!-- ©AngelaMos | 2026 -->
<!-- README.md -->
```
______ _____ ____ ____ _
/ ___/ | / / _ \/ __ \/ __ `/
/ / | |/ / __/ / / / /_/ /
/_/ |___/\___/_/ /_/\__, /
/____/
```
[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2337-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/advanced/rveng)
[![Python](https://img.shields.io/badge/Python-3.13-3776AB?style=flat&logo=python&logoColor=white)](https://www.python.org)
[![FastAPI](https://img.shields.io/badge/FastAPI-009688?style=flat&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com)
[![React](https://img.shields.io/badge/React-19-61DAFB?style=flat&logo=react&logoColor=black)](https://react.dev)
[![capstone](https://img.shields.io/badge/disassembler-capstone-6d4aff?style=flat)](https://www.capstone-engine.org)
[![No execution](https://img.shields.io/badge/binaries-never%20executed-2ea043?style=flat)](#it-never-runs-a-binary)
[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0)
> An interactive reverse-engineering learning platform. It hands you a real compiled binary, asks a concrete question about it, gives you an in-browser hex viewer, disassembler, section map, and string scanner to answer it, and grades your answer. Only when you are right does it reveal the original C source, so you connect the machine code you just read back to the code that produced it. One framework-free Python analysis engine wears three faces: a web app, a read-only HTTP API, and an embeddable library.
## Why a reverse-engineering platform
A pile of worksheets and pre-compiled binaries can walk you through reverse engineering, but it cannot check your work and it cannot become anything more. rveng keeps the solve-then-reveal loop and makes it a real system. The engine that parses ELF, drives the disassembler, resolves imports, and grades answers is the core. The web app turns the worksheet into an interactive lab: an in-browser hex viewer, live disassembly, a section map, and a gradeable challenge runner. The engine and its lesson content are decoupled from the web framework, so they embed into a larger application as a standalone reverse-engineering feature. One core, three consumers, curated content.
## It never runs a binary
The single load-bearing decision, and the reason a web app that eats binaries is safe: the backend never executes any binary. Every operation is reading and parsing bytes.
- Hex dump, ELF header parse, section walk, symbol read, and string scan are all pure byte reads.
- Disassembly is decoding, not running. capstone reads instruction bytes and returns their text form; it never transfers control to the decoded code.
- Patch challenges are graded by a static byte diff against a known-good patched target. The patched binary is never executed.
Challenge binaries are curated and pre-compiled and shipped as static assets; nobody uploads an executable to run. So there is no arbitrary-code-execution surface, no sandbox to escape, and no resource-exhaustion path through a hostile binary, because nothing is ever run. This is a hard constraint, not a preference. Any feature that would require executing a binary is out of scope until it is redesigned against this posture, most likely by moving execution to an isolated, disposable sandbox that is explicitly not the analysis backend.
## Features
**The engine**
- A hand-rolled ELF64 parser: header, section table, and symbol table read straight from raw bytes
- x86-64 disassembly via capstone in Intel syntax, annotated with comparisons, conditional branches, call targets, and RIP-relative data references
- Import resolution through the PLT, walking `.plt`, `.rela.plt`, `.dynsym`, and `.dynstr` the way the loader would, so a bare `call 0x401050` becomes `call atoi`
- Cross-references (who calls this, what data it touches) and a basic-block control-flow graph for a single function
- Function discovery in stripped binaries by scanning executable sections for the standard prologue, so a symbol-stripped binary is still navigable
**The platform**
- Six curated challenges over one sample binary, spanning the five core reverse-engineering skills plus a stripped variant
- Solve-then-reveal grading in three machine-checkable categories: found-value, identified-symbol, and patched-bytes
- Progress persisted in SQLite behind a swappable interface, so it drops into a host application's own store without touching the engine
- A React web app and a read-only FastAPI, served together for one-command self-hosting
## Quick Start
rveng self-hosts on localhost with Docker. No account, no secret, no external service, nothing to configure.
```bash
curl -fsSL https://angelamos.com/rveng/install.sh | bash
# then open http://localhost:8790
```
One command takes a fresh machine to the app built and running: it installs Docker if it is missing, builds the engine image and the React app, brings the stack up, and waits until it answers. The first thing you do is open the browser.
Already have the repo cloned? Run it straight from the project directory:
```bash
just up # build and serve on http://localhost:8790
just dev-up # hot-reload dev stack on http://localhost:8791
just test # engine tests, no server (uv run pytest -q)
just typecheck # frontend type check, no server
just down # stop
```
> [!TIP]
> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see every recipe grouped by area: `dev` (dockerized Vite hot-reload), `prod` (the self-host stack), `verify` (tests and type check), and `cleanup`.
>
> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
## Architecture
One analysis engine with three faces. The engine is framework-free Python that knows nothing about HTTP or React. A thin FastAPI layer adapts it to the web. A React app consumes that API. Progress lives behind a small interface so the whole thing embeds into a larger application without dragging a web framework along.
```
+--------------------------+
| rveng/engine/ (pure) |
| elf disasm plt xref cfg |
| hex strings patch discover|
| challenge (grading) |
+------------+--------------+
|
+-----------------+-----------------+
| |
+-----+------+ +------+------+
| HTTP API | | library |
| FastAPI | | (import it)|
+-----+------+ +-------------+
|
+-----+------+
| React app |
+------------+
```
In production, nginx serves the built frontend and proxies `/api` to the engine container, which stays a pure API and never learns to serve a SPA. Development mirrors that with nginx fronting the Vite dev server for hot reload. The two stacks use structural-literal project names (`rveng` and `rveng-dev`), so they are namespace-isolated by construction and never collide.
```
PROD (compose.yml, "rveng") DEV (dev.compose.yml, "rveng-dev")
browser :8790 browser :8791
| |
[ nginx ] serves dist [ nginx ] --> [ vite HMR ]
| /api | /api
[ api ] uvicorn [ api ] uvicorn --reload
| |
rveng_data (sqlite progress) rveng_data_dev
```
## Project Structure
```
rveng/
├── compose.yml # prod self-host: nginx + api (name: rveng)
├── dev.compose.yml # dev: nginx + vite HMR + api --reload
├── justfile # dev / prod / verify / cleanup recipes
├── install.sh # one-shot curl|bash: installs Docker, builds, runs
├── uninstall.sh # tears the stacks down and removes the cache
├── infra/
│ ├── docker/ # api.dockerfile, vite.dev, vite.prod (multistage -> nginx)
│ └── nginx/ # dev.nginx (fronts Vite HMR), prod.nginx (serves dist)
├── src/rveng/
│ ├── engine/ # the pure analysis core (no HTTP, no framework)
│ │ ├── elf.py # hand-rolled ELF64 header / sections / symbols
│ │ ├── disasm.py # capstone x86-64 decode + annotation
│ │ ├── plt.py # PLT / GOT import resolution
│ │ ├── xref.py # cross-references from decoded instructions
│ │ ├── cfg.py # basic-block control-flow graph
│ │ ├── discover.py # stripped-binary function discovery
│ │ ├── hex.py strings.py patch.py # dump, string scan, byte diff
│ │ └── challenge.py # the challenge model and solve-then-reveal grader
│ └── api/ # thin FastAPI adapter over the engine
│ ├── app.py # create_app() and every read-only route
│ ├── store.py # challenge loader + ProgressStore (in-memory / sqlite)
│ ├── schemas.py limits.py middleware.py server.py
├── challenges/ # the six curated challenges (target + source + answer)
├── frontend/ # the React face (self-contained, extractable)
└── learn/ # the teaching track
```
## Learn
This project ships a full teaching track. Read it in order, or jump to what you need.
| Doc | What it covers |
|-----|----------------|
| [`learn/00-OVERVIEW.md`](learn/00-OVERVIEW.md) | What rveng is, the no-execution posture, and a quick tour |
| [`learn/01-CONCEPTS.md`](learn/01-CONCEPTS.md) | Reverse-engineering theory: static analysis, ELF, symbols, the PLT, patching, grounded in real analysis |
| [`learn/02-ARCHITECTURE.md`](learn/02-ARCHITECTURE.md) | The one-engine-three-faces design and how a request flows, with diagrams |
| [`learn/03-IMPLEMENTATION.md`](learn/03-IMPLEMENTATION.md) | A code walkthrough of every engine module against the sample binary |
| [`learn/04-CHALLENGES.md`](learn/04-CHALLENGES.md) | The six challenges, what each teaches, and how to add your own |
## License
[AGPL 3.0](LICENSE).

View File

@ -0,0 +1,7 @@
{
"id": "01-read-the-hex",
"module": "hex-reading",
"title": "Read the hex",
"mission": "This binary hides a secret string in its read-only data. Open the hex viewer and read the ASCII column on the right to recover it. Submit the string exactly as it appears.",
"answer": { "category": "found_value", "expected": "the_flag_is_here" }
}

View File

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

Binary file not shown.

View File

@ -0,0 +1,7 @@
{
"id": "02-find-the-entry",
"module": "elf-anatomy",
"title": "Find the entry point",
"mission": "Every ELF file records the virtual address where execution begins. Read the ELF header and find the entry point (the e_entry field). Answer in hex or decimal.",
"answer": { "category": "found_value", "expected": 4198496 }
}

View File

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

Binary file not shown.

View File

@ -0,0 +1,7 @@
{
"id": "03-flip-the-gate",
"module": "patching",
"title": "Flip the gate",
"mission": "The check at file offset 0x1154 is a conditional jump (jne, bytes 75 07) that skips the unlock path. Patch those two bytes so the branch is never taken and the unlock path always runs. Submit the two replacement bytes as hex.",
"answer": { "category": "patched_bytes", "offset": 4436, "patch": "9090" }
}

View File

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

Binary file not shown.

View File

@ -0,0 +1,7 @@
{
"id": "04-name-the-function",
"module": "strings-symbols",
"title": "Name the function",
"mission": "One function in this binary decides whether the gate opens. Read the symbol table and name it.",
"answer": { "category": "identified_symbol", "name": "check" }
}

View File

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

View File

@ -0,0 +1,7 @@
{
"id": "05-find-the-gate",
"module": "disassembly",
"title": "Find the gate",
"mission": "This binary checks a number against a magic value. Disassemble the check function and find the constant it compares against. Answer in decimal or hex.",
"answer": { "category": "found_value", "expected": 1337 }
}

View File

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

Binary file not shown.

View File

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

View File

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

Binary file not shown.

View File

@ -0,0 +1,39 @@
# ©AngelaMos | 2026
# compose.yml
name: rveng
services:
api:
build:
context: .
dockerfile: infra/docker/api.dockerfile
container_name: rveng-api
expose:
- "8000"
volumes:
- rveng_data:/app/data
networks:
- app
restart: unless-stopped
nginx:
build:
context: .
dockerfile: infra/docker/vite.prod
container_name: rveng-nginx
ports:
- "${NGINX_HOST_PORT:-8790}:80"
depends_on:
api:
condition: service_healthy
networks:
- app
restart: unless-stopped
networks:
app:
driver: bridge
volumes:
rveng_data:

View File

@ -0,0 +1,61 @@
# ©AngelaMos | 2026
# dev.compose.yml
name: rveng-dev
services:
api:
build:
context: .
dockerfile: infra/docker/api.dockerfile
container_name: rveng-api-dev
command: ["uv", "run", "--no-sync", "uvicorn", "rveng.api.server:app",
"--host", "0.0.0.0", "--port", "8000", "--reload", "--reload-dir", "/app/src"]
environment:
- PYTHONDONTWRITEBYTECODE=1
volumes:
- ./src:/app/src
- ./challenges:/app/challenges
- rveng_data_dev:/app/data
networks:
- app
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: ../infra/docker/vite.dev
container_name: rveng-frontend-dev
environment:
- CI=true
- npm_config_store_dir=/pnpm-store
volumes:
- ./frontend:/app
- frontend_modules:/app/node_modules
- frontend_pnpm_store:/pnpm-store
networks:
- app
restart: unless-stopped
nginx:
image: nginx:1.27-alpine
container_name: rveng-nginx-dev
ports:
- "${NGINX_HOST_PORT:-8791}:80"
volumes:
- ./infra/nginx/dev.nginx:/etc/nginx/nginx.conf:ro
depends_on:
- api
- frontend
networks:
- app
restart: unless-stopped
networks:
app:
driver: bridge
volumes:
rveng_data_dev:
frontend_modules:
frontend_pnpm_store:

View File

@ -0,0 +1,140 @@
<!-- ©AngelaMos | 2026 -->
<!-- 01-elf-format.md -->
# ELF Format Research
Primary source: the System V Application Binary Interface and the ELF-64
Object File Format specification. Every field size and offset below is
cross-checked against a real binary compiled locally.
## Ground-truth sample
```
gate.c compiled with: gcc -no-pie -fno-stack-protector -O0 -o gate gate.c
file: ELF 64-bit LSB executable, x86-64, EXEC, dynamically linked, not stripped
```
Chosen `-no-pie` so the load addresses are fixed and readable (EXEC type, not a
PIE/DYN shared object with load-time relocation). This is the shape the curated
challenge binaries use so a learner sees stable addresses.
## ELF header (Elf64_Ehdr)
`readelf -h gate` reported, and the first 64 raw bytes confirm, this layout.
Offsets are into the file from byte 0.
| Off | Size | Field | Value in sample | Raw bytes |
|-----|------|-------|-----------------|-----------|
| 0x00 | 16 | e_ident | magic + class + data + version | `7f 45 4c 46 02 01 01 00` then padding |
| 0x10 | 2 | e_type | EXEC (2) | `02 00` |
| 0x12 | 2 | e_machine | x86-64 (62) | `3e 00` |
| 0x14 | 4 | e_version | 1 | `01 00 00 00` |
| 0x18 | 8 | e_entry | 0x401060 | `60 10 40 00 00 00 00 00` |
| 0x20 | 8 | e_phoff | 64 | `40 00 00 00 00 00 00 00` |
| 0x28 | 8 | e_shoff | 13984 (0x36a0) | `a0 36 00 00 00 00 00 00` |
| 0x30 | 4 | e_flags | 0 | `00 00 00 00` |
| 0x34 | 2 | e_ehsize | 64 | `40 00` |
| 0x36 | 2 | e_phentsize | 56 | `38 00` |
| 0x38 | 2 | e_phnum | 14 | `0e 00` |
| 0x3a | 2 | e_shentsize | 64 | `40 00` |
| 0x3c | 2 | e_shnum | 30 | `1e 00` |
| 0x3e | 2 | e_shstrndx | 29 | `1d 00` |
e_ident breakdown (the first 16 bytes):
- bytes 0-3: magic `7f 45 4c 46` = `\x7f E L F`. Every ELF starts with these.
- byte 4 (EI_CLASS): `02` = ELFCLASS64 (64-bit). `01` would be 32-bit.
- byte 5 (EI_DATA): `01` = little-endian (ELFDATA2LSB).
- byte 6 (EI_VERSION): `01`.
- bytes 7-15: OS/ABI + padding, zero here.
The raw hex cross-check is exact: the entry point bytes at file offset 0x18 are
`60 10 40 00` little-endian = 0x401060, matching both `readelf -h` and the entry
point of `.text`. The section header table offset at 0x28 is `a0 36 00 00` =
0x36a0 = 13984, matching e_shoff.
## Section header table (Elf64_Shdr)
Located at e_shoff (0x36a0), e_shnum (30) entries of e_shentsize (64) bytes
each. Section names are indices into the section named by e_shstrndx (29,
`.shstrtab`). Sections relevant to the learning modules, from `readelf -S`:
| Nr | Name | Type | Address | Offset | Size | Flags |
|----|------|------|---------|--------|------|-------|
| 11 | .init | PROGBITS | 0x401000 | 0x1000 | 0x17 | AX |
| 12 | .plt | PROGBITS | 0x401020 | 0x1020 | 0x40 | AX |
| 13 | .text | PROGBITS | 0x401060 | 0x1060 | 0x182 | AX |
| 15 | .rodata | PROGBITS | 0x402000 | 0x2000 | 0x30 | A |
| 24 | .data | PROGBITS | 0x404018 | 0x3018 | 0x10 | WA |
| 25 | .bss | NOBITS | 0x404028 | 0x3028 | 0x8 | WA |
| 27 | .symtab | SYMTAB | 0 | 0x3048 | 0x378 | |
| 28 | .strtab | STRTAB | 0 | 0x33c0 | 0x1ca | |
Key facts the engine must model:
- Flags: A = allocated into memory, X = executable, W = writable. `.text` is
AX (code), `.rodata` is A (read-only data), `.data` is WA, `.bss` is WA.
- `.bss` type is NOBITS: it occupies memory at runtime but has zero size in the
file (Size 0x8 but no file bytes). The engine must not read file bytes for a
NOBITS section.
- A section with Address 0 is not loaded into the process image (`.symtab`,
`.strtab`, `.shstrtab`). These exist only in the file.
- The entry point 0x401060 equals `.text` Address, and the symbol table shows
`_start` at 0x401060. Entry is the first instruction the loader jumps to.
## Program header table (Elf64_Phdr)
Located at e_phoff (64), e_phnum (14) entries of e_phentsize (56) bytes. These
describe segments: how the file maps into memory at load time. From
`readelf -l`, the LOAD segments and their flags:
| Offset | VirtAddr | FileSiz | Flags | Holds |
|--------|----------|---------|-------|-------|
| 0x0000 | 0x400000 | 0x568 | R | ELF header + read-only metadata |
| 0x1000 | 0x401000 | 0x1ed | R E | .init .plt .text .fini (code) |
| 0x2000 | 0x402000 | 0x14c | R | .rodata + eh_frame |
| 0x2df8 | 0x403df8 | 0x230 | RW | .data .bss and relro |
Sections are the linker/analysis view. Segments are the loader view. The engine
parses sections for the learning modules (naming, layout) and can show segments
for the elf-anatomy module to explain how file bytes become a running process.
## Symbol table (Elf64_Sym)
`.symtab` at file offset 0x3048, entry size 24 (0x18). Names index into
`.strtab`. Sample function symbols from `readelf -s`:
| Value | Size | Type | Bind | Name |
|-------|------|------|------|------|
| 0x401060 | 34 | FUNC | GLOBAL | _start |
| 0x401146 | 30 | FUNC | GLOBAL | check |
| 0x401164 | 126 | FUNC | GLOBAL | main |
Each symbol carries value (address), size, a type (FUNC/OBJECT/etc.), a binding
(LOCAL/GLOBAL/WEAK), and a section index. `check` at 0x401146 with size 30 is
the target of the strings-symbols and disassembly modules. A stripped binary
has no `.symtab`, which is why later challenges can strip symbols to force
learners into raw disassembly.
## What we hand-roll vs delegate
Hand-roll (this is the ELF-format learning surface):
- e_ident + Elf64_Ehdr field parse from raw bytes (the table above).
- Section header table walk: read e_shoff/e_shnum/e_shentsize, iterate entries,
resolve names via e_shstrndx.
Delegate to pyelftools where hand-rolling adds no pedagogical value:
- Program header details, relocation tables, dynamic section, DWARF.
pyelftools 0.33 is the installed reference and is used to cross-check the
hand-rolled parser in the M1 known-answer tests: the hand-rolled header and
section fields must equal what pyelftools reports for the same binary.
## Facts an adversarial check confirmed or corrected
- Confirmed: entry point, e_shoff, e_shnum, and all e_ident bytes match the raw
file bytes exactly (not taken from memory or a generic ELF diagram).
- Confirmed: `.bss` is NOBITS with a nonzero memory size and no file content;
the parser must special-case it.
- Corrected assumption: an EXEC (non-PIE) binary has fixed addresses; a default
`gcc` build is PIE (ET_DYN) with load-relative addresses. The challenge
binaries are compiled `-no-pie` on purpose so addresses in `readelf`,
`objdump`, and the engine all agree with what a learner sees.

View File

@ -0,0 +1,127 @@
<!-- ©AngelaMos | 2026 -->
<!-- 02-x86-64-capstone.md -->
# x86-64 Disassembly and Capstone Research
Primary sources: the Intel 64 and IA-32 Architectures Software Developer
Manuals for instruction semantics, and the Capstone engine documentation for
API usage. Cross-checked against `objdump -d -M intel` on the local sample.
## Minimum x86-64 mental model for these challenges
A learner solving the five modules needs to recognize a small instruction set,
not the whole ISA. The instructions that actually appear in the challenge
binaries:
- `mov dst, src` : copy a value. `mov dword ptr [rbp-4], edi` stores the first
argument (edi) into a local stack slot.
- `cmp a, b` : compute `a - b` and set flags, discarding the result. This is
how a program tests a value. The compared constant is the thing a learner
hunts for.
- `test a, b` : bitwise AND setting flags, commonly `test eax, eax` to check
for zero.
- the jcc family (`je`, `jne`, `jg`, `jle`, ...) : conditional jump based on the
flags a preceding `cmp`/`test` set. `jne` after `cmp x, 0x539` means "branch
if x is not 1337".
- `jmp` : unconditional jump.
- `call` / `ret` : function call and return.
- `lea` : load effective address, often used to point at a string in `.rodata`.
- `push` / `pop` : stack, seen in function prologue/epilogue with `rbp`.
Registers that show up: `rax`/`eax` (return value and scratch), `rbp`/`rsp`
(frame and stack pointers), `edi`/`esi`/`edx` (first three integer arguments in
the System V AMD64 calling convention).
Intel syntax is used throughout (`mov dst, src`, destination first) rather than
AT&T (`mov src, dst`, `%` and `$` sigils) because it is easier for a beginner
and matches `objdump -d -M intel`.
## The gate pattern
The whole disassembly module rests on one recognizable shape: a `cmp` against a
constant followed by a conditional jump. In the sample, `check(int n)` compiles
to exactly this. From `objdump -d -M intel gate`:
```
401146 <check>:
40114d: 81 7d fc 39 05 00 00 cmp DWORD PTR [rbp-0x4],0x539
401154: 75 07 jne 40115d <check+0x17>
401156: b8 01 00 00 00 mov eax,0x1
40115b: eb 05 jmp 401162 <check+0x1c>
40115d: b8 00 00 00 00 mov eax,0x0
```
`0x539` = 1337. The C source compared `n == 1337`. A learner reads the `cmp`,
converts the hex, and has the magic number. The engine's job is to surface this
`cmp` clearly and annotate that the following `jne` is the gate.
## Capstone API
Installed reference: capstone 5.0.9 (Python binding).
Construction and iteration:
```python
from capstone import Cs, CS_ARCH_X86, CS_MODE_64, CS_OPT_SYNTAX_INTEL
md = Cs(CS_ARCH_X86, CS_MODE_64)
md.syntax = CS_OPT_SYNTAX_INTEL # = 1; Intel is also the x86 default
for ins in md.disasm(code_bytes, start_vaddr):
ins.address # int, virtual address of the instruction
ins.mnemonic # str, e.g. "cmp"
ins.op_str # str, e.g. "dword ptr [rbp - 4], 0x539"
ins.bytes # bytes, the raw machine bytes of this instruction
ins.size # int, length in bytes
```
`md.disasm(code, addr)` yields instructions until it hits bytes it cannot
decode. For the engine, `code` is the `.text` bytes and `addr` is `.text`'s
virtual address, so `ins.address` lines up with `readelf`/`objdump` addresses.
## objdump vs capstone agreement (the M1 KAT)
Disassembling the exact bytes of `check()` at vaddr 0x401146 with capstone
(Intel syntax) produced, instruction for instruction:
```
0x401146: push rbp [55]
0x401147: mov rbp, rsp [4889e5]
0x40114a: mov dword ptr [rbp - 4], edi [897dfc]
0x40114d: cmp dword ptr [rbp - 4], 0x539 [817dfc39050000]
0x401154: jne 0x40115d [7507]
0x401156: mov eax, 1 [b801000000]
0x40115b: jmp 0x401162 [eb05]
0x40115d: mov eax, 0 [b800000000]
0x401162: pop rbp [5d]
0x401163: ret [c3]
```
Every address, mnemonic, operand, and byte string matches `objdump -d -M intel`.
The only differences are cosmetic formatting (capstone renders `0x539` where
older objdump may print the same value, and omits the `<check+0x17>` symbol
annotation that objdump adds from the symbol table). This byte-and-mnemonic
agreement is the known-answer test the M1 `disasm` module is verified against:
disassembling this fixed byte range must reproduce this instruction sequence.
## Annotation the engine adds
Capstone gives raw instructions. The engine adds a thin annotation layer for
teaching:
- mark `cmp`/`test` as comparisons and surface the immediate operand in both
hex and decimal (so `0x539` is shown as 1337 without the learner converting).
- mark jcc instructions as the conditional branch that acts on the preceding
comparison.
- resolve intra-function jump/call targets to a relative label
(`check+0x17`) using the function's start address, matching objdump's
readability, sourced from the symbol table when present.
## Facts an adversarial check confirmed or corrected
- Confirmed: capstone 5.0.9 with `md.syntax = 4` reproduces objdump Intel-syntax
disassembly byte-for-byte on the sample; the agreement is real, not assumed.
- Confirmed: `0x539` decodes to 1337 and originates from the source `n == 1337`.
- Corrected: `CS_OPT_SYNTAX_INTEL` is 1, not 4. The value 4 is
`CS_OPT_SYNTAX_MASM`, which happens to render these simple instructions
identically to Intel, so an early proof script using the literal 4 produced
correct-looking output by coincidence. Intel is already the x86 default in
capstone 5.0.9 (verified: default and explicit-Intel yield identical output,
AT&T differs). The engine sets `CS_OPT_SYNTAX_INTEL` by name, never a literal.

View File

@ -0,0 +1,85 @@
<!-- ©AngelaMos | 2026 -->
<!-- 03-pedagogy.md -->
# Reverse-Engineering Pedagogy Research
Source: the design of the predecessor rveng plus how the five modules actually
teach. The goal is a machine-checkable version of the same solve-then-reveal
loop, so the platform can grade a learner instead of just handing them tools.
## The solve-then-reveal loop
The predecessor's model, kept intact:
1. A module teaches one skill through a short concept note.
2. A challenge gives a compiled binary and a mission ("find the magic number").
3. The learner uses the tools (now in-browser) to solve it.
4. After solving, the original C source is revealed so the learner connects the
machine-level evidence back to the code that produced it.
Revealing source only after a correct answer is what makes the loop teach. The
learner must reach the answer from the binary, then gets to see they were right
and why. This requires the answer to be checkable before the reveal, which the
predecessor could not do (it just trusted the learner). The platform grades.
## Answer-spec categories (what the verifier must grade)
Every challenge needs a machine-checkable answer. Three categories cover all
five modules, each grounded in the sample `gate` binary:
- **found-value** : the learner locates a value in the binary and submits it.
Example: the magic number in `check()` is `0x539` = 1337, read from
`cmp DWORD PTR [rbp-0x4],0x539`. The verifier accepts the value in decimal or
hex, normalized before comparison. Also covers finding a string
(`the_flag_is_here` at .rodata 0x402004) or an offset.
- **identified-symbol** : the learner names a function, section, or symbol.
Example: "which function decides the outcome?" answer `check` (at 0x401146,
size 30 in `.symtab`). The verifier compares against the known symbol name,
case-insensitive.
- **patched-bytes** : the learner changes bytes to alter behavior and submits
the patched byte range. Example: flip the gate by changing the `jne` (opcode
`75`) at 0x401154 so the unlock path is always taken. The verifier diffs the
submission against a known-good patched target statically. It never runs the
result.
## Module to category mapping
| # | Module | Skill | Answer category |
|---|--------|-------|-----------------|
| 01 | hex-reading | read hex dumps, locate values | found-value |
| 02 | elf-anatomy | header, sections, entry point | found-value / identified-symbol |
| 03 | patching | change bytes, change behavior | patched-bytes |
| 04 | strings-symbols | find names and strings | found-value / identified-symbol |
| 05 | disassembly | read assembly, find the gate | found-value |
## What makes a good challenge per module
- hex-reading: a value or ASCII string is visible in the hex dump at a
findable offset. The learner practices reading offsets and the ASCII gutter.
- elf-anatomy: the answer is a header field or a section fact (entry point,
which section holds a string, how many sections). Teaches the file's skeleton.
- patching: a single conditional jump or comparison gates behavior; a
one-to-few-byte edit changes the outcome. Teaches that behavior is bytes.
- strings-symbols: a password or a function name is recoverable from `.rodata`
or `.symtab` without disassembly. Teaches the cheap wins before the hard work.
- disassembly: symbols may be stripped so the learner must read the `cmp`/jcc
gate directly. Teaches assembly as ground truth when names are gone.
## Grading rules
- found-value: normalize both sides to an integer when numeric (accept `0x539`,
`539h`, `1337`), or exact-match after trim/lowercase when a string.
- identified-symbol: exact match after trim/lowercase against the known name.
- patched-bytes: the submitted bytes, applied at the specified offset to the
original, must equal the known-good patched target under a static byte diff.
No execution, ever.
## Facts an adversarial check confirmed
- Confirmed against the real binary: the magic number is `0x539` (1337), the
deciding function is `check` at 0x401146, and the gate is the `jne` at
0x401154. These are the concrete answers the sample's challenges grade.
- The reveal-after-solve ordering is the load-bearing pedagogy: source is
withheld until a correct submission, which is only possible because every
answer is machine-checkable.

View File

@ -0,0 +1,87 @@
<!-- ©AngelaMos | 2026 -->
<!-- 04-no-execution-posture.md -->
# No-Execution Security Posture Research
The single load-bearing security decision for the platform: the backend never
executes any binary. This document enumerates every engine operation and
confirms each is read-only static analysis, and records why patch grading is a
static diff rather than a behavioral test.
## The threat that this design removes
A naive "web app that analyzes binaries" invites users to upload arbitrary
executables that the server then runs or analyzes with tools that themselves
execute code. That path requires sandboxing untrusted native code (containers,
seccomp, resource limits, escape mitigation) and is a large, permanent attack
surface. The platform sidesteps it entirely:
- Challenge binaries are curated and pre-compiled by the author and shipped as
static assets. Users do not upload binaries to run.
- Every operation the engine performs is reading and parsing bytes, not
executing them.
If nothing is ever executed, there is no arbitrary-code-execution surface, no
sandbox to escape, and no resource-exhaustion path through a hostile binary.
## Every engine operation is read-only
| Operation | What it does | Executes target? |
|-----------|--------------|------------------|
| hex dump | read bytes, format offset/hex/ascii | no |
| ELF header parse | read fixed-offset fields from bytes | no |
| section/segment walk | read the section/program header tables | no |
| symbol table read | read `.symtab`/`.strtab` bytes | no |
| string extraction | scan bytes for printable runs | no |
| disassembly | capstone decodes bytes to mnemonics | no |
| patch verify | static byte diff of two buffers | no |
Disassembly is decoding, not running: capstone reads instruction bytes and
returns their textual form. It never transfers control to the decoded code.
## Patch grading without execution
The patching module asks the learner to change bytes to alter behavior (for the
sample, flip the `jne` gate so the unlock path always runs). The tempting way to
grade this is to run the patched binary and check its output. That would mean
executing learner-influenced bytes on the server. Rejected.
Instead, each patch challenge ships a known-good patched target alongside the
original. Grading is: apply the learner's submitted bytes at the specified
offset to the original, then compare the result against the known-good patched
target with a static byte diff. Equal means correct. The patched binary is
never run. This grades the exact skill (produce the right byte edit) with zero
execution.
## Input constraints at the API boundary
These are responsibilities of the M2 route layer, not the M1 engine. The engine
grades totally (a malformed submission returns "not correct", never a crash),
but the size and range limits below are enforced where untrusted input enters,
at the API boundary, and are not yet present because the route layer does not
yet exist.
- Analysis routes accept a challenge id, never an arbitrary uploaded binary to
analyze or run. The binary analyzed is always a curated challenge asset.
- The submit route accepts an answer: a value, a name, or a patched byte range.
Submitted bytes are only ever diffed, never assembled into something executed.
- Byte inputs will be length-bounded at the route so a submission cannot be an
arbitrarily large blob. Until M2 lands this bound, the engine still never
executes the bytes; the open item is DoS size-capping, not code execution.
## The standing rule
Any future feature that would require executing a binary (running a challenge to
observe behavior, an interactive debugger, dynamic analysis) is out of scope by
default. It may be reconsidered only with a separate design that preserves this
posture, most likely by moving execution to an isolated, disposable sandbox that
is explicitly not the analysis backend. Until then: the backend reads bytes, it
does not run them.
## Facts an adversarial check confirmed
- Enumerated every engine operation; all seven are read-only byte processing.
- Confirmed capstone disassembly is pure decoding with no control transfer to
the target.
- Confirmed patch grading is achievable as a static diff against a known-good
target, so the one operation that "sounds like" it needs execution does not.

View File

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

View File

@ -0,0 +1,39 @@
<!-- ©AngelaMos | 2026 -->
<!-- INDEX.md -->
# rveng Domain Research Archive
Reverse-engineering-platform research: the ELF file format, x86-64 disassembly
via capstone, the solve-then-reveal teaching model, and the no-execution
security posture. Every field size, offset, opcode, and disassembly line is
cross-checked against a real binary compiled locally (`gate`, built with
`gcc -no-pie -fno-stack-protector -O0`), not taken from memory or a generic
diagram. Each doc carries the VERIFIED fact where an adversarial check overruled
the first pass.
Honest grounding to keep across all of these: the engine reads bytes, it never
executes them. Challenge binaries are curated and pre-compiled; all analysis is
read-only static parsing and disassembly; patch challenges are graded by static
byte diff against a known-good target. This is what makes a web app that eats
binaries safe, and it is a hard constraint, not a preference.
| File | Read when |
|---|---|
| `01-elf-format.md` | implementing `engine/elf.py`: the Elf64 header field table with raw-byte cross-check, section/segment/symbol tables, NOBITS handling, what we hand-roll vs delegate to pyelftools, why the challenges are `-no-pie` |
| `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
`0x36a0` (13984), e_shnum 30, e_ident `7f 45 4c 46 02 01 01`.
- The sample gate: `check` at `0x401146` (size 30), magic `cmp ...,0x539` = 1337
at `0x40114d`, gate `jne` at `0x401154`, secret `the_flag_is_here` at .rodata
`0x402004`.
- capstone 5.0.9 reproduces objdump Intel disassembly byte-for-byte;
`CS_OPT_SYNTAX_INTEL` is 1 (not 4, which is MASM), and Intel is already the
x86 default.
Build contract and full architecture: `../plans/rveng-design.md`.
Implementation plan: `../plans/rveng-implementation-plan.md`.

View File

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

View File

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

View File

@ -0,0 +1,26 @@
# 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
.pnpm-store
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More