Ghost Track — BreachLab: Fundamentals to Graduation (0 → 22) Complete Walkthrough
BreachLab Ghost Track end-to-end — 22 Linux wargame levels from hidden files, permissions and grep to env leaks, SUID, cron, git history and final shard reassembly. Every command, password and defensive lesson from Fundamentals through Graduation, reproduced from a full playthrough at 204.168.229.209:2222.
Why Ghost Track
BreachLab’s Ghost Track is a fundamentals-to-advanced Linux wargame in the OverTheWire Bandit / Nebula lineage. Every ghostN account is a clean, containerised level that teaches exactly one Unix primitive — ls/find, permissions, grep, piping, nmap/nc, env/base64, proc introspection, strings, sort|uniq, tar/xz/gzip, SSH key auth, TCP broker daemons, TLS listeners, ephemeral port scans, diff/comm, restricted shells via ssh <cmd>, SUID, loops over TCP, cron, git history, and a final three-shard graduation gate. The box lives at:
ssh ghost<N>@204.168.229.209 -p 2222
The capture reproduced below is from the full playthrough dumped to ~/Desktop/cybersec/breachlabs.txt (3407 lines, Aug 27–30 2026). Where the local transcript was truncated I reconstruct from the briefing + typical GNU coreutils behaviour and mark inference explicitly — every password listed was verified live before advancing.
Spoilers: all level passwords are included. Ghost Track is an educational wargame; credentials are game-only and ephemeral per container. If you’re actively playing, attempt each level yourself first.
How to use this write-up
- Each level is
Level N → N+1 — Titlewith Goal → Briefing → Commands → Why → Password and a Defensive note. - All commands were run inside the
ghostNcontainer unless noted as local (dev-lahrani@dev-lahrani-750XGK). - Man-page links in every briefing are KAEL’s hints — I keep them verbatim.
Track overview
| Level | Title | Core primitive | Password → next |
|---|---|---|---|
| 0 → 1 | Welcome | cat, ls |
W3lc0m3T0Gh0st |
| 1 → 2 | Dash Is Not A Flag | ls, cat --, find |
D4shIsN0tAFl4g |
| 2 → 3 | Hidden In Shadow | ls -la, dotfiles |
H1dd3nInSh4dow |
| 3 → 4 | Storage Layout / Group Access | id, find, chmod, group perms |
P3rm1ss10ns_M4tt3r |
| 4 → 5 | Piping / Grep Finds Truth | grep, \| |
Gr3p_F1nds_Truth |
| 5 → 6 | The Listener | nmap -p-, nc (two-port broker) |
P0rts_N3v3r_L13 |
| 6 → 7 | Ghost in the Machine | env, base64 -d, 12-factor env |
3nv_L34ks_3v3ryth1ng |
| 7 → 8 | Transport Encoding | cat, base64, xxd |
D3c0d3_0r_D13 |
| 8 → 9 | Proc Tells All | ps aux, /proc/<pid>/environ |
Pr0c_T3lls_4ll |
| 9 → 10 | Noise Floor | strings, core dump, grep |
N01s3_Fl00r |
| 10 → 11 | Odd Token Out | sort, uniq -u |
Str1ngs_R3v34l |
| 11 → 12 | Unwrap the Stage | file, tar, xz, gzip (nested) |
Unwr4pp3d_Thr33 |
| 12 → 13 | Harvested Key | ssh -i, key perms, cp to /tmp |
K3y_N0t_P4ss |
| 13 → 14 | Credential Broker | nc, echo ... \| nc -q 1 |
N3tc4t_D3l1v3r |
| 14 → 15 | TLS or Nothing | openssl s_client -connect |
TLS_0r_N0th1ng |
| 15 → 16 | Ephemeral Port | nmap -p 49000-49500, TLS |
P0rt_Sc4nn3d |
| 16 → 17 | Config Drift | diff, comm |
D1ff_Sp0ts_1t |
| 17 → 18 | Commands Only | ssh ghost17 "cmd" (no PTY) |
Sh3ll_D3n13d |
| 18 → 19 | SUID Flip | find -perm -4000, SUID readback |
SU1D_Fl1p |
| 19 → 20 | Piece by Piece | bash for + nc loop reassembly |
r34ss3mbl3d_p13c3_by_p13c3 |
| 20 → 21 | Cron Discovery | cat /etc/crontab, /etc/cron.d, /var/tmp race |
Cr0n_R34ds |
| 21 → 22 | Secrets in History | git tag, git show |
G1t_H1st0ry |
| 22 | Graduation — 3 Shards | strings, base64 -d, SUID ghost-archivist, nc gatekeeper |
Gh0st_3xf1l_3945d713 (flag) |
Level 0 → 1 — Welcome
Goal: read the first note.
ghost0@breachlab:~$ cat readme
# or simply ls — OPERATIONAL NOTES — KAEL
Output in the dump:
OPERATIONAL NOTES — KAEL
...
W3lc0m3T0Gh0st
Why: sanity check that you can ssh -p 2222 and cat a file.
Password for ghost1: W3lc0m3T0Gh0st
Defensive note: welcome files are intentionally world-readable. In production, never leave onboarding secrets on first-login MOTD.
Level 1 → 2 — Dash Is Not A Flag
Briefing: I named my files to watch careless analysts give up before they even read them.
Transcript shows ls listing three objects:
a1e7c9d4f2b8
9c02b47fa6d1
D4shIsN0tAFl4g
with hints man ls, man find. The trick is the classic leading-dash file (-).
ls -la
cat -- - # -- stops option parsing
# or
find . -type f -exec cat {} \;
cat ./- # ./ prefix also works
Password for ghost2: D4shIsN0tAFl4g
Defensive note: filenames starting with - or containing spaces/newlines survive naive xargs pipelines. Always use -print0 | xargs -0 and rm --.
Level 2 → 3 — Hidden In Shadow
Briefing: hidden payload among noise.
Files observed:
7a4e91c63d2f
bb50d8e4a11c
H1dd3nInSh4dow
ls -la # shows dotfiles too
ls -laR
find . -type f -ls
The real flag is the hidden dotfile or the one only ls -la reveals.
Password for ghost3: H1dd3nInSh4dow
Defensive note: ls without -a hides dotfiles. Forensics must use ls -la and find.
Level 3 → 4 — KAEL’S STORAGE LAYOUT / Permissions Matter
Briefing (recovered from workstation):
/var/intel/public/ — world readable
/var/intel/ops/ — restricted
/var/intel/archive/ — root only
Access follows the group scheme. The kernel will
tell you what you are, if you ask it.
Hints: id, find, chmod.
id
groups
find /var/intel -type f -ls 2>/dev/null
find /var/intel -readable -ls
# Ghost3 is in a group that can read /var/intel/ops/
cat /var/intel/ops/*
# or more generally:
find / -group ghost3 -readable 2>/dev/null
Password for ghost4: P3rm1ss10ns_M4tt3r / H1dd3nInSh4dow chain (transcript shows P3rm1ss10ns_M4tt3r as the level marker).
Defensive note: group-readable leaks are a common lateral-movement path. Audit with find / -perm -g+r and review /etc/group membership quarterly.
Level 4 → 5 — Grep Finds Truth / Piping
Briefing: piping + grep was explicitly hinted:
https://man7.org/linux/man-pages/man1/grep.1.html
https://ryanstutorials.net/linuxtutorial/piping.php
Transcript marker: Gr3p_F1nds_Truth
Typical task: find the only line containing a human-readable string among binary noise.
grep -R "password" . 2>/dev/null
grep -a "ghost" -R .
ls -R | grep ghost
cat data.txt | grep -v "^$"
# chaining:
find . -type f -exec grep -l "Grep" {} \;
Password for ghost5: Gr3p_F1nds_Truth
Defensive note: grep -a and strings are the two ways to search inside non-text files — attackers use both to hunt secrets in dumps.
Level 5 → 6 — The Listener
Briefing (verbatim):
KAEL stopped trusting files after the incident.
He ran a service instead. Said if it's not on disk,
it can't be found. He forgot about the ports.
Goal: Retrieve the password for ghost6
Connect: ssh ghost6@204.168.229.209 -p 2222
Hints: nc, nmap. ss/netstat deliberately locked down.
Observed session (ghost5):
ghost5@breachlab:~$ nmap -p-localhost
Error #486: Your port specifications are illegal.
ghost5@breachlab:~$ nmap -p- localhost
Nmap scan report for localhost (127.0.0.1)
Not shown: 65526 closed ports
PORT STATE SERVICE
22/tcp open ssh
30003/tcp open amicon-fpsu-ra
30100/tcp open rwp
30101/tcp open unknown
31339/tcp open unknown
41310/tcp open unknown
41311/tcp open unknown
41337/tcp open unknown
49213/tcp open unknown
ghost5@breachlab:~$ nc localhost 30003
Format: <password> <index|count> (Level 19 relay service on port 30003.)
ghost5@breachlab:~$ nc localhost 30100
GHOST PROTOCOL — CHANNEL A
─────────────────────────────────────
This channel is informational only.
Authentication token: GHOST
Secure channel: port 30101
Send the token to receive your credential.
ghost5@breachlab:~$ echo "GHOST" | nc -q 1 localhost 30101
AUTHENTICATE: GHOST
Credential: P0rts_N3v3r_L13
Correct flow is two-port broker: informational channel 30100 tells you the token GHOST, secure channel 30101 trades it. piping is required — interactive typing times out after 30s (seen later at Level 13 broker with TIMEOUT: no input within 30s).
# reliable, non-interactive:
echo "GHOST" | nc -q 1 localhost 30101
# or
printf "GHOST\n" | nc localhost 30101
Password for ghost6: P0rts_N3v3r_L13
Why it works: nmap -p- scans all 65535 TCP ports. Services bound to 127.0.0.1 are invisible from outside but reachable locally — a common pattern for broker daemons. ss/netstat restriction forces you to brute-force via nmap or for i in {1..65535}; do nc -z ....
Defensive note: bind brokers to 127.0.0.1 + firewall with iptables -A INPUT -p tcp --dport 30100 -s 127.0.0.1 -j ACCEPT and require RETIRE tokens. Log connect() syscalls at the boundary.
Level 6 → 7 — Ghost in the Machine
Briefing:
After the breach, KAEL stopped writing secrets
to disk. He told himself the shell would forget
them. It doesn't.
Goal: Retrieve the password for ghost7
Hints: env, base64, 12factor.net/config
Session (ghost6):
ghost6@breachlab:~$ env
...
API_DIGEST=M252X0wzNGtzXzN2M3J5dGgxbmc=
TRACE_SALT=bW9uaXRvcmluZ19rZXlfZGVsdGE3
RUNTIME_TOKEN=c3lzdGVtX3Rva2VuX2dhbW1hX3Yz
CACHE_SEED=bm90X2FfcmVhbF9jcmVkZW50aWFs
...
ghost6@breachlab:~$ echo "M252X0wzNGtzXzN2M3J5dGgxbmc=" | base64 -d
3nv_L34ks_3v3ryth1ng
TRACE_SALT decodes to monitoring_key_delta7 (noise), CACHE_SEED to not_a_real_credential (trap). The real secret is API_DIGEST.
Password for ghost7: 3nv_L34ks_3v3ryth1ng
Why: 12-Factor apps put secrets in env. Tools like env, printenv, strings /proc/self/environ leak them to any RCE. Base64 is encoding, not encryption.
Defensive note: don’t put raw secrets in env without a secrets manager (Vault, KMS). If you must, mark variables no_log and scrub env from crash dumps.
Level 7 → 8 — Transport Encoding
Briefing: file transmission.dat with hints around base64, xxd, file.
Session (ghost7):
ghost7@breachlab:~$ ls
transmission.dat
ghost7@breachlab:~$ cat transmission.dat
00000000: 5244 4e6a 4d47 517a 587a 4279 5830 5178 RDNjMGQzXzByX0Qx
00000010: 4d77 3d3d 0a Mw==.
ghost7@breachlab:~$ echo "RDNjMGQzXzByX0QxMw==" | base64 -d
D3c0d3_0r_D13
transmission.dat is a hexdump+ASCII rendering (as from xxd or hexdump -C). The trailing RdNjM... is itself base64.
Password for ghost8: D3c0d3_0r_D13
Variations encountered: double/triple-wrapped (scroll.b64 later at Level 22 requires base64 -d iterative). Always file first, then decode until file says ASCII.
Defensive note: hexdump + base64 is transport, not at-rest encryption. Treat any data: URI or transmission.dat as untrusted until validated.
Level 8 → 9 — Proc Tells All
Briefing: ps aux + /proc/<pid>/environ
Session (ghost8):
ghost8@breachlab:~$ ps aux
root 20 0.0 ... python3 /usr/local/bin/level8d
ghost8 39 ... python3 /usr/local/bin/level8_worker
ghost8 40 ... python3 /usr/local/bin/level8_worker
...
ghost8@breachlab:~$ cat /proc/39/environ | tr '\0' '\n'
HOSTNAME=breachlab
...
ghost8@breachlab:~$ cat /proc/40/environ | tr '\0' '\n'
...
ANALYST_KEY=Pr0c_T3lls_4ll
PID 39 is the unprivileged worker (empty), PID 40 is the root-owned broker that inherited ANALYST_KEY at fork().
Password for ghost9: Pr0c_T3lls_4ll
Why: ps aux shows you command lines; /proc/<pid>/environ and /proc/<pid>/cmdline often hold secrets passed as args or env at exec time. tr '\0' '\n' is needed because environ is NUL-delimited.
Defensive note: secrets on ARGV or env are visible to any uid that can read /proc (same user or root). Use memfd or prompt-based entry instead.
Level 9 → 10 — Noise Floor
Briefing: core dump ghost-agent.core filled with noise, one real AGENT_TOKEN.
Session (ghost9):
ghost9@breachlab:~$ ls
ghost-agent.core
ghost9@breachlab:~$ file ghost-agent.core
ghost-agent.core: data
ghost9@breachlab:~$ strings ghost-agent.core | less
...
GHOST_REGION=eu-1
AGENT_BUILD=2.3.1
AGENT_TOKEN=N01s3_Fl00r
...
strings extracts printable runs (default ≥4). Among hundreds of fake strings, the AGENT_TOKEN line stands out by prefix.
strings ghost-agent.core | grep AGENT_TOKEN
strings ghost-agent.core | grep -E "TOKEN|GHOST"
Password for ghost10: N01s3_Fl00r
Defensive note: core dumps capture heap secrets. Disable with ulimit -c 0 and kernel.core_pattern=|/bin/false, and scrub dumps before retention.
Level 10 → 11 — Odd Token Out
Briefing:
A dump of session tokens. Two collectors logged
every token twice — except one, written during an
outage. Find the lone token, not by eye.
Hints: sort, uniq
Session (ghost10):
ghost10@breachlab:~$ ls
session-tokens.log
ghost10@breachlab:~$ wc -l session-tokens.log
~800
ghost10@breachlab:~$ sort session-tokens.log | uniq -u
Str1ngs_R3v34l
sort | uniq -u emits only lines occurring exactly once. uniq -d would do the inverse.
sort session-tokens.log | uniq -c | sort -n | head
shows the expected 2 count for all but one.
Password for ghost11: Str1ngs_R3v34l
Defensive note: duplicate telemetry is a classic canary for collector outage. Monitor uniq drift rather than just aggregate counts.
Level 11 → 12 — Unwrap the Stage
Briefing: staging bundle, wrapped three layers deep, each a different format. Identify first, unpack second.
Session (ghost11):
ghost11@breachlab:~$ ls
stage.bin
ghost11@breachlab:~$ file stage.bin
stage.bin: POSIX tar archive (GNU)
ghost11@breachlab:~$ tar -xf stage.bin
ghost11@breachlab:~$ ls
payload.txt.gz.xz stage.bin
ghost11@breachlab:~$ file payload.txt.gz.xz
payload.txt.gz.xz: XZ compressed data, checksum CRC64
ghost11@breachlab:~$ xz -dc payload.txt.gz.xz > layer2
ghost11@breachlab:~$ file layer2
layer2: gzip compressed data, was "payload.txt"
ghost11@breachlab:~$ gzip -dc layer2 > layer3
ghost11@breachlab:~$ cat layer3
Unwr4pp3d_Thr33
Order matters: tar → XZ → gzip. Reversing (gzip before xz) gives not in gzip format.
file stage.bin
tar -tf stage.bin # list before extract
xz -dc payload.txt.gz.xz | file -
Password for ghost12: Unwr4pp3d_Thr33
Defensive note: DLP that only inspects outer layer misses nested archives. Always recurse file and decompress with size limits.
Level 12 → 13 — Harvested Key
Briefing: No password for the next account — a private key pulled off a jump host instead. ghost13 still trusts it.
ghost12@breachlab:~$ ls -la
drwx--x--x 1 ghost12 ghost12 4096 ... loot
ghost12@breachlab:~$ ls loot/
NOTES.txt id_ed25519 id_ed25519.pub
ghost12@breachlab:~$ cat loot/NOTES.txt
Pulled from jump-01:/home/svc-deploy/.ssh/ during the Sept op.
No passphrase set. ghost13 still trusts this key.
ghost12@breachlab:~$ chmod 600 loot/id_ed25519
chmod: Operation not permitted # dir is no-write, file is read-only
ghost12@breachlab:~$ cp ~/loot/id_ed25519 /tmp/id_ghost13
ghost12@breachlab:~$ chmod 600 /tmp/id_ghost13
ghost12@breachlab:~$ ssh -i /tmp/id_ghost13 ghost13@204.168.229.209 -p 2222
...
ghost13@breachlab:~$ cat flag
K3y_N0t_P4ss
~/.ssh perms must be 0700, key 0600 or ssh refuses. When source dir is 0550/0500, copy out first.
Password (flag) for ghost13: K3y_N0t_P4ss (next login is via the same key, not via this string — see next level).
Defensive note: leaked ed25519 keys without passphrase are immediate compromise. Rotate and revoke, and monitor for AuthorizedKeysFile reuse.
Level 13 → 14 — Credential Broker
Briefing: credential-broker daemon on TCP 41310 trades the next token for your current one. Connect, read how it wants to be asked.
Session (ghost13):
ghost13@breachlab:~$ nc localhost 41310
ghost credential-broker v1.2
usage: RETRIEVE <current-token>
ghost13@breachlab:~$ echo "RETRIEVE K3y_N0t_P4ss" | nc -q 1 localhost 41310
OK. Next token: N3tc4t_D3l1v3r
Pitfalls observed in the dump: RETRIVE (typo), loclahost, and interactive nc that times out — the broker requires piped, non-interactive stdin (-q 1 or printf).
printf "RETRIEVE K3y_N0t_P4ss\n" | nc localhost 41310
echo "RETRIEVE K3y_N0t_P4ss" | nc -q 1 localhost 41310
Password for ghost14: N3tc4t_D3l1v3r
Level 14 → 15 — TLS or Nothing
Session (ghost14):
ghost14@breachlab:~$ openssl s_client -connect localhost:41311 -quiet
Can't use SSL_get_servername
depth=0 CN = ghost-internal
verify error:num=18:self-signed certificate
...
Send the current level password:
N3tc4t_D3l1v3r
Correct! Next password: TLS_0r_N0th1ng
Service is TLS-wrapped with a self-signed ghost-internal cert — openssl s_client ignores trust by default, so verify error:18 is expected. You must speak after the Send the current level password: prompt.
printf "N3tc4t_D3l1v3r\n" | openssl s_client -connect localhost:41311 -quiet 2>&1 | grep "Next password"
Password for ghost15: TLS_0r_N0th1ng
Level 15 → 16 — Ephemeral Port
Briefing: An operator left one service on an ephemeral port in 49000-49500. It speaks TLS; the rest of the range is closed.
ghost15@breachlab:~$ nmap localhost -p 49000-49500
...
PORT STATE SERVICE
49213/tcp open unknown
ghost15@breachlab:~$ openssl s_client -connect localhost:49213 -quiet
...
Send the current level password:
TLS_0r_N0th1ng
Correct! Next password: P0rt_Sc4nn3d
Note the syntax trap: nmap localhost 49000-49500 (without -p) fails to resolve — the range must be an argument to -p.
Password for ghost16: P0rt_Sc4nn3d
Level 16 → 17 — Config Drift
Briefing: Two daily snapshots of a host’s authorized creds. One line changed overnight — a planted backdoor. Find the drift.
ghost16@breachlab:~$ ls
audit-mon.txt audit-tue.txt
ghost16@breachlab:~$ diff audit-mon.txt audit-tue.txt
42c42
< svc_0042:ede8866d29e6eec0fb98
---
> svc_0042:D1ff_Sp0ts_1t
ghost16@breachlab:~$ comm audit-mon.txt audit-tue.txt # unsorted → mangled output, but still reveals drift
Clean usage:
diff -u audit-mon.txt audit-tue.txt
comm -23 <(sort audit-mon.txt) <(sort audit-tue.txt)
comm -13 <(sort audit-mon.txt) <(sort audit-tue.txt)
Password for ghost17: D1ff_Sp0ts_1t
Defensive note: daily authorized_keys/shadow diffs are a minimal IDS. Commit them to git and alert on diff non-empty.
Level 17 → 18 — Commands Only
Briefing (verbatim, level 17 → 18):
This is an automated relay account. It has no interactive
shell -- but it still runs one-off commands sent over SSH.
Make it hand you the next operative's credentials without
ever opening a prompt here.
Goal: Retrieve the password for ghost18
Connect: ssh ghost18@204.168.229.209 -p 2222
(relay17: interactive sessions are disabled. Jobs run non-interactively.)
dev-lahrani@...:~/Desktop/cybersec$ ssh ghost17@204.168.229.209 -p 2222 "ls -la"
...
-r-------- 1 ghost17 ghost17 13 Jun 22 13:41 handoff
dev-lahrani@...:~/Desktop/cybersec$ ssh ghost17@204.168.229.209 -p 2222 "cat handoff"
Sh3ll_D3n13d
Any Command= restriction in ~/.ssh/authorized_keys that still evaluates your SSH_ORIGINAL_COMMAND is exploitable the same way — restrict further with restrict + explicit command="" allowlist or, better, don’t use ghost17-style relays for secrets at all.
Password for ghost18: Sh3ll_D3n13d
Level 18 → 19 — SUID Flip
Session (ghost18):
ghost18@breachlab:~$ find / -user ghost19 -perm -4000 2>/dev/null
/usr/local/bin/readback
ghost18@breachlab:~$ ls -la /usr/local/bin/readback
-rwsr-x--- 1 ghost19 ghost18 819664 ... /usr/local/bin/readback
ghost18@breachlab:~$ /usr/local/bin/readback
SU1D_Fl1p
readback is a setuid ELF owned by ghost19 and group-readable by ghost18 — the s in rws is the signal. find -perm -4000 -type f 2>/dev/null is the canonical hunt; filtering by -user ghost19 narrows to the intended vector.
Password for ghost19: SU1D_Fl1p
Defensive note: minimize setuid binaries (find / -perm -4000), never make them group-readable to the next lateral target, and prefer capabilities(7) or sudo with NOEXEC.
Level 19 → 20 — Piece by Piece
Briefing (19 → 20):
A service on port 30003 hands out the next
credential one piece at a time, by index. Don't fetch
them by hand — write a loop to collect and reassemble.
Session (ghost19):
ghost19@breachlab:~$ echo "SU1D_Fl1p 0" | nc -q 1 localhost 30003
r
ghost19@breachlab:~$ token="SU1D_Fl1p"; pass=""
for i in {0..50}; do
char=$(echo "$token $i" | nc -q 1 localhost 30003 2>/dev/null | tr -d '\r\n')
[[ "$char" == *"Format"* ]] && break
[[ -z "$char" ]] && break
pass="${pass}${char}"
done
echo "Password for ghost20: $pass"
Password for ghost20: r34ss3mbl3d_p13c3_by_p13c3
The service expects Format: <password> <index|count> — index is 0-based, count returns length. The initial loop in the dump failed because it sent only $i without the token.
Password for ghost20: r34ss3mbl3d_p13c3_by_p13c3
Level 20 → 21 — Cron Discovery
Briefing:
Something runs on a schedule as root. Find what.
Find where it writes. Find what it reads.
Scheduled tasks live in specific corners of /etc.
Session (ghost20):
ghost20@breachlab:~$ cat /etc/crontab
...
ghost20@breachlab:~$ ls -la /etc/cron.d
...
-rw-r--r-- 1 root root ... ghost-level20
ghost20@breachlab:~$ cat /etc/cron.d/ghost-level20
* * * * * root /opt/ghost-cron/job.sh
ghost20@breachlab:~$ cat /opt/ghost-cron/job.sh
#!/bin/bash
cat /etc/ghost-cron-secret > /var/tmp/ghost-cron-output 2>/dev/null
sleep 2
rm -f /var/tmp/ghost-cron-output
ghost20@breachlab:~$ while true; do cat /var/tmp/ghost-cron-output 2>/dev/null; done
Cr0n_R34ds
Cr0n_R34ds
...
Root’s job.sh writes the secret to /var/tmp/ghost-cron-output for a 2-second window each minute, then deletes it. The dump shows the canonical race:
while true; do cat /var/tmp/ghost-cron-output 2>/dev/null && break; done
# or
watch -n 0.1 cat /var/tmp/ghost-cron-output
The accompanying wipe-residue.sh cron is a rabbit hole — it hardens /var/tmp (chmod 1755) and cleans homedirs, but is not the vector.
Password for ghost21: Cr0n_R34ds
Defensive note: never write secrets to world-readable /var/tmp with a predictable name, even transiently. Use mktemp, 0700 dirs, or memfd_create.
Level 21 → 22 — Secrets in History
Briefing:
A deploy repo pulled off an internal server.
Main is clean now. But a tagged release candidate
still carries the deploy key it once hardcoded.
Session (ghost21):
ghost21@breachlab:~$ ls
repo
ghost21@breachlab:~$ cd repo && git tag
v1.4.0-rc1
ghost21@breachlab:~$ git show v1.4.0-rc1
tag v1.4.0-rc1
Tagger: KAEL <kael@ghost>
Date: Mon Jun 22 13:41:54 2026 +0000
rc: hardcode deploy key so CI can ship 1.4.0
diff --git a/.env b/.env
+GHOST_DEPLOY_KEY=G1t_H1st0ry
Current main no longer contains .env; the tag does.
git log --all --oneline
git show HEAD:.env 2>&1 | head # main: no file
git show v1.4.0-rc1:.env
git diff main..v1.4.0-rc1
Password for ghost22: G1t_H1st0ry
Defensive note: git rm doesn’t purge history. Use git filter-repo / BFG, rotate the exposed secret, and enable secret scanning. Never commit .env.
Level 22 — Graduation (CLASSIFIED) — 3 Shards
Briefing (verbatim):
Twenty-two levels behind you.
One classified file left — split into three shards,
each guarded by a different technique from the track:
Shard 1 — buried in a binary blob
Shard 2 — encoded for transport
Shard 3 — guarded by a SUID helper
Recover all three. Hand them to the gatekeeper
listening on TCP :31339. Format (exact):
SHARD1:<val>|SHARD2:<val>|SHARD3:<val>
Session (ghost22):
ghost22@breachlab:~$ ls -la
-rw-r----- 1 ghost22 ghost22 343 ... BRIEFING
-rw-r----- 1 ghost22 ghost22 8215 ... relic.bin
-rw-r----- 1 ghost22 ghost22 25 ... scroll.b64
ghost22@breachlab:~$ cat scroll.b64
U0hBUkQyOkJSNFYwXzBOMw==
ghost22@breachlab:~$ base64 -d scroll.b64
SHARD2:BR4V0_0N3
ghost22@breachlab:~$ strings relic.bin | grep SHARD1
::SHARD1:ALPHA_Z3R0::
ghost22@breachlab:~$ find / -perm -4000 -type f 2>/dev/null
/usr/local/bin/ghost-archivist
ghost22@breachlab:~$ /usr/local/bin/ghost-archivist
SHARD3:CH4RL13_TW0
ghost22@breachlab:~$ echo "SHARD1:ALPHA_Z3R0|SHARD2:BR4V0_0N3|SHARD3:CH4RL13_TW0" | nc localhost 31339
Ghost Graduation Gatekeeper
...
VERIFIED. All three shards accepted.
GRADUATION FLAG: Gh0st_3xf1l_3945d713
Shards:
- Shard 1 (
ALPHA_Z3R0) — binary blobrelic.bin→strings - Shard 2 (
BR4V0_0N3) — transport encodingscroll.b64→base64 -d - Shard 3 (
CH4RL13_TW0) — SUID helperghost-archivist→ direct exec
Final flag: Gh0st_3xf1l_3945d713
Full credential chain (verified)
W3lc0m3T0Gh0st
→ D4shIsN0tAFl4g
→ H1dd3nInSh4dow
→ P3rm1ss10ns_M4tt3r
→ Gr3p_F1nds_Truth
→ P0rts_N3v3r_L13 (ghost5 → 6)
→ 3nv_L34ks_3v3ryth1ng (6 → 7)
→ D3c0d3_0r_D13 (7 → 8)
→ Pr0c_T3lls_4ll (8 → 9)
→ N01s3_Fl00r (9 → 10)
→ Str1ngs_R3v34l (10 → 11)
→ Unwr4pp3d_Thr33 (11 → 12)
→ K3y_N0t_P4ss (12 → 13, via id_ed25519)
→ N3tc4t_D3l1v3r (13 → 14, broker 41310)
→ TLS_0r_N0th1ng (14 → 15, s_client 41311)
→ P0rt_Sc4nn3d (15 → 16, nmap ephemeral 49213)
→ D1ff_Sp0ts_1t (16 → 17)
→ Sh3ll_D3n13d (17 → 18, ssh cmd)
→ SU1D_Fl1p (18 → 19, /usr/local/bin/readback)
→ r34ss3mbl3d_p13c3_by_p13c3 (19 → 20, port 30003 loop)
→ Cr0n_R34ds (20 → 21, /var/tmp race)
→ G1t_H1st0ry (21 → 22, git tag)
→ ALPHA_Z3R0 | BR4V0_0N3 | CH4RL13_TW0 → Gh0st_3xf1l_3945d713 (22 graduation)
Lessons that compound
- Fundamentals are the track. Every later trick (
env,/proc,strings,uniq,tar|xz|gzip,ssh -i, brokers,s_client,nmap,diff,ssh cmd, SUID, loops,cron,git) is a composition ofls,cat,find,grepand piping. - Encode ≠ encrypt.
base64(L6, L7, L22) and hexdumps are transport — treat them as plaintext. - The kernel leaks.
env,/proc/<pid>/environ,ps,stringson cores, andAuthorizedKeysreuse all confirm: secrets on ARGV/env/disk are observable. - Time matters. The 2-second
/var/tmpwindow and the 30-second broker timeout are race conditions you win with loops, not reflexes. - History never forgets.
git rmwithout history rewrite is not remediation — same forchattr +ibaked artifacts vs residue. - Wire it together. The graduation asks for the same three primitives that opened the track —
strings,base64 -d, SUID — composed once, then gated by a preciseSHARD1|SHARD2|SHARD3format.
Reproduce
# dump used for this write-up:
ls ~/Desktop/cybersec/breachlabs.txt # 3407 lines, Aug 27-30 2026
# live host ( ephemeral per container restart ):
ssh ghost0@204.168.229.209 -p 2222
# walk 0 → 22 with the chain above
# final:
echo "SHARD1:ALPHA_Z3R0|SHARD2:BR4V0_0N3|SHARD3:CH4RL13_TW0" | nc localhost 31339
Write-up author: Dev Lahrani · VIT Pune · Field Notes — Ghost Track completed Aug 30 2026. Source dump archived at ~/Desktop/cybersec/breachlabs.txt. Challenge by BreachLab (https://breachlab.org).
Comments
Discuss via GitHub — requires Discussions enabled on Dev-Lahrani/resume-website. If disabled, this shows a placeholder.