feat: rveng M4 challenges + SQLite progress, M5 self-host + docs
M4: - Add 01-read-the-hex (hex-reading) and 02-find-the-entry (elf-anatomy) gradeable challenges over the gate binary, completing the five-module pedagogy - Swap in-memory progress for a SQLite ProgressStore behind the existing Protocol; routes untouched, migration-free, thread-safe M5: - Self-host Docker: prod (nginx + uvicorn) and dev (nginx + Vite HMR) compose, justfile, Dockerfiles, and nginx configs; zero-config clone-and-run - learn/ teaching docs (00-04) and public README - One-shot install.sh and uninstall.sh
This commit is contained in:
parent
01dca71765
commit
1d9668ee34
|
|
@ -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
|
||||
|
|
@ -8,3 +8,5 @@ __pycache__/
|
|||
.pytest_cache/
|
||||
.venv/
|
||||
*.pyc
|
||||
|
||||
data/
|
||||
|
|
|
|||
|
|
@ -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,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:
|
||||
|
|
@ -12,6 +12,7 @@ dist
|
|||
dist-ssr
|
||||
*.local
|
||||
.vite
|
||||
.pnpm-store
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
# ©AngelaMos | 2026
|
||||
# api.dockerfile
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.2 /uv /uvx /bin/
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=never
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
COPY src ./src
|
||||
COPY challenges ./challenges
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD ["uv", "run", "--no-sync", "python", "-c", \
|
||||
"import urllib.request; urllib.request.urlopen('http://localhost:8000/api/challenges')"]
|
||||
|
||||
CMD ["uv", "run", "--no-sync", "uvicorn", "rveng.api.server:app", \
|
||||
"--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# ©AngelaMos | 2026
|
||||
# vite.dev
|
||||
|
||||
FROM node:24-slim
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.29.1 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["sh", "-c", "pnpm install && exec pnpm dev --host 0.0.0.0"]
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# ©AngelaMos | 2026
|
||||
# vite.prod
|
||||
|
||||
FROM node:24-slim AS build
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.29.1 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY infra/nginx/prod.nginx /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# ©AngelaMos | 2026
|
||||
# dev.nginx
|
||||
|
||||
events {}
|
||||
|
||||
http {
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://frontend:5173;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# ©AngelaMos | 2026
|
||||
# prod.nginx
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
|
||||
client_max_body_size 1m;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
#!/usr/bin/env bash
|
||||
# ©AngelaMos | 2026
|
||||
# install.sh
|
||||
#
|
||||
# One-shot installer for rveng, a self-hosted reverse-engineering learning
|
||||
# platform. Takes a fresh machine to the app built, running, and reachable in a
|
||||
# browser, with zero further steps, whether run from a clone or piped from a
|
||||
# domain via curl. rveng is a Docker service, so the deliverable is the running
|
||||
# app, not a binary on PATH.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================================
|
||||
# CONFIG
|
||||
# ============================================================================
|
||||
REPO_OWNER="CarterPerez-dev"
|
||||
REPO_NAME="Cybersecurity-Projects"
|
||||
PROJECT_SUBDIR="PROJECTS/advanced/rveng"
|
||||
TAGLINE="interactive reverse-engineering learning platform"
|
||||
REPO_URL="https://github.com/${REPO_OWNER}/${REPO_NAME}.git"
|
||||
DEFAULT_BRANCH="main"
|
||||
HOST_PORT="${RVENG_PORT:-8790}"
|
||||
|
||||
# ============================================================================
|
||||
# Colors — gated so `| bash`, logs, and CI stay clean
|
||||
# ============================================================================
|
||||
if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||
BOLD=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[31m'; GREEN=$'\033[32m'
|
||||
YELLOW=$'\033[33m'; CYAN=$'\033[36m'; RESET=$'\033[0m'
|
||||
else
|
||||
BOLD=""; DIM=""; RED=""; GREEN=""; YELLOW=""; CYAN=""; RESET=""
|
||||
fi
|
||||
|
||||
info() { printf '%s\n' " ${CYAN}+${RESET} $*" >&2; }
|
||||
ok() { printf '%s\n' " ${GREEN}+${RESET} $*" >&2; }
|
||||
warn() { printf '%s\n' " ${YELLOW}!${RESET} $*" >&2; }
|
||||
die() { printf '%s\n' " ${RED}x $*${RESET}" >&2; exit 1; }
|
||||
header(){ printf '\n%s\n\n' "${BOLD}${CYAN}--- $* ---${RESET}" >&2; }
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
trap 'printf "%s\n" "${RED}x install failed${RESET}" >&2' ERR
|
||||
|
||||
banner() {
|
||||
printf '%s' "${CYAN}${BOLD}" >&2
|
||||
cat >&2 <<'ART'
|
||||
_ __ __ __ ___ _ __ __ _
|
||||
| '__/ \ / // -_)| '_ \ / _` |
|
||||
|_| \_/\_/ \___||_.__/ \__, |
|
||||
|___/
|
||||
ART
|
||||
printf '%s\n' "${RESET}" >&2
|
||||
printf '%s\n' " ${DIM}${TAGLINE}${RESET}" >&2
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Privilege + package-manager fan
|
||||
# ============================================================================
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
if have sudo; then SUDO="sudo"; fi
|
||||
fi
|
||||
|
||||
pkg_install() {
|
||||
if have apt-get; then $SUDO apt-get update -y || warn "apt update had errors; continuing"
|
||||
$SUDO apt-get install -y --no-install-recommends "$@"
|
||||
elif have dnf; then $SUDO dnf install -y "$@"
|
||||
elif have pacman; then $SUDO pacman -S --needed --noconfirm "$@"
|
||||
elif have zypper; then $SUDO zypper install -y "$@"
|
||||
elif have apk; then $SUDO apk add "$@"
|
||||
elif have brew; then brew install "$@"
|
||||
else die "no known package manager; install manually: $*"; fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Args
|
||||
# ============================================================================
|
||||
usage() {
|
||||
cat >&2 <<USAGE
|
||||
install.sh — build and run rveng
|
||||
|
||||
./install.sh [options]
|
||||
curl -fsSL https://angelamos.com/rveng/install.sh | bash
|
||||
|
||||
options:
|
||||
--port PORT host port to serve on (default: ${HOST_PORT})
|
||||
-h, --help this help
|
||||
|
||||
env:
|
||||
RVENG_PORT same as --port
|
||||
USAGE
|
||||
}
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--port) [ $# -ge 2 ] || die "--port needs a value"; HOST_PORT="$2"; shift 2 ;;
|
||||
--port=*) HOST_PORT="${1#*=}"; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "unknown option: $1 (try --help)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# OS
|
||||
# ============================================================================
|
||||
OS="$(uname -s)"
|
||||
case "$OS" in
|
||||
Linux) OS="linux" ;;
|
||||
Darwin) OS="darwin" ;;
|
||||
MINGW*|MSYS*|CYGWIN*) die "Windows unsupported. Use WSL2 with Docker." ;;
|
||||
*) die "unsupported OS: $OS" ;;
|
||||
esac
|
||||
|
||||
# ============================================================================
|
||||
# Bootstrap — works in-clone OR piped from a domain
|
||||
# ============================================================================
|
||||
resolve_project() {
|
||||
if [ -f "./compose.yml" ] && [ -f "./infra/docker/api.dockerfile" ]; then
|
||||
pwd; return
|
||||
fi
|
||||
local self="${BASH_SOURCE[0]:-}"
|
||||
if [ -n "$self" ] && [ -f "$(dirname "$self")/compose.yml" ]; then
|
||||
(cd "$(dirname "$self")" && pwd); return
|
||||
fi
|
||||
have git || { warn "git missing; installing it"; pkg_install git; }
|
||||
have git || die "could not install git; install it then re-run"
|
||||
local cache="${XDG_CACHE_HOME:-$HOME/.cache}/rveng-src"
|
||||
if [ -d "$cache/.git" ]; then
|
||||
info "updating cached clone at $cache"
|
||||
git -C "$cache" pull --ff-only --quiet 2>/dev/null || warn "pull failed; using existing clone"
|
||||
else
|
||||
info "cloning ${REPO_URL}"
|
||||
git clone --depth 1 --branch "$DEFAULT_BRANCH" --quiet "$REPO_URL" "$cache" \
|
||||
|| die "clone failed from ${REPO_URL}"
|
||||
fi
|
||||
printf '%s\n' "$cache/$PROJECT_SUBDIR"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Docker — the only real dependency; install it if missing
|
||||
# ============================================================================
|
||||
ensure_docker() {
|
||||
if ! have docker; then
|
||||
if [ "$OS" = "darwin" ]; then
|
||||
die "Docker not found. Install Docker Desktop for Mac, then re-run."
|
||||
fi
|
||||
info "installing Docker via get.docker.com"
|
||||
download_docker
|
||||
fi
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
if have systemctl; then
|
||||
info "starting the Docker daemon"
|
||||
$SUDO systemctl start docker 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
docker info >/dev/null 2>&1 || die "Docker is installed but the daemon is not running; start it and re-run"
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
info "installing the Docker Compose plugin"
|
||||
pkg_install docker-compose-plugin || die "install the Docker Compose v2 plugin, then re-run"
|
||||
fi
|
||||
ok "docker $(docker --version | awk '{print $3}' | tr -d ,)"
|
||||
}
|
||||
|
||||
download_docker() {
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
if have curl; then curl -fsSL https://get.docker.com -o "$tmp"
|
||||
elif have wget; then wget -qO "$tmp" https://get.docker.com
|
||||
else die "need curl or wget to install Docker"; fi
|
||||
$SUDO sh "$tmp" || { rm -f "$tmp"; die "Docker install failed"; }
|
||||
rm -f "$tmp"
|
||||
if [ -n "$SUDO" ] && have usermod; then
|
||||
$SUDO usermod -aG docker "$(id -un)" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Compose wrapper — run as the current user against the prod stack
|
||||
# ============================================================================
|
||||
compose() {
|
||||
NGINX_HOST_PORT="$HOST_PORT" docker compose "$@"
|
||||
}
|
||||
|
||||
http_probe() {
|
||||
if have curl; then curl -fsS "$1" >/dev/null 2>&1
|
||||
elif have wget; then wget -q -O /dev/null "$1" 2>/dev/null
|
||||
else return 1; fi
|
||||
}
|
||||
|
||||
wait_healthy() {
|
||||
local url="http://localhost:${HOST_PORT}/api/challenges" i
|
||||
for i in $(seq 1 90); do
|
||||
if http_probe "$url"; then return 0; fi
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main
|
||||
# ============================================================================
|
||||
main() {
|
||||
banner
|
||||
ensure_docker
|
||||
|
||||
header "Fetching rveng"
|
||||
PROJECT="$(resolve_project)"
|
||||
[ -d "$PROJECT" ] || die "could not locate the rveng project directory"
|
||||
cd "$PROJECT"
|
||||
ok "project at $PROJECT"
|
||||
|
||||
header "Building and starting (this compiles the frontend and engine image)"
|
||||
compose up -d --build
|
||||
|
||||
header "Waiting for the app"
|
||||
if wait_healthy; then
|
||||
ok "rveng is answering on port ${HOST_PORT}"
|
||||
else
|
||||
warn "app did not answer within the timeout; check 'docker compose logs'"
|
||||
fi
|
||||
|
||||
printf '\n%s\n\n' " ${GREEN}${BOLD}rveng is running.${RESET}" >&2
|
||||
cat >&2 <<FOOTER
|
||||
${DIM}open:${RESET} ${CYAN}http://localhost:${HOST_PORT}${RESET}
|
||||
|
||||
${DIM}stop:${RESET} ${CYAN}docker compose down${RESET} ${DIM}(in $PROJECT)${RESET}
|
||||
${DIM}logs:${RESET} ${CYAN}docker compose logs -f${RESET}
|
||||
FOOTER
|
||||
if have just; then
|
||||
cat >&2 <<'JUST'
|
||||
just lifecycle: just up / just down / just logs / just dev-up (hot-reload)
|
||||
JUST
|
||||
fi
|
||||
printf '%s\n' " ${DIM}docs: https://github.com/${REPO_OWNER}/${REPO_NAME}/tree/main/${PROJECT_SUBDIR}${RESET}" >&2
|
||||
return 0
|
||||
}
|
||||
|
||||
main "$@" </dev/null
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
# =============================================================================
|
||||
# ©AngelaMos | 2026
|
||||
# justfile
|
||||
# =============================================================================
|
||||
|
||||
set shell := ["bash", "-uc"]
|
||||
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
|
||||
|
||||
# =============================================================================
|
||||
# Default
|
||||
# =============================================================================
|
||||
|
||||
default:
|
||||
@just --list --unsorted
|
||||
|
||||
# =============================================================================
|
||||
# Docker Compose (Development)
|
||||
# =============================================================================
|
||||
|
||||
[group('dev')]
|
||||
dev-up *ARGS:
|
||||
docker compose -f dev.compose.yml up {{ARGS}}
|
||||
|
||||
[group('dev')]
|
||||
dev-start *ARGS:
|
||||
docker compose -f dev.compose.yml up -d {{ARGS}}
|
||||
|
||||
[group('dev')]
|
||||
dev-down *ARGS:
|
||||
docker compose -f dev.compose.yml down {{ARGS}}
|
||||
|
||||
[group('dev')]
|
||||
dev-stop:
|
||||
docker compose -f dev.compose.yml stop
|
||||
|
||||
[group('dev')]
|
||||
dev-build *ARGS:
|
||||
docker compose -f dev.compose.yml build {{ARGS}}
|
||||
|
||||
[group('dev')]
|
||||
dev-rebuild:
|
||||
docker compose -f dev.compose.yml build --no-cache
|
||||
|
||||
[group('dev')]
|
||||
dev-logs *SERVICE:
|
||||
docker compose -f dev.compose.yml logs -f {{SERVICE}}
|
||||
|
||||
[group('dev')]
|
||||
dev-ps:
|
||||
docker compose -f dev.compose.yml ps
|
||||
|
||||
# =============================================================================
|
||||
# Docker Compose (Production / self-host)
|
||||
# =============================================================================
|
||||
|
||||
[group('prod')]
|
||||
up *ARGS:
|
||||
docker compose up {{ARGS}}
|
||||
|
||||
[group('prod')]
|
||||
start *ARGS:
|
||||
docker compose up -d {{ARGS}}
|
||||
|
||||
[group('prod')]
|
||||
down *ARGS:
|
||||
docker compose down {{ARGS}}
|
||||
|
||||
[group('prod')]
|
||||
stop:
|
||||
docker compose stop
|
||||
|
||||
[group('prod')]
|
||||
build *ARGS:
|
||||
docker compose build {{ARGS}}
|
||||
|
||||
[group('prod')]
|
||||
rebuild:
|
||||
docker compose build --no-cache
|
||||
|
||||
[group('prod')]
|
||||
logs *SERVICE:
|
||||
docker compose logs -f {{SERVICE}}
|
||||
|
||||
[group('prod')]
|
||||
ps:
|
||||
docker compose ps
|
||||
|
||||
# =============================================================================
|
||||
# Verify (one-shot, host, no long-lived servers)
|
||||
# =============================================================================
|
||||
|
||||
[group('verify')]
|
||||
test:
|
||||
uv run pytest -q
|
||||
|
||||
[group('verify')]
|
||||
typecheck:
|
||||
cd frontend && pnpm typecheck
|
||||
|
||||
[group('verify')]
|
||||
lint:
|
||||
cd frontend && pnpm biome check .
|
||||
|
||||
# =============================================================================
|
||||
# Cleanup
|
||||
# =============================================================================
|
||||
|
||||
[group('cleanup')]
|
||||
clean:
|
||||
docker compose -f dev.compose.yml down -v
|
||||
docker compose down -v
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
<!-- ©AngelaMos | 2026 -->
|
||||
<!-- 00-OVERVIEW.md -->
|
||||
|
||||
# rveng: Overview
|
||||
|
||||
rveng is an interactive reverse-engineering learning platform. It hands you a
|
||||
real compiled binary, asks a concrete question about it ("what number does the
|
||||
check compare against?"), 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.
|
||||
|
||||
It is one framework-free Python analysis engine wearing three faces: a web app,
|
||||
an HTTP API, and (by construction) an embeddable library. The engine parses
|
||||
ELF, disassembles x86-64 with capstone, resolves imports through the PLT, builds
|
||||
control-flow graphs, finds functions in stripped binaries, and verifies
|
||||
challenge answers. The FastAPI layer is a thin read-only adapter over it. The
|
||||
React app is the face a learner touches.
|
||||
|
||||
This folder teaches how the whole thing works, from the security posture that
|
||||
makes it safe down to the byte offsets in the sample binary.
|
||||
|
||||
## The one rule that shapes everything: no execution
|
||||
|
||||
rveng never runs a binary. Every operation is reading and parsing bytes:
|
||||
|
||||
- Hex dump, ELF header parse, section walk, symbol read, string scan: 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.
|
||||
|
||||
A naive "web app that analyzes binaries" invites users to upload executables the
|
||||
server then runs, which means sandboxing untrusted native code forever. rveng
|
||||
sidesteps that entire attack surface. Challenge binaries are curated and
|
||||
pre-compiled by the author and shipped as static assets. 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. This constraint is
|
||||
load-bearing, not a preference. Adding a feature that runs a binary would require
|
||||
a separate design that keeps this posture.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- A hand-rolled ELF64 parser: header, section table, symbol table, all read
|
||||
straight from raw bytes and cross-checked in the tests against pyelftools.
|
||||
- x86-64 disassembly via capstone, in Intel syntax, with per-instruction
|
||||
annotation of comparisons, conditional branches, call targets, and
|
||||
RIP-relative data references.
|
||||
- Import resolution through the PLT: a `call 0x401050` is labeled `atoi`, read
|
||||
out of `.plt`, `.rela.plt`, `.dynsym`, and `.dynstr` the way the loader would.
|
||||
- Cross-references (who calls this function, what data does it touch) and a
|
||||
basic-block control-flow graph for a single function.
|
||||
- Function discovery in stripped binaries by scanning executable sections for
|
||||
the standard function prologue, so a binary with its symbol table removed is
|
||||
still navigable.
|
||||
- Six gradeable challenges over one sample binary, spanning the five core RE
|
||||
skills, with solve-then-reveal grading.
|
||||
- Progress persisted in SQLite behind a small interface, so it swaps cleanly for
|
||||
a host application's own store.
|
||||
- A React web app and a read-only FastAPI, served together for self-hosting with
|
||||
one command.
|
||||
|
||||
## Quick start
|
||||
|
||||
rveng self-hosts. Clone it, bring it up with Docker, open localhost. There is
|
||||
nothing to configure, no account, no secret, no external service.
|
||||
|
||||
```
|
||||
cd PROJECTS/advanced/rveng
|
||||
just up
|
||||
# open http://localhost:8790
|
||||
```
|
||||
|
||||
That builds the React app, has nginx serve it, and runs the FastAPI engine
|
||||
behind it. To develop with hot-reload instead:
|
||||
|
||||
```
|
||||
just dev-up
|
||||
# open http://localhost:8791
|
||||
```
|
||||
|
||||
To run the engine's tests and the frontend type check without any server:
|
||||
|
||||
```
|
||||
just test # uv run pytest -q
|
||||
just typecheck # cd frontend && pnpm typecheck
|
||||
```
|
||||
|
||||
## The sample binary
|
||||
|
||||
Every example in these docs uses `gate`, a small program compiled with
|
||||
`gcc -no-pie -fno-stack-protector -O0`. It reads a number from `argv`, compares
|
||||
it against `1337` in a function called `check`, and prints a secret string when
|
||||
the number matches. That one binary is enough to teach hex reading, the ELF
|
||||
skeleton, symbol and string recovery, reading a disassembled gate, patching the
|
||||
gate, and doing all of it again once the symbols are stripped.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- `01-CONCEPTS.md` for the reverse-engineering theory and why static analysis
|
||||
alone gets you a long way.
|
||||
- `02-ARCHITECTURE.md` for the one-engine-three-faces design and how a request
|
||||
flows through it.
|
||||
- `03-IMPLEMENTATION.md` for a walk through every engine module against the
|
||||
sample binary.
|
||||
- `04-CHALLENGES.md` for the six challenges, what each teaches, and how to add
|
||||
your own.
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
<!-- ©AngelaMos | 2026 -->
|
||||
<!-- 01-CONCEPTS.md -->
|
||||
|
||||
# Reverse-Engineering Concepts
|
||||
|
||||
Reverse engineering a binary is the practice of recovering what a program does
|
||||
from the bytes that run, without the source code. You do it when there is no
|
||||
source: analyzing malware, auditing a closed-source driver, understanding a
|
||||
vulnerability from a shipped patch, checking whether a firmware image does what
|
||||
its vendor claims. This document covers the concepts rveng teaches, grounded in
|
||||
how real analysts work.
|
||||
|
||||
## Static versus dynamic analysis
|
||||
|
||||
There are two ways to study a binary.
|
||||
|
||||
- **Dynamic analysis** runs it and watches: a debugger, a sandbox, a tracer.
|
||||
You see real behavior, but you have to execute possibly-hostile code, and the
|
||||
code can detect the sandbox and lie.
|
||||
- **Static analysis** reads the bytes without running them: parse the file
|
||||
format, disassemble the instructions, read the strings and symbols. Nothing
|
||||
executes, so nothing can attack you or hide from you by refusing to run.
|
||||
|
||||
rveng is entirely static, and that is not a limitation dodge. A large amount of
|
||||
real reverse engineering is static. The clearest famous example: in 2017 the
|
||||
WannaCry ransomware was slowed worldwide when an analyst found a hardcoded domain
|
||||
name inside the sample that it checked before spreading, and registered that
|
||||
domain. That domain was a "killswitch" that lived in the binary as a plain
|
||||
string, the kind of artifact you recover by reading the file rather than
|
||||
guessing. Reading strings and control flow out of a file is a first-class skill,
|
||||
and it is the skill rveng grades.
|
||||
|
||||
## What a binary actually is
|
||||
|
||||
A compiled program on Linux is an ELF file (Executable and Linkable Format, the
|
||||
System V ABI's format). It is not a blob. It has a rigid, documented structure:
|
||||
|
||||
```
|
||||
+---------------------+ offset 0
|
||||
| ELF header | magic, class, entry point, where the tables are
|
||||
+---------------------+
|
||||
| program headers | how the loader maps the file into memory (segments)
|
||||
+---------------------+
|
||||
| .text (code) | the machine instructions
|
||||
| .rodata (constants) | string literals, read-only data
|
||||
| .data (globals) | writable initialized data
|
||||
| ... |
|
||||
+---------------------+
|
||||
| section headers | a table describing every section above
|
||||
+---------------------+
|
||||
| .symtab / .strtab | function and variable names (removed when stripped)
|
||||
+---------------------+
|
||||
```
|
||||
|
||||
The ELF header at offset 0 is the map to everything else. Its first four bytes
|
||||
are always `7f 45 4c 46` (`\x7fELF`). One field, `e_entry`, holds the virtual
|
||||
address where execution begins. Another, `e_shoff`, points at the section header
|
||||
table. Learning to read those fixed offsets by hand is `02-elf-anatomy`, and the
|
||||
engine that does it is `elf.py`.
|
||||
|
||||
## Addresses, offsets, and why they differ
|
||||
|
||||
Two coordinate systems run through every binary, and confusing them is the most
|
||||
common beginner mistake.
|
||||
|
||||
- A **file offset** is a position in the file on disk. Byte number 0x1154 in the
|
||||
file.
|
||||
- A **virtual address** is where a byte lands in memory once the loader maps the
|
||||
file. Address 0x401154 at runtime.
|
||||
|
||||
They are related by the section that contains the byte: `vaddr = file_offset -
|
||||
section.offset + section.addr`. The sample `gate` is compiled `-no-pie`, which
|
||||
means it is not position-independent and its addresses are fixed at link time,
|
||||
so the numbers in these docs are stable and you can reason about them directly.
|
||||
Position-independent executables (the modern default) load at a random base, and
|
||||
you would work in offsets from that base instead. rveng uses `-no-pie` on purpose
|
||||
so the teaching addresses never move.
|
||||
|
||||
## Symbols, and the world without them
|
||||
|
||||
A **symbol** is a name attached to an address: the function `check` lives at
|
||||
`0x401146`, the entry `main` at `0x401164`. Symbols live in `.symtab`, and names
|
||||
in `.strtab`. They exist to help linkers and debuggers, not to run the program,
|
||||
so a release build or a piece of malware usually **strips** them: the `.symtab`
|
||||
section is deleted. The code still runs identically. It is just anonymous.
|
||||
|
||||
Stripping is why real reverse engineering is hard, and why rveng has a stripped
|
||||
challenge. When `check` is no longer named, you cannot search for it. You have to
|
||||
find functions another way (scan for the prologue that starts most functions),
|
||||
read the disassembly directly, and recognize the `cmp` against a constant as the
|
||||
gate. That is `discover.py` finding `sub_401146` where a symbol table would have
|
||||
said `check`.
|
||||
|
||||
The one thing stripping does not remove is dynamic linking information. A program
|
||||
that calls `printf` still needs the `.dynsym` and `.plt` machinery to find
|
||||
`printf` in libc at load time. So even in a stripped binary, calls to library
|
||||
functions can be recovered by name. That is what `plt.py` does.
|
||||
|
||||
## The PLT: how an external call gets a name back
|
||||
|
||||
When `gate` calls `atoi`, the compiler does not know where `atoi` will be in
|
||||
memory, so it calls a small stub in the Procedure Linkage Table (`.plt`). That
|
||||
stub jumps through a Global Offset Table (`.got.plt`) slot that the loader fills
|
||||
in. The link between "this PLT stub" and "the name `atoi`" is stored in the
|
||||
relocation table `.rela.plt`, which points into `.dynsym`, whose names live in
|
||||
`.dynstr`.
|
||||
|
||||
```
|
||||
call 0x401050 the code calls a PLT stub
|
||||
0x401050: jmp [rip+X] the stub jumps through a GOT slot at address G
|
||||
.rela.plt: G -> dynsym[i] the relocation says slot G binds symbol index i
|
||||
.dynsym[i].name -> .dynstr the symbol's name is "atoi"
|
||||
```
|
||||
|
||||
Walking that chain turns `call 0x401050` into `call atoi`. The engine does
|
||||
exactly this in `plt.py`, and it is why the disassembly pane can label imported
|
||||
calls instead of showing bare addresses.
|
||||
|
||||
## Reading a gate in assembly
|
||||
|
||||
The heart of most crackme-style challenges is a comparison feeding a conditional
|
||||
jump. In `gate`, the `check` function contains:
|
||||
|
||||
```
|
||||
cmp DWORD PTR [rbp-0x4], 0x539 ; compare the input against 0x539 (1337)
|
||||
jne <fail path> ; if not equal, take the fail branch
|
||||
<success path>
|
||||
```
|
||||
|
||||
`0x539` is `1337` in decimal, and Intel-syntax capstone prints it as `0x539`.
|
||||
Recognizing that the magic number is `1337`, read from the `cmp`, is
|
||||
`05-disassembly`. Recognizing that flipping the `jne` (opcode byte `75`) so the
|
||||
branch is never taken forces the success path is `03-patching`. The skill in both
|
||||
cases is reading intent out of instructions.
|
||||
|
||||
## Patching: behavior is just bytes
|
||||
|
||||
A conditional jump `jne` is the two bytes `75 07` in this binary. Overwrite them
|
||||
with `90 90` (two `nop` no-ops) and the jump is gone, so control falls straight
|
||||
into the unlock path regardless of the input. That is binary patching: you change
|
||||
behavior by editing bytes, no recompile. It is the mechanism behind historical
|
||||
software cracks, behind legitimate hot-patching, and behind micro-patching a
|
||||
vulnerable function without shipping a whole new build.
|
||||
|
||||
rveng grades a patch without running it. Each patch challenge ships the original
|
||||
binary and a known-good patched target. Your submitted bytes are applied to the
|
||||
original at the given offset, and the result is compared to the known-good target
|
||||
with a byte diff. Equal means correct. This grades the exact skill (produce the
|
||||
right edit) with zero execution. That is the trick in `patch.py`.
|
||||
|
||||
## The solve-then-reveal loop
|
||||
|
||||
The pedagogy rveng preserves from its predecessor is simple and strict:
|
||||
|
||||
1. A challenge gives you a binary and a concrete mission.
|
||||
2. You use the tools to reach an answer from the binary alone.
|
||||
3. You submit. The engine grades it.
|
||||
4. Only a correct answer reveals the original C source.
|
||||
|
||||
Revealing source only after a correct answer is what makes the loop teach. You
|
||||
must reach the answer from the machine evidence, then you get to see you were
|
||||
right and why. This is only possible because every answer is machine-checkable,
|
||||
which forces every challenge into one of three grading categories:
|
||||
|
||||
- **found-value**: locate a number or string in the binary and submit it. The
|
||||
magic `1337`, or the string `the_flag_is_here`. Numbers are normalized so
|
||||
`0x539`, `539h`, and `1337` all match.
|
||||
- **identified-symbol**: name a function, section, or symbol. Answer `check`,
|
||||
matched case-insensitively.
|
||||
- **patched-bytes**: edit bytes to change behavior, graded by static diff against
|
||||
a known-good target.
|
||||
|
||||
Every one of the six challenges is one of these three, and that mapping is the
|
||||
bridge between "reverse engineering as a craft" and "reverse engineering a
|
||||
machine can grade." The next document shows how the system is built around it.
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
<!-- ©AngelaMos | 2026 -->
|
||||
<!-- 02-ARCHITECTURE.md -->
|
||||
|
||||
# Architecture
|
||||
|
||||
rveng is 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 is persisted behind a small
|
||||
interface so it can be swapped. This shape exists so the engine and the lesson
|
||||
content can be lifted out of this project and mounted inside a larger
|
||||
application without dragging a web framework along.
|
||||
|
||||
## One engine, three faces
|
||||
|
||||
```
|
||||
+--------------------------+
|
||||
| rveng/engine/ (pure) |
|
||||
| elf disasm plt xref cfg |
|
||||
| hex strings patch discover|
|
||||
| challenge (grading) |
|
||||
+------------+--------------+
|
||||
|
|
||||
+------------------------+------------------------+
|
||||
| | |
|
||||
+----+-----+ +------+------+ +------+------+
|
||||
| HTTP API | | library | | CLI |
|
||||
| FastAPI | | (import it)| | (potential) |
|
||||
+----+-----+ +-------------+ +-------------+
|
||||
|
|
||||
+----+-----+
|
||||
| React app|
|
||||
+----------+
|
||||
```
|
||||
|
||||
The engine is the product. The API is an adapter. The React app is a face. The
|
||||
same engine that answers `/api/challenges/05-find-the-gate/disasm` could be
|
||||
imported directly by CertGames and driven with no HTTP at all. Nothing in
|
||||
`rveng/engine/` imports FastAPI, and nothing renders HTML. That separation is the
|
||||
whole point.
|
||||
|
||||
## The engine modules
|
||||
|
||||
Each module is small and does one thing against raw bytes. None of them execute
|
||||
anything.
|
||||
|
||||
```
|
||||
engine/elf.py parse the ELF64 header, sections, and symbols from bytes
|
||||
engine/hex.py render a canonical offset/hex/ascii dump
|
||||
engine/strings.py scan bytes for printable runs
|
||||
engine/disasm.py decode x86-64 with capstone, annotate the interesting ops
|
||||
engine/plt.py resolve a PLT stub address to an imported symbol name
|
||||
engine/xref.py collect cross-references from decoded instructions
|
||||
engine/cfg.py split a function into basic blocks and connect them
|
||||
engine/discover.py find functions in a stripped binary by prologue scan
|
||||
engine/patch.py apply and diff byte edits, verify a patch statically
|
||||
engine/challenge.py the challenge model and the solve-then-reveal grader
|
||||
```
|
||||
|
||||
The dependency direction is strict: everything depends on `elf.py` and its byte
|
||||
buffer, `disasm.py` feeds `xref.py` and `cfg.py`, and `challenge.py` depends on
|
||||
`patch.py` for patch grading. Nothing points back up toward the API.
|
||||
|
||||
## The API layer
|
||||
|
||||
`rveng/api/` is the adapter. It loads the curated challenges once at startup,
|
||||
exposes read-only analysis of them, and grades submissions.
|
||||
|
||||
```
|
||||
api/app.py builds the FastAPI app and defines every route
|
||||
api/store.py loads challenges from disk; the progress store (Protocol + impls)
|
||||
api/schemas.py the Pydantic response and request shapes
|
||||
api/limits.py size and length caps for untrusted input
|
||||
api/middleware.py a body-size cap enforced before the body is parsed
|
||||
api/server.py the ASGI entry point (create_app())
|
||||
```
|
||||
|
||||
The routes are deliberately boring. Given a challenge id they return a hex view,
|
||||
an ELF summary, a disassembly, a control-flow graph, cross-references, or the
|
||||
string list. One route accepts a submission and grades it. No route accepts a
|
||||
binary to analyze; the only binaries in the system are the curated challenge
|
||||
assets.
|
||||
|
||||
## How a request flows
|
||||
|
||||
Take a learner disassembling the `check` function of the sample:
|
||||
|
||||
```
|
||||
browser
|
||||
GET /api/challenges/05-find-the-gate/disasm?symbol=check
|
||||
|
|
||||
nginx (serves the React app, proxies /api to the API container)
|
||||
|
|
||||
FastAPI route in app.py
|
||||
| look up the challenge by id (store.py)
|
||||
| parse its binary (elf.ElfImage)
|
||||
| find the symbol "check" (image.symbol)
|
||||
| disassemble the symbol (disasm.disassemble_symbol)
|
||||
| resolve any call names (plt.plt_map)
|
||||
| serialize to schemas.DisasmView
|
||||
v
|
||||
JSON back to the browser, rendered by the disasm pane
|
||||
```
|
||||
|
||||
The engine did the analysis; the API only translated ids to calls and objects to
|
||||
JSON. Crucially, the gate-highlight annotation on the disassembly is withheld
|
||||
until the challenge is solved, so a learner sees honest raw disassembly first and
|
||||
the "this is the gate" hint only after they have found it themselves.
|
||||
|
||||
## Progress behind an interface
|
||||
|
||||
Progress (which challenges a session has solved) is the one piece of state. It
|
||||
lives behind a `ProgressStore` protocol with two methods:
|
||||
|
||||
```
|
||||
mark_solved(session, challenge_id) -> None
|
||||
solved(session) -> set[str]
|
||||
```
|
||||
|
||||
Two implementations satisfy it: `InMemoryProgress` (a dict, used in tests) and
|
||||
`SqliteProgress` (a file-backed table, the default the server runs). The routes
|
||||
only ever see the protocol, so swapping the backing store is a one-line change in
|
||||
`create_app` and touches no route. When rveng is extracted into a host
|
||||
application, that application points the same protocol at its own database, and
|
||||
nothing else moves.
|
||||
|
||||
```
|
||||
routes ---> ProgressStore (protocol)
|
||||
|
|
||||
+--------+---------+
|
||||
| |
|
||||
InMemoryProgress SqliteProgress (host app plugs its own here)
|
||||
```
|
||||
|
||||
## The deployment topology
|
||||
|
||||
rveng self-hosts on localhost with Docker. There is no cloud, no TLS, no domain,
|
||||
no account. Two containers in production:
|
||||
|
||||
```
|
||||
PROD (compose.yml, project "rveng") DEV (dev.compose.yml, project "rveng-dev")
|
||||
|
||||
browser :8790 browser :8791
|
||||
| |
|
||||
[ nginx ] serves built React dist [ nginx ] proxies everything
|
||||
| /api | / | /api
|
||||
[ api ] uvicorn (FastAPI engine) [ vite HMR ] [ api ] uvicorn --reload
|
||||
| |
|
||||
rveng_data volume (sqlite progress) rveng_data_dev volume
|
||||
```
|
||||
|
||||
nginx serves the compiled frontend and proxies `/api` to the API container. The
|
||||
API is a pure engine adapter that never learns to serve a SPA, which keeps it
|
||||
extractable. In development, nginx fronts the Vite dev server for hot reload
|
||||
instead of serving a static build. The two projects use structural-literal names
|
||||
(`rveng` and `rveng-dev`), so dev and prod are namespace-isolated by
|
||||
construction and cannot collide. Everything runs on built-in defaults, so a clean
|
||||
clone comes up with no configuration.
|
||||
|
||||
## Extraction into a host application
|
||||
|
||||
The end state this architecture buys: mounting rveng as a feature elsewhere means
|
||||
importing `rveng.engine`, reusing the challenge assets as-is, pointing the
|
||||
`ProgressStore` protocol at the host's store, and mounting the self-contained
|
||||
React components. The engine and content come along unchanged. That is the
|
||||
difference between building a throwaway project and building a feature that
|
||||
happens to also stand alone.
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
<!-- ©AngelaMos | 2026 -->
|
||||
<!-- 03-IMPLEMENTATION.md -->
|
||||
|
||||
# Implementation
|
||||
|
||||
A walk through the engine, module by module, against the sample `gate` binary.
|
||||
Every address and offset below is real and reproducible: `gate` is compiled with
|
||||
`gcc -no-pie -fno-stack-protector -O0`, so nothing moves. Code is referenced by
|
||||
function name, never line number, because line numbers rot.
|
||||
|
||||
## elf.py: reading the file format from raw bytes
|
||||
|
||||
`elf.py` hand-rolls the ELF64 parser with `struct`. It does not use a library to
|
||||
read the format, on purpose: the point is to learn where every field lives. The
|
||||
tests then cross-check the hand-rolled result against pyelftools
|
||||
(`test_matches_pyelftools`), so pyelftools is a correctness oracle in the test
|
||||
suite, not a dependency the engine leans on at runtime.
|
||||
|
||||
`parse_header` is the entry point. It rejects anything that is not ELF64:
|
||||
|
||||
- The first four bytes must be `\x7fELF` (`ELF_MAGIC`).
|
||||
- Byte 4 (`EI_CLASS`) must be `ELFCLASS64`.
|
||||
- Byte 5 (`EI_DATA`) selects endianness, turned into a `struct` prefix (`<` for
|
||||
little-endian, which is what x86-64 uses).
|
||||
|
||||
Then it reads fixed offsets straight out of the header: `e_entry` at `0x18`,
|
||||
`e_shoff` at `0x28`, `e_shnum` at `0x3C`, and so on. For `gate` this yields
|
||||
`e_entry = 0x401060`, `e_shoff = 0x36a0` (13984), `e_shnum = 30`. `_validate_
|
||||
section_table` then bounds-checks the section table so a malformed file raises
|
||||
`NotAnElf` instead of reading out of range.
|
||||
|
||||
`parse_sections` reads all `e_shnum` section headers, then resolves each
|
||||
section's name. Names are not stored inline; each header holds a `name` offset
|
||||
into a dedicated string section, and the header's index is `e_shstrndx`. The
|
||||
parser reads that section's bytes once and pulls each name as a C string. That is
|
||||
how a nameless index becomes `.text`, `.rodata`, `.symtab`.
|
||||
|
||||
`parse_symbols` finds the `.symtab` section, follows its `link` field to the
|
||||
matching string table, and reads each 24-byte symbol entry: name offset, value
|
||||
(the symbol's address), size, and a packed `info` byte whose low nibble is the
|
||||
type and high nibble is the binding. For `gate`, this recovers `check` at
|
||||
`0x401146` with size 30, and `main` at `0x401164`. `ElfImage` wraps all of this
|
||||
and offers `section(name)`, `symbol(name)`, and `functions()` (symbols whose type
|
||||
is `STT_FUNC`).
|
||||
|
||||
The one design note worth internalizing: a section can be `SHT_NOBITS` (`.bss`),
|
||||
meaning it occupies memory but no file bytes. `Section.file_bytes` returns empty
|
||||
for those, so nothing tries to read file content that is not there.
|
||||
|
||||
## hex.py: the canonical dump
|
||||
|
||||
`hex.py` renders the same layout as `xxd`: an 8-hex-digit offset, sixteen bytes
|
||||
of hex split into two groups of eight, then the ASCII gutter. `HexLine.ascii_
|
||||
gutter` prints a byte as its character only when it is in the printable range
|
||||
`0x20` to `0x7E`, and a `.` otherwise. That gutter is the whole skill of
|
||||
`01-hex-reading`: the secret string `the_flag_is_here` is invisible in the hex
|
||||
columns but obvious in the gutter, sitting in `.rodata` at file offset `0x2004`.
|
||||
|
||||
## strings.py: printable runs
|
||||
|
||||
`extract` walks the bytes, accumulating a run whenever it sees a printable byte
|
||||
and flushing the run as a `FoundString` when it breaks, if the run met the
|
||||
minimum length. `extract_in_section` scopes that to one section's bytes while
|
||||
keeping file-relative offsets, so you can ask specifically for the strings in
|
||||
`.rodata`. This is the cheap win of reverse engineering: before disassembling
|
||||
anything, read the strings, and a password or a format string often falls out.
|
||||
|
||||
## patch.py: editing behavior without running it
|
||||
|
||||
Three functions, all pure:
|
||||
|
||||
- `apply(original, offset, new_bytes)` returns a new buffer with the edit
|
||||
written in, bounds-checked, and length-preserving. A patch never changes the
|
||||
file size.
|
||||
- `diff(a, b)` returns the per-byte differences between two equal-length
|
||||
buffers.
|
||||
- `verify_patch(original, offset, submitted, known_good)` applies the submission
|
||||
and returns whether the result equals the known-good target.
|
||||
|
||||
This is the mechanism that grades a patch challenge with no execution. For the
|
||||
gate, the `jne` at file offset `0x1154` is the bytes `75 07`. Overwriting them
|
||||
with `90 90` produces exactly the known-good patched target, so `verify_patch`
|
||||
returns true. Nothing is run; two buffers are compared.
|
||||
|
||||
## disasm.py: decoding x86-64 with capstone
|
||||
|
||||
`_new_engine` configures capstone for 64-bit x86 in Intel syntax with detail on.
|
||||
Intel syntax is the one where the constant prints as `0x539`; the setting is
|
||||
`CS_OPT_SYNTAX_INTEL`. Detail mode is required because the engine inspects each
|
||||
instruction's operands, not just its text.
|
||||
|
||||
Every decoded instruction becomes an `Instruction` dataclass carrying its
|
||||
address, mnemonic, operand string, raw bytes, and three annotations that the
|
||||
higher layers depend on:
|
||||
|
||||
- `immediate`: the immediate operand of a non-flow instruction, so `cmp [rbp-4],
|
||||
0x539` exposes `0x539`. This is pulled by `_immediate`, which reads the
|
||||
`X86_OP_IMM` operand.
|
||||
- `branch_target`: for a control-flow instruction (a conditional jump, `call`,
|
||||
or `jmp`), the immediate is the destination address instead of a data value.
|
||||
The engine routes the immediate to `branch_target` for flow ops and to
|
||||
`immediate` for everything else, so the two never get confused.
|
||||
- `rip_target`: for a RIP-relative memory operand, the absolute address it
|
||||
points at, computed by `_rip_target` as `instruction address + instruction
|
||||
size + displacement`. This is how `lea rax, [rip+X]` loading the secret string
|
||||
resolves to `0x402004`.
|
||||
|
||||
There are three ways to disassemble:
|
||||
|
||||
- `disassemble_symbol` uses a symbol's section index, value, and size to carve
|
||||
exactly that function's bytes. This is the clean path when symbols exist.
|
||||
- `disassemble_at` disassembles from a raw virtual address until the first `ret`.
|
||||
This is the path for a stripped binary, where you have an address (from
|
||||
discovery) but no size.
|
||||
- `disassemble_text` decodes the whole `.text` section for whole-binary analysis
|
||||
like cross-referencing.
|
||||
|
||||
`find_gate` returns the first comparison instruction that carries an immediate,
|
||||
which for `gate` is the `cmp ..., 0x539`. That is the machine reading the gate
|
||||
the same way a learner does.
|
||||
|
||||
## plt.py: giving imported calls their names back
|
||||
|
||||
A stripped or dynamically linked binary calls libc functions through PLT stubs.
|
||||
`plt.py` reconstructs the stub-to-name mapping the way the loader would:
|
||||
|
||||
- `_dynamic_names` reads `.dynsym` and resolves each dynamic symbol's name from
|
||||
`.dynstr`.
|
||||
- `_got_to_name` reads `.rela.plt`. Each relocation has a target GOT address
|
||||
(`r_offset`) and a packed `r_info` whose top 32 bits are the dynamic-symbol
|
||||
index. It maps each GOT slot to the imported name.
|
||||
- `_stub_got_slot` finds the `jmp [rip+disp]` inside a 16-byte PLT entry (the
|
||||
opcode is `ff 25`) and computes which GOT slot it jumps through, as `entry
|
||||
address + jump offset + 6 + displacement`.
|
||||
- `plt_map` ties it together: for every PLT stub, find its GOT slot, look up the
|
||||
name bound to that slot.
|
||||
|
||||
For `gate` this produces exactly `0x401030 -> puts`, `0x401040 -> printf`,
|
||||
`0x401050 -> atoi`. That is why the disassembly of `main` can show `call atoi`
|
||||
instead of `call 0x401050`.
|
||||
|
||||
## xref.py and cfg.py: structure over the instruction stream
|
||||
|
||||
`xref.py` turns decoded instructions into references. Every `branch_target`
|
||||
becomes a call or branch reference, every `rip_target` becomes a data reference.
|
||||
`build_xrefs` groups them by the address they point at, so you can ask "who
|
||||
references `check`?" and `xrefs_to` filters to one target. Running it over the
|
||||
sample finds `main` calling `check`.
|
||||
|
||||
`cfg.py` builds a control-flow graph for a single function using the classic
|
||||
leader algorithm:
|
||||
|
||||
1. `_leaders` marks basic-block boundaries. A block starts at the first
|
||||
instruction, at any instruction immediately after a terminator (a conditional
|
||||
branch, `jmp`, or `ret`), and at any branch target that lands inside the
|
||||
function.
|
||||
2. `build_cfg` slices the instruction stream at those leaders into `BasicBlock`s.
|
||||
3. It connects them: a conditional branch emits a `taken` edge to its target and
|
||||
a `fallthrough` edge to the next block; a `jmp` emits a `jump` edge; a plain
|
||||
block falls through; a `ret` emits nothing.
|
||||
|
||||
For `check`, this produces the diamond you would expect: an entry block with the
|
||||
`cmp`/`jne`, a taken edge to the fail path and a fallthrough edge to the success
|
||||
path.
|
||||
|
||||
## discover.py: finding functions with no symbols
|
||||
|
||||
When a binary is stripped, `functions()` returns nothing, so navigation needs
|
||||
another anchor. `discover_functions` scans every executable section for the
|
||||
standard function prologue `55 48 89 e5`, which is `push rbp; mov rbp, rsp`, the
|
||||
frame setup that begins most `-O0` functions. Each hit becomes a `Discovered
|
||||
Function` labeled `sub_<address>`. On `gate_stripped` this rediscovers the entry
|
||||
of `check` at `0x401146` even though its name is gone, and the disassembly-by-
|
||||
address path takes over from there. This is exactly how you work a real stripped
|
||||
binary: no names, so you find code by its shape.
|
||||
|
||||
## challenge.py: the grader and the reveal
|
||||
|
||||
`challenge.py` holds the three answer specs (`FoundValue`, `IdentifiedSymbol`,
|
||||
`PatchedBytes`) and the `grade` function that dispatches on which one a challenge
|
||||
uses:
|
||||
|
||||
- A `FoundValue` numeric answer is graded through `normalize_int`, which accepts
|
||||
`0x539`, `539h`, and `1337` and compares as integers. A string `FoundValue` is
|
||||
compared after trimming and lowercasing.
|
||||
- An `IdentifiedSymbol` is compared case-insensitively against the known name.
|
||||
- A `PatchedBytes` submission is graded by `verify_patch` against the known-good
|
||||
target.
|
||||
|
||||
The load-bearing behavior is at the end of `grade`: it returns the challenge's
|
||||
source only when the answer is correct. A wrong answer returns `revealed_source =
|
||||
None`. That single conditional is the solve-then-reveal pedagogy, and it works
|
||||
because every answer is checkable before the reveal. The API layer never has to
|
||||
know how grading works; it calls `grade`, and hands back whatever source (if any)
|
||||
comes out.
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
<!-- ©AngelaMos | 2026 -->
|
||||
<!-- 04-CHALLENGES.md -->
|
||||
|
||||
# Challenges
|
||||
|
||||
Six challenges over one binary. They span the five core reverse-engineering
|
||||
skills, and the sixth removes the training wheels by stripping the symbols. Each
|
||||
is graded, and each reveals its source only when you are right. The answers are
|
||||
printed here because this is the teaching document; solve them from the binary
|
||||
first, then check yourself.
|
||||
|
||||
The sample `gate` reads a number from `argv`, compares it against `1337` in a
|
||||
function called `check`, and prints the secret `the_flag_is_here` when the number
|
||||
matches. All six challenges are that one program, seen from different angles.
|
||||
|
||||
## The five skills, mapped to grading
|
||||
|
||||
Every challenge is one of three machine-checkable answer categories. That mapping
|
||||
is what lets a machine grade a craft:
|
||||
|
||||
| # | Challenge | Module | Skill | Category |
|
||||
|---|-----------|--------|-------|----------|
|
||||
| 01 | Read the hex | hex-reading | read a dump, find a value | found-value |
|
||||
| 02 | Find the entry point | elf-anatomy | read the ELF header | found-value |
|
||||
| 03 | Flip the gate | patching | change bytes, change behavior | patched-bytes |
|
||||
| 04 | Name the function | strings-symbols | read the symbol table | identified-symbol |
|
||||
| 05 | Find the gate | disassembly | read the assembly | found-value |
|
||||
| 06 | Gate in the dark | disassembly (stripped) | work without symbols | found-value |
|
||||
|
||||
## 01: Read the hex
|
||||
|
||||
Mission: a secret string sits in the binary's read-only data. Open the hex
|
||||
viewer and read the ASCII column to recover it.
|
||||
|
||||
Answer: `the_flag_is_here`, at file offset `0x2004` in `.rodata`.
|
||||
|
||||
What it teaches: the two halves of a hex dump. The hex columns are noise to a
|
||||
human; the ASCII gutter on the right is where a string becomes legible. This is
|
||||
the first move on any unknown file, and it is free.
|
||||
|
||||
## 02: Find the entry point
|
||||
|
||||
Mission: every ELF file records where execution begins. Read the header and find
|
||||
the entry point, the `e_entry` field.
|
||||
|
||||
Answer: `0x401060` (decimal `4198496`). Both forms are accepted.
|
||||
|
||||
What it teaches: the ELF header is the map to the whole file, and `e_entry` is a
|
||||
fixed field at offset `0x18`. Learning that the header is a rigid table you can
|
||||
read by hand, rather than magic, is the foundation for everything else.
|
||||
|
||||
## 03: 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: `9090` (two `nop`s), applied at offset `0x1154`.
|
||||
|
||||
What it teaches: behavior is bytes. A single conditional jump gates the outcome,
|
||||
and neutralizing it with two `nop`s forces the success path. The grader applies
|
||||
your bytes to the original and compares against a known-good patched target with
|
||||
a static diff, so you are graded on producing the exact edit, and nothing is ever
|
||||
run.
|
||||
|
||||
## 04: Name the function
|
||||
|
||||
Mission: one function decides whether the gate opens. Read the symbol table and
|
||||
name it.
|
||||
|
||||
Answer: `check` (at `0x401146`, size 30, in `.symtab`). Matched
|
||||
case-insensitively.
|
||||
|
||||
What it teaches: symbols are names attached to addresses, and when they are
|
||||
present they hand you the map for free. This is the cheap path that the stripped
|
||||
challenge later takes away.
|
||||
|
||||
## 05: 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: `1337` (the `cmp ..., 0x539`). Accepted as `1337`, `0x539`, or `539h`.
|
||||
|
||||
What it teaches: reading intent out of instructions. The magic number is not a
|
||||
string and not a symbol; it is an immediate operand inside a `cmp`, and the only
|
||||
way to it is to read the disassembly of `check`.
|
||||
|
||||
## 06: Gate in the dark
|
||||
|
||||
Mission: this binary is stripped, 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: `1337`, same as challenge 05, but reached with no symbol table.
|
||||
|
||||
What it teaches: the real-world case. Malware and release builds are stripped, so
|
||||
`04`'s symbol lookup and `05`'s named disassembly both fail here. You find code
|
||||
by its shape (the `push rbp; mov rbp, rsp` prologue), disassemble by raw address
|
||||
until the `ret`, and recognize the same gate. This is reverse engineering when
|
||||
the binary is not trying to help you.
|
||||
|
||||
## Extending the platform
|
||||
|
||||
Adding a challenge is adding an asset directory, no code change. Each challenge
|
||||
is a folder under `challenges/` with three files:
|
||||
|
||||
```
|
||||
challenges/07-your-challenge/
|
||||
challenge.json id, module, title, mission, and the answer spec
|
||||
target the compiled binary to analyze
|
||||
source.c the source revealed on a correct answer
|
||||
```
|
||||
|
||||
The `answer` object in `challenge.json` picks one of the three grading
|
||||
categories:
|
||||
|
||||
```
|
||||
{ "category": "found_value", "expected": 1337 }
|
||||
{ "category": "found_value", "expected": "the_flag_is_here" }
|
||||
{ "category": "identified_symbol","name": "check" }
|
||||
{ "category": "patched_bytes", "offset": 4436, "patch": "9090" }
|
||||
```
|
||||
|
||||
The loader validates the directory at startup, and one malformed challenge is
|
||||
skipped with a warning rather than crashing the whole set. From there, ideas that
|
||||
fit the existing engine without any new capability:
|
||||
|
||||
- A challenge that asks which section holds a given string, graded as an
|
||||
identified-symbol on the section name.
|
||||
- A multi-byte patch that changes a comparison constant rather than removing a
|
||||
jump.
|
||||
- A binary with a decoy function so function discovery returns more than one
|
||||
candidate and the learner has to pick the real gate from the disassembly.
|
||||
- A found-value challenge on a RIP-relative data reference, so the learner has to
|
||||
compute an absolute address from an instruction the way `disasm.py` does.
|
||||
|
||||
Ideas that need new engine work, and would be real projects: a second
|
||||
architecture (ARM64 via capstone's other modes), a dynamic-analysis face behind a
|
||||
separate sandboxed design that preserves the no-execution posture of this
|
||||
backend, or an auto-laid-out graph view over the existing control-flow graph.
|
||||
|
|
@ -20,8 +20,8 @@ from rveng.api.limits import (
|
|||
from rveng.api.middleware import BodySizeLimitMiddleware
|
||||
from rveng.api.store import (
|
||||
ChallengeStore,
|
||||
InMemoryProgress,
|
||||
ProgressStore,
|
||||
SqliteProgress,
|
||||
load_store,
|
||||
)
|
||||
from rveng.engine import cfg as cfgmod
|
||||
|
|
@ -41,7 +41,7 @@ def create_app(
|
|||
Build the rveng API over a challenge store and progress store
|
||||
"""
|
||||
store = store or load_store(CHALLENGES_ROOT)
|
||||
progress = progress or InMemoryProgress()
|
||||
progress = progress or SqliteProgress()
|
||||
app = FastAPI(title="rveng")
|
||||
app.add_middleware(BodySizeLimitMiddleware, max_bytes=MAX_BODY_BYTES)
|
||||
app.add_middleware(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ store.py
|
|||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
|
|
@ -22,6 +24,12 @@ MANIFEST = "challenge.json"
|
|||
TARGET = "target"
|
||||
SOURCE = "source.c"
|
||||
|
||||
DATA_DIR = "data"
|
||||
DB_FILENAME = "progress.db"
|
||||
DEFAULT_DB_PATH = Path(__file__).resolve().parents[3] / DATA_DIR / DB_FILENAME
|
||||
IN_MEMORY_DB = ":memory:"
|
||||
SOLVED_TABLE = "solved"
|
||||
|
||||
CAT_FOUND_VALUE = "found_value"
|
||||
CAT_IDENTIFIED_SYMBOL = "identified_symbol"
|
||||
CAT_PATCHED_BYTES = "patched_bytes"
|
||||
|
|
@ -118,7 +126,7 @@ class ProgressStore(Protocol):
|
|||
|
||||
class InMemoryProgress:
|
||||
"""
|
||||
A process-local progress store, swapped for SQLite in M4
|
||||
A process-local progress store used for tests and ephemeral runs
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
|
|
@ -129,3 +137,38 @@ class InMemoryProgress:
|
|||
|
||||
def solved(self, session: str) -> set[str]:
|
||||
return set(self._solved.get(session, set()))
|
||||
|
||||
|
||||
class SqliteProgress:
|
||||
"""
|
||||
A SQLite-backed progress store durable across restarts
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path | str = DEFAULT_DB_PATH):
|
||||
self._path = str(path)
|
||||
if self._path != IN_MEMORY_DB:
|
||||
Path(self._path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
self._conn = sqlite3.connect(self._path, check_same_thread=False)
|
||||
self._conn.execute(
|
||||
f"CREATE TABLE IF NOT EXISTS {SOLVED_TABLE} ("
|
||||
"session TEXT NOT NULL, "
|
||||
"challenge_id TEXT NOT NULL, "
|
||||
"PRIMARY KEY (session, challenge_id))")
|
||||
self._conn.commit()
|
||||
|
||||
def mark_solved(self, session: str, challenge_id: str) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
f"INSERT OR IGNORE INTO {SOLVED_TABLE} "
|
||||
"(session, challenge_id) VALUES (?, ?)",
|
||||
(session, challenge_id))
|
||||
self._conn.commit()
|
||||
|
||||
def solved(self, session: str) -> set[str]:
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
f"SELECT challenge_id FROM {SOLVED_TABLE} "
|
||||
"WHERE session = ?",
|
||||
(session,)).fetchall()
|
||||
return {row[0] for row in rows}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from fastapi.testclient import TestClient
|
|||
|
||||
from rveng.api.app import create_app
|
||||
from rveng.api.limits import MAX_HEX_BYTES, MAX_SESSION_LEN
|
||||
from rveng.api.store import load_store
|
||||
from rveng.api.store import InMemoryProgress, SqliteProgress, load_store
|
||||
|
||||
CHALLENGES = Path(__file__).resolve().parents[1] / "challenges"
|
||||
|
||||
|
|
@ -23,15 +23,15 @@ ELF_MAGIC_LINE = (
|
|||
|
||||
@pytest.fixture()
|
||||
def client() -> TestClient:
|
||||
return TestClient(create_app())
|
||||
return TestClient(create_app(progress=InMemoryProgress()))
|
||||
|
||||
|
||||
def test_list_has_the_seed_challenges(client: TestClient):
|
||||
body = client.get("/api/challenges").json()
|
||||
ids = {c["id"] for c in body}
|
||||
assert ids == {
|
||||
"03-flip-the-gate", "04-name-the-function", "05-find-the-gate",
|
||||
"06-stripped-gate"}
|
||||
"01-read-the-hex", "02-find-the-entry", "03-flip-the-gate",
|
||||
"04-name-the-function", "05-find-the-gate", "06-stripped-gate"}
|
||||
|
||||
|
||||
def test_detail_does_not_leak_source_or_answer(client: TestClient):
|
||||
|
|
@ -152,7 +152,7 @@ def test_progress_tracks_solves(client: TestClient):
|
|||
json={"answer": "1337", "session": "s1"})
|
||||
body = client.get("/api/progress?session=s1").json()
|
||||
assert body["solved"] == ["05-find-the-gate"]
|
||||
assert body["total"] == 4
|
||||
assert body["total"] == 6
|
||||
|
||||
|
||||
def test_disasm_resolves_import_call_names(client: TestClient):
|
||||
|
|
@ -206,6 +206,49 @@ def test_stripped_challenge_grades_found_value(client: TestClient):
|
|||
assert r["correct"] is True
|
||||
|
||||
|
||||
def test_sqlite_backed_progress_survives_a_fresh_app(tmp_path: Path):
|
||||
db = tmp_path / "progress.db"
|
||||
first = TestClient(create_app(progress=SqliteProgress(db)))
|
||||
first.post(
|
||||
"/api/challenges/05-find-the-gate/submit",
|
||||
json={"answer": "1337", "session": "s1"})
|
||||
reopened = TestClient(create_app(progress=SqliteProgress(db)))
|
||||
body = reopened.get("/api/progress?session=s1").json()
|
||||
assert body["solved"] == ["05-find-the-gate"]
|
||||
|
||||
|
||||
def test_hex_challenge_reveals_secret_string(client: TestClient):
|
||||
r = client.post(
|
||||
"/api/challenges/01-read-the-hex/submit",
|
||||
json={"answer": "the_flag_is_here"}).json()
|
||||
assert r["correct"] is True
|
||||
assert "the_flag_is_here" in r["revealed_source"]
|
||||
|
||||
|
||||
def test_hex_challenge_wrong_string_hides_source(client: TestClient):
|
||||
r = client.post(
|
||||
"/api/challenges/01-read-the-hex/submit",
|
||||
json={"answer": "wrong_string"}).json()
|
||||
assert r["correct"] is False
|
||||
assert r["revealed_source"] is None
|
||||
|
||||
|
||||
def test_entry_challenge_grades_hex_and_decimal(client: TestClient):
|
||||
for answer in ("0x401060", "4198496", "401060h"):
|
||||
r = client.post(
|
||||
"/api/challenges/02-find-the-entry/submit",
|
||||
json={"answer": answer}).json()
|
||||
assert r["correct"] is True, answer
|
||||
|
||||
|
||||
def test_entry_challenge_matches_engine_header(client: TestClient):
|
||||
body = client.get("/api/challenges/02-find-the-entry/elf").json()
|
||||
r = client.post(
|
||||
"/api/challenges/02-find-the-entry/submit",
|
||||
json={"answer": hex(body["entry"])}).json()
|
||||
assert r["correct"] is True
|
||||
|
||||
|
||||
def test_load_store_skips_malformed_dir(tmp_path: Path):
|
||||
root = tmp_path / "challenges"
|
||||
root.mkdir()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
test_progress.py
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from rveng.api.store import IN_MEMORY_DB, InMemoryProgress, SqliteProgress
|
||||
|
||||
|
||||
def test_sqlite_marks_and_reads_back(tmp_path: Path):
|
||||
progress = SqliteProgress(tmp_path / "p.db")
|
||||
progress.mark_solved("s1", "05-find-the-gate")
|
||||
assert progress.solved("s1") == {"05-find-the-gate"}
|
||||
|
||||
|
||||
def test_sqlite_isolates_sessions(tmp_path: Path):
|
||||
progress = SqliteProgress(tmp_path / "p.db")
|
||||
progress.mark_solved("s1", "05-find-the-gate")
|
||||
progress.mark_solved("s2", "03-flip-the-gate")
|
||||
assert progress.solved("s1") == {"05-find-the-gate"}
|
||||
assert progress.solved("s2") == {"03-flip-the-gate"}
|
||||
|
||||
|
||||
def test_sqlite_mark_is_idempotent(tmp_path: Path):
|
||||
progress = SqliteProgress(tmp_path / "p.db")
|
||||
progress.mark_solved("s1", "05-find-the-gate")
|
||||
progress.mark_solved("s1", "05-find-the-gate")
|
||||
assert progress.solved("s1") == {"05-find-the-gate"}
|
||||
|
||||
|
||||
def test_sqlite_persists_across_instances(tmp_path: Path):
|
||||
db = tmp_path / "p.db"
|
||||
SqliteProgress(db).mark_solved("s1", "05-find-the-gate")
|
||||
reopened = SqliteProgress(db)
|
||||
assert reopened.solved("s1") == {"05-find-the-gate"}
|
||||
|
||||
|
||||
def test_sqlite_unknown_session_is_empty(tmp_path: Path):
|
||||
progress = SqliteProgress(tmp_path / "p.db")
|
||||
assert progress.solved("nobody") == set()
|
||||
|
||||
|
||||
def test_sqlite_in_memory_creates_no_file(tmp_path: Path):
|
||||
progress = SqliteProgress(IN_MEMORY_DB)
|
||||
progress.mark_solved("s1", "05-find-the-gate")
|
||||
assert progress.solved("s1") == {"05-find-the-gate"}
|
||||
assert not any(tmp_path.iterdir())
|
||||
|
||||
|
||||
def test_in_memory_and_sqlite_share_the_protocol(tmp_path: Path):
|
||||
for progress in (InMemoryProgress(), SqliteProgress(tmp_path / "p.db")):
|
||||
progress.mark_solved("s1", "c1")
|
||||
assert progress.solved("s1") == {"c1"}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env bash
|
||||
# ©AngelaMos | 2026
|
||||
# uninstall.sh
|
||||
#
|
||||
# Mirror of install.sh: stop rveng, remove its containers, images, and volumes,
|
||||
# and delete the cached clone the installer made. Leaves Docker itself alone.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||
BOLD=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[31m'; GREEN=$'\033[32m'
|
||||
YELLOW=$'\033[33m'; CYAN=$'\033[36m'; RESET=$'\033[0m'
|
||||
else
|
||||
BOLD=""; DIM=""; RED=""; GREEN=""; YELLOW=""; CYAN=""; RESET=""
|
||||
fi
|
||||
|
||||
info() { printf '%s\n' " ${CYAN}+${RESET} $*" >&2; }
|
||||
ok() { printf '%s\n' " ${GREEN}+${RESET} $*" >&2; }
|
||||
warn() { printf '%s\n' " ${YELLOW}!${RESET} $*" >&2; }
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
find_project() {
|
||||
if [ -f "./compose.yml" ] && [ -f "./infra/docker/api.dockerfile" ]; then
|
||||
pwd; return
|
||||
fi
|
||||
local self="${BASH_SOURCE[0]:-}"
|
||||
if [ -n "$self" ] && [ -f "$(dirname "$self")/compose.yml" ]; then
|
||||
(cd "$(dirname "$self")" && pwd); return
|
||||
fi
|
||||
local cache="${XDG_CACHE_HOME:-$HOME/.cache}/rveng-src/PROJECTS/advanced/rveng"
|
||||
[ -f "$cache/compose.yml" ] && printf '%s\n' "$cache"
|
||||
}
|
||||
|
||||
printf '\n%s\n\n' "${BOLD}${CYAN}--- removing rveng ---${RESET}" >&2
|
||||
|
||||
if have docker; then
|
||||
PROJECT="$(find_project || true)"
|
||||
if [ -n "${PROJECT:-}" ] && [ -d "$PROJECT" ]; then
|
||||
info "stopping stacks in $PROJECT"
|
||||
( cd "$PROJECT" && docker compose down -v --remove-orphans 2>/dev/null || true )
|
||||
( cd "$PROJECT" && docker compose -f dev.compose.yml down -v --remove-orphans 2>/dev/null || true )
|
||||
ok "containers and volumes removed"
|
||||
else
|
||||
warn "could not find the project dir; skipping compose down"
|
||||
fi
|
||||
else
|
||||
warn "docker not found; nothing to stop"
|
||||
fi
|
||||
|
||||
CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/rveng-src"
|
||||
if [ -d "$CACHE" ]; then
|
||||
info "removing cached clone at $CACHE"
|
||||
rm -rf "$CACHE"
|
||||
ok "cache removed"
|
||||
fi
|
||||
|
||||
printf '\n%s\n\n' " ${GREEN}${BOLD}rveng removed.${RESET}" >&2
|
||||
printf '%s\n' " ${DIM}Docker itself was left installed.${RESET}" >&2
|
||||
Loading…
Reference in New Issue