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:
CarterPerez-dev 2026-07-05 22:14:39 -04:00
parent 01dca71765
commit 1d9668ee34
29 changed files with 1799 additions and 8 deletions

View File

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

View File

@ -8,3 +8,5 @@ __pycache__/
.pytest_cache/
.venv/
*.pyc
data/

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

@ -12,6 +12,7 @@ dist
dist-ssr
*.local
.vite
.pnpm-store
# Editor directories and files
.vscode/*

View File

@ -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"]

View File

@ -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"]

View File

@ -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

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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(

View File

@ -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}

View File

@ -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()

View File

@ -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"}

View File

@ -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