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 — Title with Goal → Briefing → Commands → Why → Password and a Defensive note.
  • All commands were run inside the ghostN container 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 blob relic.binstrings
  • Shard 2 (BR4V0_0N3) — transport encoding scroll.b64base64 -d
  • Shard 3 (CH4RL13_TW0) — SUID helper ghost-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

  1. 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 of ls, cat, find, grep and piping.
  2. Encode ≠ encrypt. base64 (L6, L7, L22) and hexdumps are transport — treat them as plaintext.
  3. The kernel leaks. env, /proc/<pid>/environ, ps, strings on cores, and AuthorizedKeys reuse all confirm: secrets on ARGV/env/disk are observable.
  4. Time matters. The 2-second /var/tmp window and the 30-second broker timeout are race conditions you win with loops, not reflexes.
  5. History never forgets. git rm without history rewrite is not remediation — same for chattr +i baked artifacts vs residue.
  6. 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 precise SHARD1|SHARD2|SHARD3 format.

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.