Chainspill is a deliberately vulnerable attack-defense CTF service for ForcAD-style labs. The theme is a legacy spillway / hydroelectric control panel that stores user "memory chains" — ordered collections of text nodes with public or private visibility.
Warning: Python 2 is long EOL. Run this only inside an isolated CTF lab container. Never expose to the internet or reuse as an application template.
docker compose up --buildService listens on http://127.0.0.1:8080.
SQLite database and export cache live in /app/storage inside the container.
The root page (/) serves an interactive Chainspill operator console.
Session token signing uses a hardcoded legacy secret in app/config.py.
All authenticated endpoints require Authorization: Bearer <token>.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/health |
— | Service liveness check |
POST |
/register |
— | Create account: {"username":"…","password":"…"} |
POST |
/login |
— | Obtain token: {"username":"…","password":"…"} |
POST |
/chain/create |
user | Create chain: {"title":"…","visibility":"private|public"} |
POST |
/chain/node/add |
user | Add node: {"chain_id":"…","body":"…","visibility":"private|public"} |
POST |
/chain/node/edit |
user | Edit node: {"node_id":"…","body":"…"} |
GET |
/chain/<id> |
user | Read chain and its visible nodes |
POST |
/chain/export |
user | Export chain as pickle, or read a cached file by filename |
POST |
/chain/import |
user | Import chain from base64 pickle |
GET |
/debug/profile?label=<string> |
debug | Profile a chain's private nodes |
GET |
/debug/readahead?chain_id=<id>&rounds=<n> |
debug | Threaded readahead prefetch for a chain's cache file |
No third-party dependencies required.
# Verify service health
python checker/checker.py check 127.0.0.1
# Place a flag (prints JSON state)
python checker/checker.py put 127.0.0.1 <flag_id> FORCAD_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
# Retrieve a flag
python checker/checker.py get 127.0.0.1 '<json state>' FORCAD_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFlags are stored in private nodes owned by a fresh user created per flag round.
The flag format is FORCAD_[A-Z0-9]{32}.
The service has multiple intended attack paths with different difficulty levels.
1. Hardcoded token secret
LEGACY_TOKEN_SECRET in app/config.py is a well-known static string.
Anyone who reads the source can register a normal user, forge that user's token with a
debug role, and call debug endpoints. This is the entry point for the harder
vulnerabilities below.
2. Path traversal in export filename
POST /chain/export with a filename field resolves paths relative to the authenticated
user's cache subdirectory, but the boundary check is against the top-level STORAGE_ROOT
rather than the user's own directory. A traversal like ../<other_user_id>/<chain>.pkl
reaches another user's cached pickle, leaking their private node contents.
3. Pickle import trusts owner_id
POST /chain/import deserialises a base64 pickle and uses the owner_id field from the
payload directly. An attacker can craft or replay a leaked pickle while changing
owner_id, visibility, and chain_id, making the imported chain readable as their own.
4. CVE-2018-1000030-inspired race condition in /debug/readahead
GET /debug/readahead drives unsafe_threaded_readahead(), which stores the current
target user, target chain, read offset, and shared cache file object in
READAHEAD_STATE without any locking. Concurrent worker threads race while seeking and
reading that shared file object, so one session can receive chunks from another user's
chain cache file.
5. Python-level pack_string overflow in /debug/profile
GET /debug/profile?label=<string> writes the label into a 48-byte fixed buffer via
pack_string_legacy(). The endpoint accepts no chain_id URL parameter — the debug
session starts targeting the caller's own most-recent chain. DebugSession stores that
label buffer, target_chain_id, and owner_id in one contiguous Python bytearray.
The legacy copy loop writes attacker input linearly across the whole frame instead of
stopping at the label limit, so A * 48 + <chain_id> overwrites the adjacent
target_chain_id field and redirects the profile dump to another chain. This is a safe
service-level simulation of the old Python 2 hotshot pack_string class of bug, not
native CPython memory corruption.
| Vulnerability | Hint |
|---|---|
| Hardcoded token secret | Replace LEGACY_TOKEN_SECRET with a strong secret outside the source tree and rotate existing tokens |
| Path traversal | Tighten the boundary check in read_constrained_filename to the user's own cache subdirectory |
Pickle owner_id trust |
Force owner_id = request.chainspill_user['id'] in import_chain_object; ignore the payload field |
| Race condition | Protect READAHEAD_STATE with a threading.Lock in legacy.py |
pack_string overflow |
Reject labels longer than PROFILE_LABEL_LIMIT, or store debug fields outside the packed buffer |
- Target runtime: Python 2.7.14 (
python:2.7.14-stretchimage). - Storage volume:
chainspill_storage— wipe withdocker compose down -vbetween rounds during testing. - The operator UI at
/covers all endpoints and is useful for manual interaction.