Merge pull request #301 from CarterPerez-dev/project/reverse-engineering-learning
Project/reverse engineering learning
This commit is contained in:
commit
22a2f52bd6
|
|
@ -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
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .gitignore
|
||||
|
||||
docs/plans/
|
||||
docs/context/
|
||||
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
*.pyc
|
||||
|
||||
data/
|
||||
|
|
@ -0,0 +1 @@
|
|||
3.13
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
<!-- ©AngelaMos | 2026 -->
|
||||
<!-- README.md -->
|
||||
|
||||
```
|
||||
______ _____ ____ ____ _
|
||||
/ ___/ | / / _ \/ __ \/ __ `/
|
||||
/ / | |/ / __/ / / / /_/ /
|
||||
/_/ |___/\___/_/ /_/\__, /
|
||||
/____/
|
||||
```
|
||||
|
||||
[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/advanced/rveng)
|
||||
[](https://www.python.org)
|
||||
[](https://fastapi.tiangolo.com)
|
||||
[](https://react.dev)
|
||||
[](https://www.capstone-engine.org)
|
||||
[](#it-never-runs-a-binary)
|
||||
[](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).
|
||||
|
|
@ -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" }
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
int check(int n) {
|
||||
if (n == 1337) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
char *secret = "the_flag_is_here";
|
||||
int n = 0;
|
||||
if (argc > 1) {
|
||||
n = atoi(argv[1]);
|
||||
}
|
||||
if (check(n)) {
|
||||
printf("unlocked: %s\n", secret);
|
||||
} else {
|
||||
printf("wrong number\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Binary file not shown.
|
|
@ -0,0 +1,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 }
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
int check(int n) {
|
||||
if (n == 1337) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
char *secret = "the_flag_is_here";
|
||||
int n = 0;
|
||||
if (argc > 1) {
|
||||
n = atoi(argv[1]);
|
||||
}
|
||||
if (check(n)) {
|
||||
printf("unlocked: %s\n", secret);
|
||||
} else {
|
||||
printf("wrong number\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Binary file not shown.
|
|
@ -0,0 +1,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" }
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
int check(int n) {
|
||||
if (n == 1337) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
char *secret = "the_flag_is_here";
|
||||
int n = 0;
|
||||
if (argc > 1) {
|
||||
n = atoi(argv[1]);
|
||||
}
|
||||
if (check(n)) {
|
||||
printf("unlocked: %s\n", secret);
|
||||
} else {
|
||||
printf("wrong number\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Binary file not shown.
|
|
@ -0,0 +1,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" }
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
int check(int n) {
|
||||
if (n == 1337) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
char *secret = "the_flag_is_here";
|
||||
int n = 0;
|
||||
if (argc > 1) {
|
||||
n = atoi(argv[1]);
|
||||
}
|
||||
if (check(n)) {
|
||||
printf("unlocked: %s\n", secret);
|
||||
} else {
|
||||
printf("wrong number\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Binary file not shown.
|
|
@ -0,0 +1,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 }
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
int check(int n) {
|
||||
if (n == 1337) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
char *secret = "the_flag_is_here";
|
||||
int n = 0;
|
||||
if (argc > 1) {
|
||||
n = atoi(argv[1]);
|
||||
}
|
||||
if (check(n)) {
|
||||
printf("unlocked: %s\n", secret);
|
||||
} else {
|
||||
printf("wrong number\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Binary file not shown.
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"id": "06-stripped-gate",
|
||||
"module": "disassembly",
|
||||
"title": "Gate in the dark",
|
||||
"mission": "This binary is stripped: no symbol table, so there are no function names to look up. Discover the functions by their prologue, disassemble the one that checks a number, and read the constant it compares against. Answer in decimal or hex.",
|
||||
"answer": { "category": "found_value", "expected": 1337 }
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
int check(int n) {
|
||||
if (n == 1337) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
char *secret = "the_flag_is_here";
|
||||
int n = 0;
|
||||
if (argc > 1) {
|
||||
n = atoi(argv[1]);
|
||||
}
|
||||
if (check(n)) {
|
||||
printf("unlocked: %s\n", secret);
|
||||
} else {
|
||||
printf("wrong number\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Binary file not shown.
|
|
@ -0,0 +1,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:
|
||||
|
|
@ -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:
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
node_modules
|
||||
build
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env*
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
.DS_Store
|
||||
coverage
|
||||
.nyc_output
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .env.example
|
||||
|
||||
# Backend the Vite dev server proxies /api to.
|
||||
# Run the API with: uv run uvicorn rveng.api.server:app --port 8000
|
||||
VITE_API_TARGET=http://localhost:8000
|
||||
|
|
@ -0,0 +1,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?
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# ©AngelaMos | 2025
|
||||
# .stylelintignore
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Production builds
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
|
||||
# JS/TS files
|
||||
**/*.js
|
||||
**/*.jsx
|
||||
**/*.ts
|
||||
**/*.tsx
|
||||
|
||||
# Generated files
|
||||
*.min.css
|
||||
|
||||
# Error system styles - ignore from linting
|
||||
src/core/app/_toastStyles.scss
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 82,
|
||||
"lineEnding": "lf"
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"jsxQuoteStyle": "double",
|
||||
"semicolons": "asNeeded",
|
||||
"trailingCommas": "es5",
|
||||
"arrowParentheses": "always"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noExcessiveCognitiveComplexity": {
|
||||
"level": "error",
|
||||
"options": { "maxAllowedComplexity": 25 }
|
||||
},
|
||||
"noForEach": "off",
|
||||
"useLiteralKeys": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "error",
|
||||
"noUnusedImports": "error",
|
||||
"useExhaustiveDependencies": "warn",
|
||||
"useHookAtTopLevel": "error",
|
||||
"noUndeclaredVariables": "error"
|
||||
},
|
||||
"style": {
|
||||
"useImportType": "error",
|
||||
"useConst": "error",
|
||||
"useTemplate": "error",
|
||||
"useSelfClosingElements": "error",
|
||||
"useFragmentSyntax": "error",
|
||||
"noNonNullAssertion": "error",
|
||||
"useConsistentArrayType": {
|
||||
"level": "error",
|
||||
"options": { "syntax": "shorthand" }
|
||||
},
|
||||
"useNamingConvention": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "error",
|
||||
"noDebugger": "error",
|
||||
"noConsole": "warn",
|
||||
"noArrayIndexKey": "warn",
|
||||
"noAssignInExpressions": "error",
|
||||
"noDoubleEquals": "error",
|
||||
"noRedeclare": "error",
|
||||
"noVar": "error"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "error"
|
||||
},
|
||||
"a11y": {
|
||||
"useAltText": "error",
|
||||
"useAnchorContent": "error",
|
||||
"useKeyWithClickEvents": "error",
|
||||
"noStaticElementInteractions": "error",
|
||||
"useButtonType": "error",
|
||||
"useValidAnchor": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["src/main.tsx"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!--
|
||||
rveng - reverse-engineering learning platform
|
||||
Author(s): © AngelaMos, CarterPerez-dev
|
||||
-->
|
||||
<meta charset="UTF-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/x-icon"
|
||||
href="/assets/favicon.ico"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
type="image/png"
|
||||
href="/assets/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="manifest"
|
||||
href="/assets/site.webmanifest"
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; form-action 'self'; worker-src 'self' blob:;"
|
||||
/>
|
||||
<title>rveng - reverse-engineering lab</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Interactive reverse-engineering learning platform: read the binary, reach the answer, reveal the source."
|
||||
/>
|
||||
<meta
|
||||
name="author"
|
||||
content=" ©AngelaMos | 2026 | CarterPerez-dev"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/main.tsx"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"name": "rveng-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format --write .",
|
||||
"typecheck": "tsc -b",
|
||||
"lint:scss": "stylelint '**/*.scss'",
|
||||
"lint:scss:fix": "stylelint '**/*.scss' --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"axios": "^1.13.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
"react-icon": "^1.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.1.13",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.8",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"@types/node": "^24.10.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"sass": "^1.95.0",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-prettier-scss": "^1.0.0",
|
||||
"stylelint-config-standard-scss": "^16.0.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "npm:rolldown-vite@7.2.5",
|
||||
"vite-tsconfig-paths": "^5.1.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@7.2.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 454 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -0,0 +1 @@
|
|||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
// ===========================
|
||||
// ©AngelaMos | 2026
|
||||
// App.tsx
|
||||
// ===========================
|
||||
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { Toaster } from 'sonner'
|
||||
|
||||
import { queryClient } from '@/core/api'
|
||||
import { router } from '@/core/app/routers'
|
||||
import '@/core/app/toast.module.scss'
|
||||
|
||||
export default function App(): React.ReactElement {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="app">
|
||||
<RouterProvider router={router} />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
duration={2000}
|
||||
theme="dark"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: 'hsl(0, 0%, 12.2%)',
|
||||
border: '1px solid hsl(0, 0%, 18%)',
|
||||
color: 'hsl(0, 0%, 98%)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// client.ts
|
||||
// ===================
|
||||
|
||||
import { API_ENDPOINTS } from '@/config'
|
||||
import { apiClient } from '@/core/api'
|
||||
import type {
|
||||
CfgView,
|
||||
ChallengeDetail,
|
||||
ChallengeSummary,
|
||||
DisasmView,
|
||||
ElfView,
|
||||
HexView,
|
||||
ProgressView,
|
||||
StringsView,
|
||||
SubmitRequest,
|
||||
SubmitResult,
|
||||
Target,
|
||||
XrefsView,
|
||||
} from './types'
|
||||
|
||||
export async function fetchChallenges(): Promise<ChallengeSummary[]> {
|
||||
const { data } = await apiClient.get<ChallengeSummary[]>(
|
||||
API_ENDPOINTS.CHALLENGES
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchChallenge(cid: string): Promise<ChallengeDetail> {
|
||||
const { data } = await apiClient.get<ChallengeDetail>(
|
||||
API_ENDPOINTS.CHALLENGE(cid)
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchHex(
|
||||
cid: string,
|
||||
offset: number,
|
||||
length: number
|
||||
): Promise<HexView> {
|
||||
const { data } = await apiClient.get<HexView>(API_ENDPOINTS.HEX(cid), {
|
||||
params: { offset, length },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchElf(cid: string): Promise<ElfView> {
|
||||
const { data } = await apiClient.get<ElfView>(API_ENDPOINTS.ELF(cid))
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchDisasm(
|
||||
cid: string,
|
||||
target: Target,
|
||||
session: string
|
||||
): Promise<DisasmView> {
|
||||
const { data } = await apiClient.get<DisasmView>(API_ENDPOINTS.DISASM(cid), {
|
||||
params: { symbol: target.symbol, address: target.address, session },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchCfg(cid: string, target: Target): Promise<CfgView> {
|
||||
const { data } = await apiClient.get<CfgView>(API_ENDPOINTS.CFG(cid), {
|
||||
params: { symbol: target.symbol, address: target.address },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchXrefs(
|
||||
cid: string,
|
||||
target: number
|
||||
): Promise<XrefsView> {
|
||||
const { data } = await apiClient.get<XrefsView>(API_ENDPOINTS.XREFS(cid), {
|
||||
params: { target },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchStrings(
|
||||
cid: string,
|
||||
minLength: number
|
||||
): Promise<StringsView> {
|
||||
const { data } = await apiClient.get<StringsView>(API_ENDPOINTS.STRINGS(cid), {
|
||||
params: { min_length: minLength },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchProgress(session: string): Promise<ProgressView> {
|
||||
const { data } = await apiClient.get<ProgressView>(API_ENDPOINTS.PROGRESS, {
|
||||
params: { session },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function submitAnswer(
|
||||
cid: string,
|
||||
body: SubmitRequest
|
||||
): Promise<SubmitResult> {
|
||||
const { data } = await apiClient.post<SubmitResult>(
|
||||
API_ENDPOINTS.SUBMIT(cid),
|
||||
body
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { QUERY_KEYS } from '@/config'
|
||||
import { QUERY_STRATEGIES } from '@/core/api'
|
||||
import { getSessionId } from '@/lib/session'
|
||||
import {
|
||||
fetchCfg,
|
||||
fetchChallenge,
|
||||
fetchChallenges,
|
||||
fetchDisasm,
|
||||
fetchElf,
|
||||
fetchHex,
|
||||
fetchProgress,
|
||||
fetchStrings,
|
||||
fetchXrefs,
|
||||
submitAnswer,
|
||||
} from '../client'
|
||||
import type { SubmitResult, Target } from '../types'
|
||||
|
||||
export function useChallenges() {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CHALLENGES,
|
||||
queryFn: fetchChallenges,
|
||||
...QUERY_STRATEGIES.static,
|
||||
})
|
||||
}
|
||||
|
||||
export function useChallenge(cid: string) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CHALLENGE(cid),
|
||||
queryFn: () => fetchChallenge(cid),
|
||||
enabled: cid.length > 0,
|
||||
...QUERY_STRATEGIES.static,
|
||||
})
|
||||
}
|
||||
|
||||
export function useHex(cid: string, offset: number, length: number) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.HEX(cid, offset, length),
|
||||
queryFn: () => fetchHex(cid, offset, length),
|
||||
enabled: cid.length > 0,
|
||||
...QUERY_STRATEGIES.static,
|
||||
})
|
||||
}
|
||||
|
||||
export function useElf(cid: string) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.ELF(cid),
|
||||
queryFn: () => fetchElf(cid),
|
||||
enabled: cid.length > 0,
|
||||
...QUERY_STRATEGIES.static,
|
||||
})
|
||||
}
|
||||
|
||||
export function useDisasm(cid: string, target: Target | null) {
|
||||
const session = getSessionId()
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.DISASM(cid, target?.label ?? '', session),
|
||||
queryFn: () => fetchDisasm(cid, target as Target, session),
|
||||
enabled: cid.length > 0 && target !== null,
|
||||
...QUERY_STRATEGIES.standard,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCfg(cid: string, target: Target | null) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CFG(cid, target?.label ?? ''),
|
||||
queryFn: () => fetchCfg(cid, target as Target),
|
||||
enabled: cid.length > 0 && target !== null,
|
||||
...QUERY_STRATEGIES.static,
|
||||
})
|
||||
}
|
||||
|
||||
export function useXrefs(cid: string, target: number | null) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.XREFS(cid, target ?? -1),
|
||||
queryFn: () => fetchXrefs(cid, target as number),
|
||||
enabled: cid.length > 0 && target !== null,
|
||||
...QUERY_STRATEGIES.static,
|
||||
})
|
||||
}
|
||||
|
||||
export function useStrings(cid: string, minLength: number) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.STRINGS(cid, minLength),
|
||||
queryFn: () => fetchStrings(cid, minLength),
|
||||
enabled: cid.length > 0,
|
||||
...QUERY_STRATEGIES.static,
|
||||
})
|
||||
}
|
||||
|
||||
export function useProgress() {
|
||||
const session = getSessionId()
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.PROGRESS(session),
|
||||
queryFn: () => fetchProgress(session),
|
||||
...QUERY_STRATEGIES.standard,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSubmit(cid: string) {
|
||||
const queryClient = useQueryClient()
|
||||
const session = getSessionId()
|
||||
return useMutation<SubmitResult, Error, string>({
|
||||
mutationFn: (answer: string) => submitAnswer(cid, { answer, session }),
|
||||
onSuccess: (result) => {
|
||||
if (result.correct) {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.PROGRESS(session) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['challenges', cid, 'disasm'],
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './hooks'
|
||||
export * from './types'
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
import type { Category } from '@/config'
|
||||
|
||||
export interface ChallengeSummary {
|
||||
id: string
|
||||
module: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface ChallengeDetail {
|
||||
id: string
|
||||
module: string
|
||||
title: string
|
||||
mission: string
|
||||
category: Category
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface HexView {
|
||||
base: number
|
||||
length: number
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
export interface FunctionView {
|
||||
name: string
|
||||
value: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface DiscoveredFunction {
|
||||
address: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface SectionView {
|
||||
index: number
|
||||
name: string
|
||||
type: string
|
||||
addr: number
|
||||
offset: number
|
||||
size: number
|
||||
flags: string
|
||||
}
|
||||
|
||||
export interface ElfView {
|
||||
type: number
|
||||
machine: number
|
||||
entry: number
|
||||
sections: SectionView[]
|
||||
functions: FunctionView[]
|
||||
discovered: DiscoveredFunction[]
|
||||
}
|
||||
|
||||
export interface InstructionView {
|
||||
address: number
|
||||
mnemonic: string
|
||||
op_str: string
|
||||
bytes: string
|
||||
immediate: number | null
|
||||
branch_target: number | null
|
||||
rip_target: number | null
|
||||
call_name: string | null
|
||||
is_gate: boolean
|
||||
}
|
||||
|
||||
export interface DisasmView {
|
||||
symbol: string
|
||||
instructions: InstructionView[]
|
||||
gate_address: number | null
|
||||
}
|
||||
|
||||
export interface CfgBlock {
|
||||
start: number
|
||||
end: number
|
||||
instructions: number[]
|
||||
}
|
||||
|
||||
export interface CfgEdge {
|
||||
src: number
|
||||
dst: number
|
||||
kind: string
|
||||
}
|
||||
|
||||
export interface CfgView {
|
||||
symbol: string
|
||||
blocks: CfgBlock[]
|
||||
edges: CfgEdge[]
|
||||
}
|
||||
|
||||
export interface XrefView {
|
||||
from_addr: number
|
||||
to_addr: number
|
||||
kind: string
|
||||
}
|
||||
|
||||
export interface XrefsView {
|
||||
target: number
|
||||
references: XrefView[]
|
||||
}
|
||||
|
||||
export interface Target {
|
||||
label: string
|
||||
address: number
|
||||
symbol?: string
|
||||
}
|
||||
|
||||
export interface StringView {
|
||||
offset: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface StringsView {
|
||||
strings: StringView[]
|
||||
}
|
||||
|
||||
export interface SubmitRequest {
|
||||
answer: string
|
||||
session: string
|
||||
}
|
||||
|
||||
export interface SubmitResult {
|
||||
correct: boolean
|
||||
message: string
|
||||
revealed_source: string | null
|
||||
}
|
||||
|
||||
export interface ProgressView {
|
||||
session: string
|
||||
solved: string[]
|
||||
total: number
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// AnswerRunner.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../styles/tokens' as *;
|
||||
@use '../styles/fonts' as *;
|
||||
|
||||
.runner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.inputRow {
|
||||
display: flex;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: $space-2 $space-3;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-sm;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.inputRow button {
|
||||
padding: $space-2 $space-4;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $color-white;
|
||||
background: $color-gray-900;
|
||||
border: none;
|
||||
border-radius: $radius-sm;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.correct,
|
||||
.incorrect {
|
||||
padding: $space-2 $space-3;
|
||||
font-size: $font-size-sm;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.correct {
|
||||
color: $color-gray-900;
|
||||
background: $color-gray-200;
|
||||
border: 1px solid $color-gray-400;
|
||||
}
|
||||
|
||||
.incorrect {
|
||||
color: $color-gray-700;
|
||||
background: $color-gray-100;
|
||||
border: 1px solid $color-border;
|
||||
}
|
||||
|
||||
.solvedNote {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// AnswerRunner.tsx
|
||||
// ===================
|
||||
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { useSubmit } from '@/api/hooks'
|
||||
import type { SubmitResult } from '@/api/types'
|
||||
import { CATEGORY_HINT, type Category } from '@/config'
|
||||
import styles from './AnswerRunner.module.scss'
|
||||
import { SourceReveal } from './SourceReveal'
|
||||
|
||||
interface AnswerRunnerProps {
|
||||
cid: string
|
||||
category: Category
|
||||
solved: boolean
|
||||
}
|
||||
|
||||
export function AnswerRunner({
|
||||
cid,
|
||||
category,
|
||||
solved,
|
||||
}: AnswerRunnerProps): React.ReactElement {
|
||||
const [answer, setAnswer] = useState('')
|
||||
const [result, setResult] = useState<SubmitResult | null>(null)
|
||||
const submit = useSubmit(cid)
|
||||
|
||||
const onSubmit = (event: FormEvent): void => {
|
||||
event.preventDefault()
|
||||
if (answer.trim().length === 0) {
|
||||
return
|
||||
}
|
||||
submit.mutate(answer, { onSuccess: (data) => setResult(data) })
|
||||
}
|
||||
|
||||
const pending = submit.isPending
|
||||
const empty = answer.trim().length === 0
|
||||
|
||||
return (
|
||||
<form className={styles.runner} onSubmit={onSubmit}>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.hint}>{CATEGORY_HINT[category]}</span>
|
||||
<div className={styles.inputRow}>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
placeholder="Your answer"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button type="submit" disabled={pending || empty}>
|
||||
{pending ? 'Checking...' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{result && (
|
||||
<div className={result.correct ? styles.correct : styles.incorrect}>
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result?.correct && result.revealed_source && (
|
||||
<SourceReveal source={result.revealed_source} />
|
||||
)}
|
||||
|
||||
{solved && result === null && (
|
||||
<p className={styles.solvedNote}>
|
||||
Already solved. Submit again to reveal the source.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// CfgPane.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../styles/tokens' as *;
|
||||
@use '../styles/fonts' as *;
|
||||
|
||||
.cfg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.blocks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-2;
|
||||
padding: $space-3 $space-4;
|
||||
background: $color-surface;
|
||||
border: 1px solid $color-border;
|
||||
border-left: 3px solid $color-gray-700;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.blockHead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
.blockName {
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.blockRange {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.blockBody {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.edgeRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.edge {
|
||||
padding: $space-1 $space-2;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xs;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-full;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.taken {
|
||||
border-color: $color-gray-600;
|
||||
background: $color-gray-100;
|
||||
}
|
||||
|
||||
.fallthrough {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.jump {
|
||||
border-color: $color-gray-500;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: $space-4;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// CfgPane.tsx
|
||||
// ===================
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useCfg } from '@/api/hooks'
|
||||
import type { Target } from '@/api/types'
|
||||
import { formatAddr } from '@/lib/format'
|
||||
import styles from './CfgPane.module.scss'
|
||||
|
||||
interface CfgPaneProps {
|
||||
cid: string
|
||||
target: Target | null
|
||||
}
|
||||
|
||||
export function CfgPane({ cid, target }: CfgPaneProps): React.ReactElement {
|
||||
const { data, isLoading, isError } = useCfg(cid, target)
|
||||
|
||||
const outEdges = useMemo(() => {
|
||||
const map = new Map<number, { dst: number; kind: string }[]>()
|
||||
for (const edge of data?.edges ?? []) {
|
||||
const list = map.get(edge.src) ?? []
|
||||
list.push({ dst: edge.dst, kind: edge.kind })
|
||||
map.set(edge.src, list)
|
||||
}
|
||||
return map
|
||||
}, [data])
|
||||
|
||||
if (target === null) {
|
||||
return <div className={styles.state}>No function selected</div>
|
||||
}
|
||||
if (isLoading) {
|
||||
return <div className={styles.state}>Building graph...</div>
|
||||
}
|
||||
if (isError || !data) {
|
||||
return <div className={styles.state}>Failed to build graph</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.cfg}>
|
||||
<p className={styles.summary}>
|
||||
{data.blocks.length} basic blocks, {data.edges.length} edges
|
||||
</p>
|
||||
<div className={styles.blocks}>
|
||||
{data.blocks.map((block, index) => {
|
||||
const edges = outEdges.get(block.start) ?? []
|
||||
return (
|
||||
<div className={styles.block} key={block.start}>
|
||||
<div className={styles.blockHead}>
|
||||
<span className={styles.blockName}>block {index}</span>
|
||||
<span className={styles.blockRange}>
|
||||
{formatAddr(block.start)} .. {formatAddr(block.end)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.blockBody}>
|
||||
{block.instructions.length} instructions
|
||||
</div>
|
||||
<div className={styles.edgeRow}>
|
||||
{edges.length === 0 ? (
|
||||
<span className={styles.terminal}>terminal</span>
|
||||
) : (
|
||||
edges.map((edge) => (
|
||||
<span
|
||||
className={`${styles.edge} ${styles[edge.kind] ?? ''}`}
|
||||
key={`${block.start}-${edge.dst}-${edge.kind}`}
|
||||
>
|
||||
{edge.kind} to {formatAddr(edge.dst)}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// ChallengeCard.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../styles/tokens' as *;
|
||||
@use '../styles/fonts' as *;
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-2;
|
||||
padding: $space-4;
|
||||
background: $color-bg;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-md;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition:
|
||||
border-color $duration-fast $ease,
|
||||
transform $duration-fast $ease;
|
||||
|
||||
&:hover {
|
||||
border-color: $color-gray-400;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.module {
|
||||
font-size: $font-size-xs;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.solved {
|
||||
padding: $space-1 $space-2;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $color-gray-900;
|
||||
background: $color-gray-200;
|
||||
border-radius: $radius-full;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.id {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// ChallengeCard.tsx
|
||||
// ===================
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { ChallengeSummary } from '@/api/types'
|
||||
import { ROUTES } from '@/config'
|
||||
import styles from './ChallengeCard.module.scss'
|
||||
|
||||
interface ChallengeCardProps {
|
||||
challenge: ChallengeSummary
|
||||
solved: boolean
|
||||
}
|
||||
|
||||
export function ChallengeCard({
|
||||
challenge,
|
||||
solved,
|
||||
}: ChallengeCardProps): React.ReactElement {
|
||||
return (
|
||||
<Link to={ROUTES.challenge(challenge.id)} className={styles.card}>
|
||||
<div className={styles.top}>
|
||||
<span className={styles.module}>{challenge.module}</span>
|
||||
{solved && <span className={styles.solved}>Solved</span>}
|
||||
</div>
|
||||
<h3 className={styles.title}>{challenge.title}</h3>
|
||||
<span className={styles.id}>{challenge.id}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// DisasmPane.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../styles/tokens' as *;
|
||||
@use '../styles/fonts' as *;
|
||||
|
||||
.disasm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $space-4;
|
||||
}
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
|
||||
select {
|
||||
padding: $space-1 $space-2;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-sm;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-sm;
|
||||
background: $color-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.gateBanner {
|
||||
padding: $space-1 $space-3;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-gray-900;
|
||||
background: $color-gray-200;
|
||||
border: 1px solid $color-gray-400;
|
||||
border-radius: $radius-sm;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.listing {
|
||||
overflow-x: auto;
|
||||
padding: $space-2;
|
||||
background: $color-gray-900;
|
||||
border-radius: $radius-md;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 6rem 9rem 4rem 1fr auto;
|
||||
gap: $space-3;
|
||||
align-items: center;
|
||||
padding: $space-1 $space-2;
|
||||
white-space: pre;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.gate {
|
||||
background: $color-gray-700;
|
||||
outline: 1px solid $color-gray-400;
|
||||
}
|
||||
|
||||
.addr {
|
||||
color: $color-gray-500;
|
||||
}
|
||||
|
||||
.raw {
|
||||
color: $color-gray-600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mnemonic {
|
||||
color: $color-white;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.ops {
|
||||
color: $color-gray-300;
|
||||
}
|
||||
|
||||
.callName {
|
||||
color: $color-gray-100;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.dataRef {
|
||||
color: $color-gray-500;
|
||||
}
|
||||
|
||||
.callers {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.target {
|
||||
justify-self: end;
|
||||
padding: 0 $space-2;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xs;
|
||||
color: $color-gray-400;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.linkable {
|
||||
color: $color-gray-100;
|
||||
border-color: $color-gray-600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: $color-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: $space-4;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// DisasmPane.tsx
|
||||
// ===================
|
||||
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { useDisasm, useXrefs } from '@/api/hooks'
|
||||
import type { Target } from '@/api/types'
|
||||
import { formatAddr } from '@/lib/format'
|
||||
import styles from './DisasmPane.module.scss'
|
||||
|
||||
interface DisasmPaneProps {
|
||||
cid: string
|
||||
target: Target | null
|
||||
}
|
||||
|
||||
export function DisasmPane({ cid, target }: DisasmPaneProps): React.ReactElement {
|
||||
const { data, isLoading, isError } = useDisasm(cid, target)
|
||||
const xrefs = useXrefs(cid, target?.address ?? null)
|
||||
const rowRefs = useRef<Map<number, HTMLDivElement | null>>(new Map())
|
||||
|
||||
const addresses = useMemo(
|
||||
() => new Set((data?.instructions ?? []).map((i) => i.address)),
|
||||
[data]
|
||||
)
|
||||
|
||||
if (target === null) {
|
||||
return <div className={styles.state}>No function selected</div>
|
||||
}
|
||||
|
||||
const scrollTo = (address: number): void => {
|
||||
rowRefs.current.get(address)?.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
const callers = (xrefs.data?.references ?? []).filter((r) => r.kind === 'call')
|
||||
|
||||
return (
|
||||
<div className={styles.disasm}>
|
||||
<div className={styles.toolbar}>
|
||||
{data?.gate_address != null && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.gateBanner}
|
||||
onClick={() => scrollTo(data.gate_address as number)}
|
||||
>
|
||||
Gate at {formatAddr(data.gate_address)}
|
||||
</button>
|
||||
)}
|
||||
{callers.length > 0 && (
|
||||
<span className={styles.callers}>
|
||||
called by {callers.map((c) => formatAddr(c.from_addr)).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && <div className={styles.state}>Disassembling...</div>}
|
||||
{isError && <div className={styles.state}>Failed to disassemble</div>}
|
||||
|
||||
{data && (
|
||||
<div className={styles.listing}>
|
||||
{data.instructions.map((ins) => {
|
||||
const linkable =
|
||||
ins.branch_target != null && addresses.has(ins.branch_target)
|
||||
return (
|
||||
<div
|
||||
key={ins.address}
|
||||
ref={(el) => {
|
||||
rowRefs.current.set(ins.address, el)
|
||||
}}
|
||||
className={`${styles.row} ${ins.is_gate ? styles.gate : ''}`}
|
||||
>
|
||||
<span className={styles.addr}>{formatAddr(ins.address)}</span>
|
||||
<span className={styles.raw}>{ins.bytes}</span>
|
||||
<span className={styles.mnemonic}>{ins.mnemonic}</span>
|
||||
<span className={styles.ops}>
|
||||
{ins.op_str}
|
||||
{ins.call_name && (
|
||||
<span className={styles.callName}>
|
||||
{' '}
|
||||
<{ins.call_name}>
|
||||
</span>
|
||||
)}
|
||||
{ins.rip_target != null && (
|
||||
<span className={styles.dataRef}>
|
||||
{' '}
|
||||
# {formatAddr(ins.rip_target)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{ins.branch_target != null && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.target} ${linkable ? styles.linkable : ''}`}
|
||||
onClick={() =>
|
||||
linkable && scrollTo(ins.branch_target as number)
|
||||
}
|
||||
disabled={!linkable}
|
||||
>
|
||||
to {formatAddr(ins.branch_target)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// HexViewer.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../styles/tokens' as *;
|
||||
@use '../styles/fonts' as *;
|
||||
|
||||
.hex {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $space-4;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
gap: $space-2;
|
||||
|
||||
button {
|
||||
padding: $space-1 $space-3;
|
||||
font-size: $font-size-sm;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-sm;
|
||||
background: $color-surface;
|
||||
color: $color-text;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.range {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.total {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.jump {
|
||||
display: flex;
|
||||
gap: $space-2;
|
||||
margin-left: auto;
|
||||
|
||||
input {
|
||||
width: 12rem;
|
||||
padding: $space-1 $space-2;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-sm;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: $space-1 $space-3;
|
||||
font-size: $font-size-sm;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-sm;
|
||||
background: $color-surface;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.dump {
|
||||
overflow-x: auto;
|
||||
padding: $space-3;
|
||||
background: $color-gray-900;
|
||||
border-radius: $radius-md;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-sm;
|
||||
line-height: $line-height-normal;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: $space-4;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
color: $color-gray-500;
|
||||
}
|
||||
|
||||
.bytes {
|
||||
color: $color-gray-100;
|
||||
}
|
||||
|
||||
.ascii {
|
||||
color: $color-gray-400;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: $space-4;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// HexViewer.tsx
|
||||
// ===================
|
||||
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { useHex } from '@/api/hooks'
|
||||
import { HEX } from '@/config'
|
||||
import { formatOffset } from '@/lib/format'
|
||||
import styles from './HexViewer.module.scss'
|
||||
|
||||
interface HexRow {
|
||||
offset: string
|
||||
bytes: string
|
||||
ascii: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
const LINE_RE = /^([0-9a-f]{8}) {2}(.+?) {2}\|(.*)\|$/
|
||||
|
||||
function parseLine(line: string): HexRow {
|
||||
const match = LINE_RE.exec(line)
|
||||
if (match === null) {
|
||||
return { offset: '', bytes: '', ascii: '', raw: line }
|
||||
}
|
||||
return { offset: match[1], bytes: match[2], ascii: match[3], raw: line }
|
||||
}
|
||||
|
||||
function parseTarget(text: string): number | null {
|
||||
const token = text.trim().toLowerCase()
|
||||
if (token.length === 0) {
|
||||
return null
|
||||
}
|
||||
const value = token.startsWith('0x')
|
||||
? Number.parseInt(token.slice(2), 16)
|
||||
: Number.parseInt(token, 10)
|
||||
return Number.isNaN(value) || value < 0 ? null : value
|
||||
}
|
||||
|
||||
interface HexViewerProps {
|
||||
cid: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export function HexViewer({ cid, size }: HexViewerProps): React.ReactElement {
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [jump, setJump] = useState('')
|
||||
const length = HEX.PAGE_LENGTH
|
||||
const { data, isLoading, isError } = useHex(cid, offset, length)
|
||||
|
||||
const canPrev = offset > 0
|
||||
const canNext = offset + length < size
|
||||
|
||||
const gotoPrev = (): void => setOffset(Math.max(0, offset - length))
|
||||
const gotoNext = (): void => {
|
||||
if (canNext) {
|
||||
setOffset(offset + length)
|
||||
}
|
||||
}
|
||||
|
||||
const onJump = (event: FormEvent): void => {
|
||||
event.preventDefault()
|
||||
const target = parseTarget(jump)
|
||||
if (target === null) {
|
||||
return
|
||||
}
|
||||
const aligned = Math.min(target, Math.max(0, size - 1))
|
||||
setOffset(aligned - (aligned % HEX.BYTES_PER_LINE))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.hex}>
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.pager}>
|
||||
<button type="button" onClick={gotoPrev} disabled={!canPrev}>
|
||||
Prev
|
||||
</button>
|
||||
<button type="button" onClick={gotoNext} disabled={!canNext}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<span className={styles.range}>
|
||||
{data
|
||||
? `${formatOffset(data.base)} .. ${formatOffset(data.base + data.length)}`
|
||||
: formatOffset(offset)}
|
||||
<span className={styles.total}> of {size} bytes</span>
|
||||
</span>
|
||||
<form className={styles.jump} onSubmit={onJump}>
|
||||
<input
|
||||
type="text"
|
||||
value={jump}
|
||||
onChange={(e) => setJump(e.target.value)}
|
||||
placeholder="offset e.g. 0x2000"
|
||||
aria-label="Jump to offset"
|
||||
/>
|
||||
<button type="submit">Go</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className={styles.state}>Loading hex...</div>}
|
||||
{isError && <div className={styles.state}>Failed to load hex</div>}
|
||||
|
||||
{data && (
|
||||
<div className={styles.dump}>
|
||||
{data.lines.map(parseLine).map((row) => (
|
||||
<div className={styles.row} key={row.offset || row.raw}>
|
||||
{row.offset ? (
|
||||
<>
|
||||
<span className={styles.gutter}>{row.offset}</span>
|
||||
<span className={styles.bytes}>{row.bytes}</span>
|
||||
<span className={styles.ascii}>{row.ascii}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.bytes}>{row.raw}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// MissionPanel.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../styles/tokens' as *;
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
padding: $space-5;
|
||||
background: $color-surface;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.module {
|
||||
font-size: $font-size-xs;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.solved {
|
||||
padding: $space-1 $space-2;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $color-gray-900;
|
||||
background: $color-gray-200;
|
||||
border-radius: $radius-full;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.mission {
|
||||
color: $color-text;
|
||||
line-height: $line-height-relaxed;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: $space-3;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.category {
|
||||
padding: $space-1 $space-2;
|
||||
color: $color-text;
|
||||
background: $color-gray-100;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.size {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// MissionPanel.tsx
|
||||
// ===================
|
||||
|
||||
import type { ChallengeDetail } from '@/api/types'
|
||||
import { CATEGORY_LABEL } from '@/config'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import styles from './MissionPanel.module.scss'
|
||||
|
||||
interface MissionPanelProps {
|
||||
challenge: ChallengeDetail
|
||||
solved: boolean
|
||||
}
|
||||
|
||||
export function MissionPanel({
|
||||
challenge,
|
||||
solved,
|
||||
}: MissionPanelProps): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.head}>
|
||||
<span className={styles.module}>{challenge.module}</span>
|
||||
{solved && <span className={styles.solved}>Solved</span>}
|
||||
</div>
|
||||
<h2 className={styles.title}>{challenge.title}</h2>
|
||||
<p className={styles.mission}>{challenge.mission}</p>
|
||||
<div className={styles.meta}>
|
||||
<span className={styles.category}>
|
||||
{CATEGORY_LABEL[challenge.category]}
|
||||
</span>
|
||||
<span className={styles.size}>{formatBytes(challenge.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// ProgressBar.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../styles/tokens' as *;
|
||||
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.track {
|
||||
height: 0.5rem;
|
||||
background: $color-gray-200;
|
||||
border-radius: $radius-full;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fill {
|
||||
height: 100%;
|
||||
background: $color-gray-700;
|
||||
border-radius: $radius-full;
|
||||
transition: width $duration-normal $ease;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// ProgressBar.tsx
|
||||
// ===================
|
||||
|
||||
import styles from './ProgressBar.module.scss'
|
||||
|
||||
interface ProgressBarProps {
|
||||
solved: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export function ProgressBar({
|
||||
solved,
|
||||
total,
|
||||
}: ProgressBarProps): React.ReactElement {
|
||||
const pct = total > 0 ? Math.round((solved / total) * 100) : 0
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.label}>
|
||||
<span>Progress</span>
|
||||
<span>
|
||||
{solved} / {total}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.track}>
|
||||
<div className={styles.fill} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// SectionMap.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../styles/tokens' as *;
|
||||
@use '../styles/fonts' as *;
|
||||
|
||||
.wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: $font-size-sm;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: $space-2 $space-3;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid $color-border;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $color-text-muted;
|
||||
background: $color-surface;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.exec {
|
||||
background: $color-gray-50;
|
||||
|
||||
.name {
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: $font-mono;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: $font-mono;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.flags {
|
||||
font-family: $font-mono;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// SectionMap.tsx
|
||||
// ===================
|
||||
|
||||
import type { SectionView } from '@/api/types'
|
||||
import { formatAddr, formatOffset } from '@/lib/format'
|
||||
import styles from './SectionMap.module.scss'
|
||||
|
||||
export function SectionMap({
|
||||
sections,
|
||||
}: {
|
||||
sections: SectionView[]
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Addr</th>
|
||||
<th>Offset</th>
|
||||
<th className={styles.num}>Size</th>
|
||||
<th>Flags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sections.map((section) => (
|
||||
<tr
|
||||
key={section.index}
|
||||
className={section.flags.includes('X') ? styles.exec : ''}
|
||||
>
|
||||
<td className={styles.dim}>{section.index}</td>
|
||||
<td className={styles.name}>{section.name || '(null)'}</td>
|
||||
<td>{section.type}</td>
|
||||
<td className={styles.mono}>{formatAddr(section.addr)}</td>
|
||||
<td className={styles.mono}>{formatOffset(section.offset)}</td>
|
||||
<td className={styles.num}>{section.size}</td>
|
||||
<td className={styles.flags}>{section.flags || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// SourceReveal.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../styles/tokens' as *;
|
||||
@use '../styles/fonts' as *;
|
||||
|
||||
.reveal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-2;
|
||||
padding: $space-4;
|
||||
background: $color-gray-900;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $color-gray-50;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-gray-400;
|
||||
}
|
||||
|
||||
.code {
|
||||
overflow-x: auto;
|
||||
padding: $space-3;
|
||||
background: $color-black;
|
||||
border-radius: $radius-sm;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-sm;
|
||||
line-height: $line-height-normal;
|
||||
color: $color-gray-100;
|
||||
white-space: pre;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// SourceReveal.tsx
|
||||
// ===================
|
||||
|
||||
import styles from './SourceReveal.module.scss'
|
||||
|
||||
export function SourceReveal({ source }: { source: string }): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.reveal}>
|
||||
<h3 className={styles.heading}>Source revealed</h3>
|
||||
<p className={styles.note}>
|
||||
You reached the answer from the binary. Here is the C that produced it.
|
||||
</p>
|
||||
<pre className={styles.code}>{source}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// StringsPane.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../styles/tokens' as *;
|
||||
@use '../styles/fonts' as *;
|
||||
|
||||
.strings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-4;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-3;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
|
||||
input {
|
||||
width: 12rem;
|
||||
}
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-left: auto;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.list {
|
||||
max-height: 28rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-md;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 8rem 1fr;
|
||||
gap: $space-4;
|
||||
padding: $space-1 $space-3;
|
||||
border-bottom: 1px solid $color-border;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.offset {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: $color-text;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: $space-4;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// StringsPane.tsx
|
||||
// ===================
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useStrings } from '@/api/hooks'
|
||||
import { STRINGS } from '@/config'
|
||||
import { formatOffset } from '@/lib/format'
|
||||
import styles from './StringsPane.module.scss'
|
||||
|
||||
export function StringsPane({ cid }: { cid: string }): React.ReactElement {
|
||||
const [minLength, setMinLength] = useState<number>(STRINGS.DEFAULT_MIN_LENGTH)
|
||||
const { data, isLoading, isError } = useStrings(cid, minLength)
|
||||
|
||||
return (
|
||||
<div className={styles.strings}>
|
||||
<div className={styles.toolbar}>
|
||||
<label className={styles.control}>
|
||||
<span>Min length: {minLength}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={STRINGS.MIN_MIN_LENGTH}
|
||||
max={STRINGS.MAX_MIN_LENGTH}
|
||||
value={minLength}
|
||||
onChange={(e) => setMinLength(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
{data && (
|
||||
<span className={styles.count}>{data.strings.length} strings</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && <div className={styles.state}>Loading strings...</div>}
|
||||
{isError && <div className={styles.state}>Failed to load strings</div>}
|
||||
|
||||
{data && (
|
||||
<div className={styles.list}>
|
||||
{data.strings.map((entry) => (
|
||||
<div className={styles.row} key={`${entry.offset}-${entry.text}`}>
|
||||
<span className={styles.offset}>{formatOffset(entry.offset)}</span>
|
||||
<span className={styles.text}>{entry.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// Workspace.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../styles/tokens' as *;
|
||||
@use '../styles/mixins' as *;
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(20rem, 24rem) 1fr;
|
||||
gap: $space-6;
|
||||
align-items: start;
|
||||
|
||||
@include breakpoint-down('lg') {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-4;
|
||||
position: sticky;
|
||||
top: $space-4;
|
||||
|
||||
@include breakpoint-down('lg') {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
.runnerCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
padding: $space-5;
|
||||
background: $color-bg;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.runnerTitle {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.analysis {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-4;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: $space-1;
|
||||
border-bottom: 1px solid $color-border;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: $space-2 $space-4;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $color-text;
|
||||
}
|
||||
}
|
||||
|
||||
.activeTab {
|
||||
color: $color-text;
|
||||
font-weight: $font-weight-medium;
|
||||
border-bottom-color: $color-gray-900;
|
||||
}
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
|
||||
select {
|
||||
padding: $space-1 $space-2;
|
||||
font-size: $font-size-sm;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-sm;
|
||||
background: $color-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.pane {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: $space-6;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// Workspace.tsx
|
||||
// ===================
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useChallenge, useElf, useProgress } from '@/api/hooks'
|
||||
import type { Target } from '@/api/types'
|
||||
import { AnswerRunner } from './AnswerRunner'
|
||||
import { CfgPane } from './CfgPane'
|
||||
import { DisasmPane } from './DisasmPane'
|
||||
import { HexViewer } from './HexViewer'
|
||||
import { MissionPanel } from './MissionPanel'
|
||||
import { SectionMap } from './SectionMap'
|
||||
import { StringsPane } from './StringsPane'
|
||||
import styles from './Workspace.module.scss'
|
||||
|
||||
const TABS = ['hex', 'disasm', 'graph', 'sections', 'strings'] as const
|
||||
type Tab = (typeof TABS)[number]
|
||||
|
||||
const TAB_LABEL: Record<Tab, string> = {
|
||||
hex: 'Hex',
|
||||
disasm: 'Disassembly',
|
||||
graph: 'Graph',
|
||||
sections: 'Sections',
|
||||
strings: 'Strings',
|
||||
}
|
||||
|
||||
const PREFERRED = ['check', 'main', '_start']
|
||||
|
||||
function pickDefault(targets: Target[]): Target | null {
|
||||
for (const name of PREFERRED) {
|
||||
const hit = targets.find((t) => t.label === name)
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
return targets[0] ?? null
|
||||
}
|
||||
|
||||
export function Workspace({ cid }: { cid: string }): React.ReactElement {
|
||||
const [tab, setTab] = useState<Tab>('hex')
|
||||
const [targetKey, setTargetKey] = useState('')
|
||||
const challenge = useChallenge(cid)
|
||||
const elf = useElf(cid)
|
||||
const progress = useProgress()
|
||||
|
||||
const targets = useMemo<Target[]>(() => {
|
||||
const named = (elf.data?.functions ?? [])
|
||||
.filter((f) => f.size > 0 && f.value > 0)
|
||||
.map((f) => ({ label: f.name, address: f.value, symbol: f.name }))
|
||||
if (named.length > 0) {
|
||||
return named
|
||||
}
|
||||
return (elf.data?.discovered ?? []).map((d) => ({
|
||||
label: d.label,
|
||||
address: d.address,
|
||||
}))
|
||||
}, [elf.data])
|
||||
|
||||
if (challenge.isLoading) {
|
||||
return <div className={styles.state}>Loading challenge...</div>
|
||||
}
|
||||
if (challenge.isError || !challenge.data) {
|
||||
return <div className={styles.state}>Challenge not found</div>
|
||||
}
|
||||
|
||||
const detail = challenge.data
|
||||
const solved = progress.data?.solved.includes(cid) ?? false
|
||||
const selected =
|
||||
targets.find((t) => t.label === targetKey) ?? pickDefault(targets)
|
||||
const needsTarget = tab === 'disasm' || tab === 'graph'
|
||||
|
||||
return (
|
||||
<div className={styles.workspace}>
|
||||
<aside className={styles.side}>
|
||||
<MissionPanel challenge={detail} solved={solved} />
|
||||
<div className={styles.runnerCard}>
|
||||
<h3 className={styles.runnerTitle}>Submit answer</h3>
|
||||
<AnswerRunner cid={cid} category={detail.category} solved={solved} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className={styles.analysis}>
|
||||
<div className={styles.tabs}>
|
||||
{TABS.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={`${styles.tab} ${tab === item ? styles.activeTab : ''}`}
|
||||
onClick={() => setTab(item)}
|
||||
>
|
||||
{TAB_LABEL[item]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{needsTarget && targets.length > 0 && (
|
||||
<label className={styles.picker}>
|
||||
<span>Function</span>
|
||||
<select
|
||||
value={selected?.label ?? ''}
|
||||
onChange={(e) => setTargetKey(e.target.value)}
|
||||
>
|
||||
{targets.map((t) => (
|
||||
<option key={t.label} value={t.label}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className={styles.pane}>
|
||||
{tab === 'hex' && <HexViewer cid={cid} size={detail.size} />}
|
||||
{tab === 'disasm' &&
|
||||
(elf.isLoading ? (
|
||||
<div className={styles.state}>Loading ELF...</div>
|
||||
) : (
|
||||
<DisasmPane cid={cid} target={selected} />
|
||||
))}
|
||||
{tab === 'graph' &&
|
||||
(elf.isLoading ? (
|
||||
<div className={styles.state}>Loading ELF...</div>
|
||||
) : (
|
||||
<CfgPane cid={cid} target={selected} />
|
||||
))}
|
||||
{tab === 'sections' &&
|
||||
(elf.isLoading ? (
|
||||
<div className={styles.state}>Loading ELF...</div>
|
||||
) : (
|
||||
<SectionMap sections={elf.data?.sections ?? []} />
|
||||
))}
|
||||
{tab === 'strings' && <StringsPane cid={cid} />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.tsx
|
||||
// ===================
|
||||
|
||||
export * from './AnswerRunner'
|
||||
export * from './CfgPane'
|
||||
export * from './ChallengeCard'
|
||||
export * from './DisasmPane'
|
||||
export * from './HexViewer'
|
||||
export * from './MissionPanel'
|
||||
export * from './ProgressBar'
|
||||
export * from './SectionMap'
|
||||
export * from './SourceReveal'
|
||||
export * from './StringsPane'
|
||||
export * from './Workspace'
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// config.ts
|
||||
// ===================
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
CHALLENGES: '/challenges',
|
||||
CHALLENGE: (cid: string) => `/challenges/${cid}`,
|
||||
HEX: (cid: string) => `/challenges/${cid}/hex`,
|
||||
ELF: (cid: string) => `/challenges/${cid}/elf`,
|
||||
DISASM: (cid: string) => `/challenges/${cid}/disasm`,
|
||||
CFG: (cid: string) => `/challenges/${cid}/cfg`,
|
||||
XREFS: (cid: string) => `/challenges/${cid}/xrefs`,
|
||||
STRINGS: (cid: string) => `/challenges/${cid}/strings`,
|
||||
SUBMIT: (cid: string) => `/challenges/${cid}/submit`,
|
||||
PROGRESS: '/progress',
|
||||
} as const
|
||||
|
||||
export const QUERY_KEYS = {
|
||||
CHALLENGES: ['challenges'] as const,
|
||||
CHALLENGE: (cid: string) => ['challenges', cid] as const,
|
||||
HEX: (cid: string, offset: number, length: number) =>
|
||||
['challenges', cid, 'hex', offset, length] as const,
|
||||
ELF: (cid: string) => ['challenges', cid, 'elf'] as const,
|
||||
DISASM: (cid: string, label: string, session: string) =>
|
||||
['challenges', cid, 'disasm', label, session] as const,
|
||||
CFG: (cid: string, label: string) => ['challenges', cid, 'cfg', label] as const,
|
||||
XREFS: (cid: string, target: number) =>
|
||||
['challenges', cid, 'xrefs', target] as const,
|
||||
STRINGS: (cid: string, minLength: number) =>
|
||||
['challenges', cid, 'strings', minLength] as const,
|
||||
PROGRESS: (session: string) => ['progress', session] as const,
|
||||
} as const
|
||||
|
||||
export const ROUTES = {
|
||||
HOME: '/',
|
||||
CHALLENGE: '/c/:cid',
|
||||
challenge: (cid: string) => `/c/${cid}`,
|
||||
} as const
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
UI: 'rveng-ui',
|
||||
SESSION: 'rveng-session',
|
||||
} as const
|
||||
|
||||
export const CATEGORY = {
|
||||
FOUND_VALUE: 'found_value',
|
||||
IDENTIFIED_SYMBOL: 'identified_symbol',
|
||||
PATCHED_BYTES: 'patched_bytes',
|
||||
} as const
|
||||
|
||||
export type Category = (typeof CATEGORY)[keyof typeof CATEGORY]
|
||||
|
||||
export const CATEGORY_LABEL: Record<Category, string> = {
|
||||
[CATEGORY.FOUND_VALUE]: 'Find a value',
|
||||
[CATEGORY.IDENTIFIED_SYMBOL]: 'Name a symbol',
|
||||
[CATEGORY.PATCHED_BYTES]: 'Patch bytes',
|
||||
}
|
||||
|
||||
export const CATEGORY_HINT: Record<Category, string> = {
|
||||
[CATEGORY.FOUND_VALUE]: 'Submit the value in decimal or hex (0x...)',
|
||||
[CATEGORY.IDENTIFIED_SYMBOL]: 'Submit the exact symbol name',
|
||||
[CATEGORY.PATCHED_BYTES]: 'Submit the replacement bytes as hex (e.g. 9090)',
|
||||
}
|
||||
|
||||
export const HEX = {
|
||||
DEFAULT_LENGTH: 256,
|
||||
PAGE_LENGTH: 256,
|
||||
MAX_LENGTH: 4096,
|
||||
BYTES_PER_LINE: 16,
|
||||
} as const
|
||||
|
||||
export const STRINGS = {
|
||||
DEFAULT_MIN_LENGTH: 4,
|
||||
MIN_MIN_LENGTH: 1,
|
||||
MAX_MIN_LENGTH: 64,
|
||||
} as const
|
||||
|
||||
export const QUERY_CONFIG = {
|
||||
STALE_TIME: {
|
||||
STATIC: Number.POSITIVE_INFINITY,
|
||||
DEFAULT: 1000 * 60 * 5,
|
||||
FREQUENT: 1000 * 30,
|
||||
},
|
||||
GC_TIME: {
|
||||
DEFAULT: 1000 * 60 * 30,
|
||||
LONG: 1000 * 60 * 60,
|
||||
},
|
||||
RETRY: {
|
||||
DEFAULT: 2,
|
||||
NONE: 0,
|
||||
},
|
||||
} as const
|
||||
|
||||
export const HTTP_STATUS = {
|
||||
OK: 200,
|
||||
BAD_REQUEST: 400,
|
||||
NOT_FOUND: 404,
|
||||
PAYLOAD_TOO_LARGE: 413,
|
||||
UNPROCESSABLE: 422,
|
||||
INTERNAL_SERVER: 500,
|
||||
} as const
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// api.config.ts
|
||||
// ===================
|
||||
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
import { transformAxiosError } from './errors'
|
||||
|
||||
const getBaseURL = (): string => {
|
||||
return import.meta.env.VITE_API_URL ?? '/api'
|
||||
}
|
||||
|
||||
export const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: getBaseURL(),
|
||||
timeout: 15000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => Promise.reject(transformAxiosError(error))
|
||||
)
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* ©AngelaMos | 2025
|
||||
* errors.ts
|
||||
*/
|
||||
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
export const ApiErrorCode = {
|
||||
NETWORK_ERROR: 'NETWORK_ERROR',
|
||||
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||
AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR',
|
||||
AUTHORIZATION_ERROR: 'AUTHORIZATION_ERROR',
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
CONFLICT: 'CONFLICT',
|
||||
RATE_LIMITED: 'RATE_LIMITED',
|
||||
SERVER_ERROR: 'SERVER_ERROR',
|
||||
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
|
||||
} as const
|
||||
|
||||
export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode]
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly code: ApiErrorCode
|
||||
readonly statusCode: number
|
||||
readonly details?: Record<string, string[]>
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: ApiErrorCode,
|
||||
statusCode: number,
|
||||
details?: Record<string, string[]>
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.code = code
|
||||
this.statusCode = statusCode
|
||||
this.details = details
|
||||
}
|
||||
|
||||
getUserMessage(): string {
|
||||
const messages: Record<ApiErrorCode, string> = {
|
||||
[ApiErrorCode.NETWORK_ERROR]:
|
||||
'Unable to connect. Please check your internet connection.',
|
||||
[ApiErrorCode.VALIDATION_ERROR]: 'Please check your input and try again.',
|
||||
[ApiErrorCode.AUTHENTICATION_ERROR]:
|
||||
'Your session has expired. Please log in again.',
|
||||
[ApiErrorCode.AUTHORIZATION_ERROR]:
|
||||
'You do not have permission to perform this action.',
|
||||
[ApiErrorCode.NOT_FOUND]: 'The requested resource was not found.',
|
||||
[ApiErrorCode.CONFLICT]:
|
||||
'This operation conflicts with an existing resource.',
|
||||
[ApiErrorCode.RATE_LIMITED]:
|
||||
'Too many requests. Please wait a moment and try again.',
|
||||
[ApiErrorCode.SERVER_ERROR]:
|
||||
'Something went wrong on our end. Please try again later.',
|
||||
[ApiErrorCode.UNKNOWN_ERROR]:
|
||||
'An unexpected error occurred. Please try again.',
|
||||
}
|
||||
return messages[this.code]
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiErrorResponse {
|
||||
detail?: string | { msg: string; type: string }[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function transformAxiosError(error: AxiosError<unknown>): ApiError {
|
||||
if (!error.response) {
|
||||
return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0)
|
||||
}
|
||||
|
||||
const { status } = error.response
|
||||
const data = error.response.data as ApiErrorResponse | undefined
|
||||
let message = 'An error occurred'
|
||||
let details: Record<string, string[]> | undefined
|
||||
|
||||
if (data?.detail) {
|
||||
if (typeof data.detail === 'string') {
|
||||
message = data.detail
|
||||
} else if (Array.isArray(data.detail)) {
|
||||
details = { validation: [] }
|
||||
data.detail.forEach((err) => {
|
||||
details?.validation.push(err.msg)
|
||||
})
|
||||
message = 'Validation error'
|
||||
}
|
||||
} else if (data?.message) {
|
||||
message = data.message
|
||||
}
|
||||
|
||||
const codeMap: Record<number, ApiErrorCode> = {
|
||||
400: ApiErrorCode.VALIDATION_ERROR,
|
||||
401: ApiErrorCode.AUTHENTICATION_ERROR,
|
||||
403: ApiErrorCode.AUTHORIZATION_ERROR,
|
||||
404: ApiErrorCode.NOT_FOUND,
|
||||
409: ApiErrorCode.CONFLICT,
|
||||
413: ApiErrorCode.VALIDATION_ERROR,
|
||||
422: ApiErrorCode.VALIDATION_ERROR,
|
||||
429: ApiErrorCode.RATE_LIMITED,
|
||||
500: ApiErrorCode.SERVER_ERROR,
|
||||
502: ApiErrorCode.SERVER_ERROR,
|
||||
503: ApiErrorCode.SERVER_ERROR,
|
||||
504: ApiErrorCode.SERVER_ERROR,
|
||||
}
|
||||
|
||||
const code = codeMap[status] || ApiErrorCode.UNKNOWN_ERROR
|
||||
|
||||
return new ApiError(message, code, status, details)
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-query' {
|
||||
interface Register {
|
||||
defaultError: ApiError
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './api.config'
|
||||
export * from './errors'
|
||||
export * from './query.config'
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// query.config.ts
|
||||
// ===================
|
||||
|
||||
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { QUERY_CONFIG } from '@/config'
|
||||
import { ApiError, ApiErrorCode } from './errors'
|
||||
|
||||
const NO_RETRY_ERROR_CODES: readonly ApiErrorCode[] = [
|
||||
ApiErrorCode.NOT_FOUND,
|
||||
ApiErrorCode.VALIDATION_ERROR,
|
||||
] as const
|
||||
|
||||
const shouldRetryQuery = (failureCount: number, error: Error): boolean => {
|
||||
if (error instanceof ApiError) {
|
||||
if (NO_RETRY_ERROR_CODES.includes(error.code)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return failureCount < QUERY_CONFIG.RETRY.DEFAULT
|
||||
}
|
||||
|
||||
const calculateRetryDelay = (attemptIndex: number): number => {
|
||||
const baseDelay = 500
|
||||
const maxDelay = 5000
|
||||
return Math.min(baseDelay * 2 ** attemptIndex, maxDelay)
|
||||
}
|
||||
|
||||
const handleQueryCacheError = (
|
||||
error: Error,
|
||||
query: { state: { data: unknown } }
|
||||
): void => {
|
||||
if (query.state.data !== undefined) {
|
||||
const message =
|
||||
error instanceof ApiError
|
||||
? error.getUserMessage()
|
||||
: 'Background update failed'
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMutationCacheError = (
|
||||
error: Error,
|
||||
_variables: unknown,
|
||||
_context: unknown,
|
||||
mutation: { options: { onError?: unknown } }
|
||||
): void => {
|
||||
if (mutation.options.onError === undefined) {
|
||||
const message =
|
||||
error instanceof ApiError ? error.getUserMessage() : 'Operation failed'
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
export const QUERY_STRATEGIES = {
|
||||
standard: {
|
||||
staleTime: QUERY_CONFIG.STALE_TIME.DEFAULT,
|
||||
gcTime: QUERY_CONFIG.GC_TIME.DEFAULT,
|
||||
},
|
||||
static: {
|
||||
staleTime: QUERY_CONFIG.STALE_TIME.STATIC,
|
||||
gcTime: QUERY_CONFIG.GC_TIME.LONG,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
} as const
|
||||
|
||||
export type QueryStrategy = keyof typeof QUERY_STRATEGIES
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: QUERY_CONFIG.STALE_TIME.DEFAULT,
|
||||
gcTime: QUERY_CONFIG.GC_TIME.DEFAULT,
|
||||
retry: shouldRetryQuery,
|
||||
retryDelay: calculateRetryDelay,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
mutations: {
|
||||
retry: QUERY_CONFIG.RETRY.NONE,
|
||||
},
|
||||
},
|
||||
queryCache: new QueryCache({
|
||||
onError: handleQueryCacheError,
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: handleMutationCacheError,
|
||||
}),
|
||||
})
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// routers.tsx
|
||||
// ===================
|
||||
|
||||
import { createBrowserRouter } from 'react-router-dom'
|
||||
import { ROUTES } from '@/config'
|
||||
import { Shell } from './shell'
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
element: <Shell />,
|
||||
children: [
|
||||
{
|
||||
path: ROUTES.HOME,
|
||||
lazy: () => import('@/pages/browser'),
|
||||
},
|
||||
{
|
||||
path: ROUTES.CHALLENGE,
|
||||
lazy: () => import('@/pages/workspace'),
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
lazy: () => import('@/pages/browser'),
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// shell.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../../styles/tokens' as *;
|
||||
@use '../../styles/fonts' as *;
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: $space-3;
|
||||
padding: $space-4 $space-6;
|
||||
border-bottom: 1px solid $color-border;
|
||||
background: $color-bg;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $color-text;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: $space-6;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: $space-8;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
padding: $space-6;
|
||||
|
||||
pre {
|
||||
padding: $space-3;
|
||||
background: $color-gray-100;
|
||||
border-radius: $radius-sm;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-sm;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* ©AngelaMos | 2026
|
||||
* shell.tsx
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
import { Link, Outlet } from 'react-router-dom'
|
||||
import { ROUTES } from '@/config'
|
||||
import styles from './shell.module.scss'
|
||||
|
||||
function ShellErrorFallback({ error }: { error: Error }): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.error}>
|
||||
<h2>Something went wrong</h2>
|
||||
<pre>{error.message}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShellLoading(): React.ReactElement {
|
||||
return <div className={styles.loading}>Loading...</div>
|
||||
}
|
||||
|
||||
export function Shell(): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.shell}>
|
||||
<header className={styles.header}>
|
||||
<Link to={ROUTES.HOME} className={styles.brand}>
|
||||
rveng
|
||||
</Link>
|
||||
<span className={styles.tag}>reverse-engineering lab</span>
|
||||
</header>
|
||||
|
||||
<main className={styles.content}>
|
||||
<ErrorBoundary FallbackComponent={ShellErrorFallback}>
|
||||
<Suspense fallback={<ShellLoading />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// toast.module.scss
|
||||
// ===================
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './shell.ui.store'
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* ©AngelaMos | 2025
|
||||
* ui.store.ts
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
interface UIState {
|
||||
theme: Theme
|
||||
sidebarOpen: boolean
|
||||
sidebarCollapsed: boolean
|
||||
setTheme: (theme: Theme) => void
|
||||
toggleSidebar: () => void
|
||||
setSidebarOpen: (open: boolean) => void
|
||||
toggleSidebarCollapsed: () => void
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
theme: 'dark',
|
||||
sidebarOpen: false,
|
||||
sidebarCollapsed: false,
|
||||
|
||||
setTheme: (theme) => set({ theme }, false, 'ui/setTheme'),
|
||||
|
||||
toggleSidebar: () =>
|
||||
set(
|
||||
(state) => ({ sidebarOpen: !state.sidebarOpen }),
|
||||
false,
|
||||
'ui/toggleSidebar'
|
||||
),
|
||||
|
||||
setSidebarOpen: (open) =>
|
||||
set({ sidebarOpen: open }, false, 'ui/setSidebarOpen'),
|
||||
|
||||
toggleSidebarCollapsed: () =>
|
||||
set(
|
||||
(state) => ({ sidebarCollapsed: !state.sidebarCollapsed }),
|
||||
false,
|
||||
'ui/toggleSidebarCollapsed'
|
||||
),
|
||||
}),
|
||||
{
|
||||
name: 'ui-storage',
|
||||
partialize: (state) => ({
|
||||
theme: state.theme,
|
||||
sidebarCollapsed: state.sidebarCollapsed,
|
||||
}),
|
||||
}
|
||||
),
|
||||
{ name: 'UIStore' }
|
||||
)
|
||||
)
|
||||
|
||||
export const useTheme = (): Theme => useUIStore((s) => s.theme)
|
||||
export const useSidebarOpen = (): boolean => useUIStore((s) => s.sidebarOpen)
|
||||
export const useSidebarCollapsed = (): boolean =>
|
||||
useUIStore((s) => s.sidebarCollapsed)
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// format.ts
|
||||
// ===================
|
||||
|
||||
export function toHex(value: number, pad = 0): string {
|
||||
const body = value.toString(16).padStart(pad, '0')
|
||||
return `0x${body}`
|
||||
}
|
||||
|
||||
export function formatAddr(value: number): string {
|
||||
return toHex(value, 6)
|
||||
}
|
||||
|
||||
export function formatOffset(value: number): string {
|
||||
return toHex(value, 8)
|
||||
}
|
||||
|
||||
const ELF_TYPES: Record<number, string> = {
|
||||
0: 'NONE',
|
||||
1: 'REL',
|
||||
2: 'EXEC',
|
||||
3: 'DYN',
|
||||
4: 'CORE',
|
||||
}
|
||||
|
||||
const ELF_MACHINES: Record<number, string> = {
|
||||
3: 'x86',
|
||||
40: 'ARM',
|
||||
62: 'x86-64',
|
||||
183: 'AArch64',
|
||||
243: 'RISC-V',
|
||||
}
|
||||
|
||||
export function elfTypeName(value: number): string {
|
||||
return ELF_TYPES[value] ?? toHex(value)
|
||||
}
|
||||
|
||||
export function elfMachineName(value: number): string {
|
||||
return ELF_MACHINES[value] ?? toHex(value)
|
||||
}
|
||||
|
||||
export function formatBytes(count: number): string {
|
||||
if (count < 1024) {
|
||||
return `${count} B`
|
||||
}
|
||||
return `${(count / 1024).toFixed(1)} KiB`
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// session.ts
|
||||
// ===================
|
||||
|
||||
import { STORAGE_KEYS } from '@/config'
|
||||
|
||||
function createId(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `s-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`
|
||||
}
|
||||
|
||||
let cached: string | null = null
|
||||
|
||||
export function getSessionId(): string {
|
||||
if (cached !== null) {
|
||||
return cached
|
||||
}
|
||||
try {
|
||||
const existing = localStorage.getItem(STORAGE_KEYS.SESSION)
|
||||
if (existing) {
|
||||
cached = existing
|
||||
return existing
|
||||
}
|
||||
const fresh = createId()
|
||||
localStorage.setItem(STORAGE_KEYS.SESSION, fresh)
|
||||
cached = fresh
|
||||
return fresh
|
||||
} catch {
|
||||
cached = createId()
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
// ===========================
|
||||
// ©AngelaMos | 2025
|
||||
// main.tsx
|
||||
// ===========================
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles.scss'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// browser.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../../styles/tokens' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-6;
|
||||
max-width: $breakpoint-xl;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: $font-size-3xl;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.progress {
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
gap: $space-4;
|
||||
}
|
||||
|
||||
.state {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.tsx
|
||||
// ===================
|
||||
|
||||
import { useChallenges, useProgress } from '@/api/hooks'
|
||||
import { ChallengeCard, ProgressBar } from '@/components'
|
||||
import styles from './browser.module.scss'
|
||||
|
||||
export function Component(): React.ReactElement {
|
||||
const challenges = useChallenges()
|
||||
const progress = useProgress()
|
||||
const solvedSet = new Set(progress.data?.solved ?? [])
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.title}>Challenges</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Read the binary. Reach the answer. Reveal the source.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{progress.data && (
|
||||
<div className={styles.progress}>
|
||||
<ProgressBar
|
||||
solved={progress.data.solved.length}
|
||||
total={progress.data.total}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{challenges.isLoading && <p className={styles.state}>Loading...</p>}
|
||||
{challenges.isError && (
|
||||
<p className={styles.state}>Failed to load challenges</p>
|
||||
)}
|
||||
|
||||
{challenges.data && (
|
||||
<div className={styles.grid}>
|
||||
{challenges.data.map((challenge) => (
|
||||
<ChallengeCard
|
||||
key={challenge.id}
|
||||
challenge={challenge}
|
||||
solved={solvedSet.has(challenge.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Component.displayName = 'Browser'
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// index.tsx
|
||||
// ===================
|
||||
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Workspace } from '@/components'
|
||||
import { ROUTES } from '@/config'
|
||||
import styles from './workspace.module.scss'
|
||||
|
||||
export function Component(): React.ReactElement {
|
||||
const { cid } = useParams<{ cid: string }>()
|
||||
|
||||
if (!cid) {
|
||||
return <div className={styles.page}>No challenge selected</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Link to={ROUTES.HOME} className={styles.back}>
|
||||
Back to challenges
|
||||
</Link>
|
||||
<Workspace cid={cid} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Component.displayName = 'ChallengeWorkspace'
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// workspace.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../../styles/tokens' as *;
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-4;
|
||||
}
|
||||
|
||||
.back {
|
||||
align-self: flex-start;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $color-text;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// styles.scss
|
||||
// ===================
|
||||
|
||||
@forward 'styles/tokens';
|
||||
@forward 'styles/fonts';
|
||||
@forward 'styles/mixins';
|
||||
|
||||
@use 'styles/reset';
|
||||
@use 'styles/tokens' as *;
|
||||
@use 'styles/fonts' as *;
|
||||
|
||||
body {
|
||||
font-family: $font-sans;
|
||||
color: $color-text;
|
||||
background-color: $color-bg;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// _fonts.scss
|
||||
// ===================
|
||||
|
||||
$font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica,
|
||||
Arial, sans-serif;
|
||||
$font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono',
|
||||
monospace;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// _index.scss
|
||||
// ===================
|
||||
|
||||
@forward 'tokens';
|
||||
@forward 'fonts';
|
||||
@forward 'mixins';
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// _mixins.scss
|
||||
// ===================
|
||||
|
||||
@use 'sass:map';
|
||||
@use 'tokens' as *;
|
||||
|
||||
$breakpoints: (
|
||||
'xs': $breakpoint-xs,
|
||||
'sm': $breakpoint-sm,
|
||||
'md': $breakpoint-md,
|
||||
'lg': $breakpoint-lg,
|
||||
'xl': $breakpoint-xl,
|
||||
'2xl': $breakpoint-2xl,
|
||||
);
|
||||
|
||||
@mixin breakpoint-up($size) {
|
||||
@media (min-width: map.get($breakpoints, $size)) { @content; }
|
||||
}
|
||||
|
||||
@mixin breakpoint-down($size) {
|
||||
@media (max-width: map.get($breakpoints, $size)) { @content; }
|
||||
}
|
||||
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@mixin visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// _reset.scss
|
||||
// ===================
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
img,
|
||||
picture,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
#root {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// _tokens.scss
|
||||
// ===================
|
||||
//
|
||||
// Neutral baseline. No brand identity -- grayscale palette, system type, plain
|
||||
// spacing scale. Design on top of these; nothing here implies a look.
|
||||
|
||||
// Spacing (8px base)
|
||||
$space-0: 0;
|
||||
$space-1: 0.25rem;
|
||||
$space-2: 0.5rem;
|
||||
$space-3: 0.75rem;
|
||||
$space-4: 1rem;
|
||||
$space-5: 1.25rem;
|
||||
$space-6: 1.5rem;
|
||||
$space-8: 2rem;
|
||||
$space-10: 2.5rem;
|
||||
$space-12: 3rem;
|
||||
$space-16: 4rem;
|
||||
$space-20: 5rem;
|
||||
$space-24: 6rem;
|
||||
|
||||
// Font sizes
|
||||
$font-size-xs: 0.75rem;
|
||||
$font-size-sm: 0.875rem;
|
||||
$font-size-base: 1rem;
|
||||
$font-size-lg: 1.125rem;
|
||||
$font-size-xl: 1.25rem;
|
||||
$font-size-2xl: 1.5rem;
|
||||
$font-size-3xl: 1.875rem;
|
||||
$font-size-4xl: 2.25rem;
|
||||
|
||||
// Weights / line-height / radius
|
||||
$font-weight-normal: 400;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-semibold: 600;
|
||||
$font-weight-bold: 700;
|
||||
$line-height-tight: 1.25;
|
||||
$line-height-normal: 1.5;
|
||||
$line-height-relaxed: 1.75;
|
||||
$radius-sm: 0.25rem;
|
||||
$radius-md: 0.5rem;
|
||||
$radius-lg: 0.75rem;
|
||||
$radius-full: 9999px;
|
||||
|
||||
// Neutral grayscale
|
||||
$color-white: #ffffff;
|
||||
$color-black: #000000;
|
||||
$color-gray-50: #fafafa;
|
||||
$color-gray-100: #f4f4f5;
|
||||
$color-gray-200: #e4e4e7;
|
||||
$color-gray-300: #d4d4d8;
|
||||
$color-gray-400: #a1a1aa;
|
||||
$color-gray-500: #71717a;
|
||||
$color-gray-600: #52525b;
|
||||
$color-gray-700: #3f3f46;
|
||||
$color-gray-800: #27272a;
|
||||
$color-gray-900: #18181b;
|
||||
|
||||
// Neutral semantic aliases
|
||||
$color-bg: $color-white;
|
||||
$color-surface: $color-gray-50;
|
||||
$color-border: $color-gray-200;
|
||||
$color-text: $color-gray-900;
|
||||
$color-text-muted: $color-gray-500;
|
||||
|
||||
// Breakpoints
|
||||
$breakpoint-xs: 480px;
|
||||
$breakpoint-sm: 640px;
|
||||
$breakpoint-md: 768px;
|
||||
$breakpoint-lg: 1024px;
|
||||
$breakpoint-xl: 1280px;
|
||||
$breakpoint-2xl: 1536px;
|
||||
|
||||
// Z-index
|
||||
$z-base: 1;
|
||||
$z-dropdown: 1000;
|
||||
$z-modal: 1300;
|
||||
$z-toast: 1400;
|
||||
|
||||
// Motion
|
||||
$duration-fast: 150ms;
|
||||
$duration-normal: 250ms;
|
||||
$ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
// ©AngelaMos | 2025
|
||||
// stylelint.config.js
|
||||
|
||||
/** @type {import('stylelint').Config} */
|
||||
export default {
|
||||
extends: ['stylelint-config-standard-scss', 'stylelint-config-prettier-scss'],
|
||||
rules: {
|
||||
'block-no-empty': true,
|
||||
'declaration-no-important': true,
|
||||
'color-no-invalid-hex': true,
|
||||
'property-no-unknown': true,
|
||||
'selector-pseudo-class-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignorePseudoClasses: ['global'],
|
||||
},
|
||||
],
|
||||
|
||||
'selector-class-pattern': [
|
||||
'^[a-z]([a-z0-9-]+)?(__[a-z0-9]([a-z0-9-]+)?)?(--[a-z0-9]([a-z0-9-]+)?)?$|^[a-z][a-zA-Z0-9]*$',
|
||||
{
|
||||
message:
|
||||
'Selector should be in BEM format (e.g., .block__element--modifier) or CSS Modules camelCase (e.g., .testButton)',
|
||||
},
|
||||
],
|
||||
|
||||
'value-keyword-case': [
|
||||
'lower',
|
||||
{
|
||||
camelCaseSvgKeywords: true,
|
||||
ignoreKeywords: [
|
||||
'BlinkMacSystemFont',
|
||||
'SFMono-Regular',
|
||||
'Menlo',
|
||||
'Monaco',
|
||||
'Consolas',
|
||||
'Roboto',
|
||||
'Arial',
|
||||
'Helvetica',
|
||||
'Times',
|
||||
'Georgia',
|
||||
'Verdana',
|
||||
'Tahoma',
|
||||
'Trebuchet',
|
||||
'Impact',
|
||||
'Comic',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
'property-no-vendor-prefix': [
|
||||
true,
|
||||
{
|
||||
ignoreProperties: ['text-size-adjust', 'appearance', 'backdrop-filter'],
|
||||
},
|
||||
],
|
||||
'value-no-vendor-prefix': true,
|
||||
'selector-no-vendor-prefix': true,
|
||||
|
||||
'property-no-deprecated': [
|
||||
true,
|
||||
{
|
||||
ignoreProperties: ['clip'],
|
||||
},
|
||||
],
|
||||
|
||||
'container-name-pattern': null,
|
||||
'layer-name-pattern': null,
|
||||
|
||||
'scss/at-rule-no-unknown': true,
|
||||
'scss/declaration-nested-properties-no-divided-groups': true,
|
||||
'scss/dollar-variable-no-missing-interpolation': true,
|
||||
'scss/dollar-variable-empty-line-before': null,
|
||||
|
||||
'declaration-empty-line-before': null,
|
||||
'custom-property-empty-line-before': null,
|
||||
|
||||
'no-descending-specificity': null,
|
||||
|
||||
'media-feature-name-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignoreMediaFeatureNames: ['map'],
|
||||
},
|
||||
],
|
||||
|
||||
'color-function-notation': null,
|
||||
'hue-degree-notation': null,
|
||||
},
|
||||
ignoreFiles: [
|
||||
'node_modules/**',
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'**/*.js',
|
||||
'**/*.ts',
|
||||
'**/*.tsx',
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/styles/_reset.scss', '**/styles/_fonts.scss'],
|
||||
rules: {
|
||||
'declaration-no-important': null,
|
||||
'scss/comment-no-empty': null,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue