Loot CLI — Filesystem Recon for CTFs and Post-Exploitation
A Python CLI that walks a directory tree once and dispatches every path through eight specialized scanners to surface credentials, keys, SUID binaries, and CTF flags
Loot CLI — Filesystem Recon for CTFs and Post-Exploitation
Walk a target once. Catch everything worth catching.
The Goal
After landing a shell — on a CTF box, a pentest engagement, or an internal audit — the next step is always the same: figure out what's on the filesystem that shouldn't be readable. .env files, private keys, SUID binaries, shell history, KeePass databases, hardcoded tokens buried in source. Doing that manually is slow and inconsistent.
I wanted a single command that could sweep an arbitrary root path, classify every interesting file with a severity level, map each finding to the appropriate MITRE ATT&CK technique, and output either a human-readable report or clean JSON for piping into other tools.
The constraint I kept front of mind: read-only. The tool never writes, never modifies, never executes anything it finds.
How It Works
The core design choice was a single-pass walk. scan_tree() calls walk() once to build the full path list, then dispatches that same list to every enabled scanner. No scanner re-traverses the tree independently.
loot-cli /home/user --severity high
loot-cli /mnt/usb --json
loot-cli /var/www --scanners filenames,stringsEach scanner yields Finding objects — a dataclass carrying category, severity, path, detail string, optional line number and snippet, and a tuple of MITRE technique IDs. The orchestrator collects, deduplicates, and sorts them by severity before rendering output.
Exit codes are CI-friendly: 0 for clean, 1 for LOW/MEDIUM, 2 for at least one HIGH, 3 for at least one CRITICAL. That makes it trivial to fail a build or alert pipeline on a real finding.
Capabilities
Eight scanners, each targeting a different class of finding:
| Scanner | What it catches |
|---|---|
filenames |
.env, id_rsa, *.pem, *.ppk, *.kdbx, flag*, user.txt, root.txt, wallet files |
strings |
PEM blocks, AWS/GitHub/Slack/Stripe/GitLab tokens, hardcoded password= assignments |
perms |
SUID/SGID binaries, world-writable files |
history |
Shell, MySQL, psql, Python, Redis, vim history files |
archives |
.zip, .tar.gz, .7z — flagged for manual extraction |
git_repos |
Every .git dir found — designed to chain into vault-scan for history raking |
other_user |
Files owned by a different UID that the current user can still read |
recent |
Top N most-recently-modified files within a configurable window |
Severity runs from INFO through LOW, MEDIUM, HIGH, and CRITICAL. An id_rsa is CRITICAL; a .db file is LOW; a SUID binary is HIGH.
The string scanner reads up to 1 MiB per file and skips anything that looks binary in the first 4 KB, keeping it fast enough for real engagement use without choking on large media directories.
Default-pruned directories (proc, sys, dev, node_modules, .venv, __pycache__, etc.) avoid the noise of virtual filesystems and build artifacts without requiring any configuration.
MITRE ATT&CK mapping is baked in at the pattern level, not applied after the fact:
| Finding | Technique |
|---|---|
.env, hardcoded creds |
T1552.001 — Credentials in Files |
| Shell/app history | T1552.003 — Bash History |
| PEM, SSH private keys | T1552.004 — Private Keys |
authorized_keys |
T1098.004 — SSH Authorized Keys |
KeePass .kdbx |
T1555.005 — Password Managers |
| SUID/SGID binaries | T1548.001 — Setuid and Setgid |
| World-writable files | T1222.002 — Linux File Permissions |
| SQL dumps, SQLite DBs | T1213 — Data from Information Repositories |
Tech & Tools
- Python 3.10–3.12 — type-annotated throughout, dataclasses for
FindingandSeverity pathlib.Pathfor all filesystem operationsos.stat/statmodule for permission and ownership checksrefor string pattern matching against token and credential patternsfnmatchfor glob-based filename matching across the pattern table- Structured JSON output suitable for ingestion by Splunk, Elastic, or any log pipeline
pytesttest suite covering each scanner independently with synthetic fixture trees
What I Learned
Single-pass architecture matters more than I expected. When I initially prototyped separate walkers per scanner, scanning a deep home directory was visibly slow — repeated os.walk calls against spinning disk added up fast. Collecting paths once and dispatching to functions changed the feel entirely.
The severity-as-exit-code pattern is underused. Most security tools exit 0 on completion and leave severity parsing to whoever reads the output. Mapping severity tiers directly to exit codes made the tool immediately composable in shell scripts and CI pipelines without any extra glue.
Pattern tables are easier to maintain than scattered if-chains. The filename scanner is driven by a single list of (glob, severity, description, mitre_ids) tuples. Adding a new dangerous filename means adding one line to that list, not finding the right place in a conditional tree. Same pattern in the string scanner for token regexes.
Deciding what to prune is as important as deciding what to scan. Early runs against a developer's home directory returned hundreds of false positives from .venv and node_modules. Defining a sensible default prune list — and making it overridable — was necessary before the tool was usable in practice.
The project pairs with vault-scan, which does the same kind of sweep through git commit history. loot-cli finds repos on disk; vault-scan rakes what got committed to them.
