
Here are the three requests to run first on any AI app. With no login, they'll often tell you the model it runs, the vector database behind it, whether it can call tools and roughly how long its hidden system prompt is.
Each one is a plain curl call, the same traffic a browser or API client sends, so you're reading what the app already hands out. Below you'll find the commands, real output from a practice target called NovaTech, what each field gives away and what to test next. Use it on your own lab or an in-scope engagement.
The three recon requests at a glance
Keep this table handy. The sections below take each row in turn.
| Request | Command | What it can leak |
|---|---|---|
| Response headers | curl -s -I | Web server, app version, LLM backend, vector DB, CORS |
| Health endpoint | curl -s /api/health | jq . | Model name, MCP on or off, RAG on or off, service name |
| Chat completion | curl -s -X POST /v1/chat/completions | API format, model, mock or live, hidden prompt size |
1. Read the response headers
Headers come back on every response you get. Developers add custom ones while they're building, and they often don't take them out before go-live.
curl -s -I http://<target>/
-s hides curl's progress bar. -I sends a HEAD request, so you get the headers and none of the page.
The last two lines are the prize. X-AI-Backend: OpenAI-GPT5.2 names the provider and model. X-RAG-Provider: ChromaDB names the vector database behind a RAG pipeline (retrieval-augmented generation, where the app looks up documents and pastes them into the prompt).
The rest is the web recon you'd do anyway. It's nginx 1.24.0 on Ubuntu, the app is NovaTech 2.1.0, and Access-Control-Allow-Origin: * lets any website read the responses. The two X- security headers mean clickjacking and MIME sniffing won't work here, so you can skip those.
Why does the model matter? It tells you which known jailbreaks, quirks and knowledge cutoff you're up against. The vector database puts RAG probing on your active recon list.
AI headers often appear only on routes that really call the model, so check the chat API as well. HEAD isn't much use on a POST-only route. Send a real POST, print its headers and throw the body away:
curl -s -D - -o /dev/null -X POST http://<target>/api/chat
-D - writes the headers to your screen and -o /dev/null drops the body.
2. Hit the health endpoint
Health endpoints exist so load balancers and monitoring can check the app is up. They don't usually need a login, and you'll find some return a lot more than "ok".
curl -s http://<target>/api/health | jq .
jq . pretty-prints the JSON so it's easy to read.
mcp_enabled: true is the big one. MCP (Model Context Protocol) is a standard way to hand a model tools it can call, like a database lookup or an email sender. If it's on, the model can take actions, so tool enumeration jumps to the top of your list.
You also get the exact model, gpt-5.2-turbo, an internal service name, novatech-customer-assistant, and version 2.1.0, which matches the X-Powered-By header.
No /api/health? Sweep the usual routes. Build a short list and let ffuf try each one:
printf '%s\n' health status api/health api/status api/version api/config metrics .well-known/ > routes.txt
ffuf -u http://<target>/FUZZ -w routes.txt -mc 200,401,403
-mc keeps only those status codes. A 401 or 403 still tells you the route's there. For a wider sweep you can use the API wordlists SecLists keeps under Discovery/Web-Content/api/, and the api_wordlist collection was merged into SecLists too.
3. Fingerprint the chat completions response
Now send the chat API one harmless message and read the whole JSON, not only the reply text.
curl -s -X POST http://<target>/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Hello"}]}' | jq .
/v1/chat/completions is OpenAI's chat route, and plenty of apps copy its shape. With -d you're sending an OpenAI-style messages list that holds one user message.
object: chat.completion and the choices list confirm the OpenAI format, so you can point OpenAI-style tooling and attacks at it as they are. model repeats gpt-5.2-turbo, and the reply names the bot.
Then look at the id. A real OpenAI id is chatcmpl- plus a random string. This one says chatcmpl-mock-, and the reply talks about maintenance mode. That route's probably a stub, so you'll want to look for others, like /api/chat, /v1/completions and /v1/models, before you spend your time on it.
Do the token maths
usage.prompt_tokens is worth a pause. "Hello" is about 1 token. Chat formatting adds a few more, so you'd expect under 10. The server counted 297.
That gap, roughly 285 tokens or about 200 words, is text the app slips in before your message. That's a hidden system prompt, and it's your first target for system prompt extraction later.
Cross-check the recon signals before you report them
You can't trust one source on its own: it might be stale, wrong or a mock. Line the findings up and you'll see where they agree and where they don't.
| Signal | Seen in | Status |
|---|---|---|
| Model is GPT-5.2 | Header, /api/health, chat JSON | 3 sources |
| App version 2.1.0 | X-Powered-By, /api/health | 2 sources |
| Tool use (MCP) on | /api/health only | 1 source |
| Hidden system prompt | 297 prompt tokens for "Hello" | Inferred |
| RAG with ChromaDB | Header says yes, health says rag_enabled false | Conflict |
| Live model on /v1/chat/completions | Mock id, maintenance reply | Likely stub |
Look hard at the RAG row. The header names ChromaDB, yet the health endpoint says RAG is off and the chat reply came back with rag_sources: null. Maybe RAG's only wired into another route. Maybe the header's left over from an old build. Either way, you'll need to test it in active recon before you write it up.
Common mistakes
Trusting HEAD. Some apps answer HEAD with 405 Method Not Allowed, or skip headers they'd send on GET. If -I looks thin, run curl -s -D - -o /dev/null http://<target>/ to see the GET headers.
Piping HTML into jq. parse error: Invalid numeric literal at line 1 means the server sent HTML, usually a 404 page. Drop | jq . and read the raw reply. Older jq builds also need the . filter when piped, so always type it.
Checking only the home page. The AI headers in the lab sat next to normal ones, but on many apps they only show on /api/chat or similar. Check every route that talks to the model.
Overrating wildcard CORS. Browsers won't send cookies to an origin of *, so on its own it isn't a data leak. It only matters when the API doesn't need auth, or trusts the network instead of a session. Check how the API authenticates before you rate it.
Attacking the mock. A chatcmpl-mock- id and a canned reply mean you might be hitting a stub. Find the live route before you burn time on it.
Fix the leaks and detect the recon
If you're the one building the app, most of this is a config change. Strip the custom headers at the reverse proxy and hide the nginx version:
server_tokens off;
proxy_hide_header X-AI-Backend;
proxy_hide_header X-RAG-Provider;
proxy_hide_header X-Powered-By;
Then keep your public health check boring: return {"status":"ok"} and put the details behind auth or on an internal port. Drop or alias the upstream model name in your API replies. Only return usage to clients that need it, because prompt_tokens shows the size of your system prompt. Set CORS to an allowlist of your own origins.
Assume your system prompt will leak anyway, and don't put secrets or access rules in it.
To spot someone doing this recon against you, look for one client hitting lots of health-style routes in a short burst, mostly 404s, plus HEAD requests that browsers rarely send. In nginx access logs:
grep -E '"HEAD |/(health|status|metrics|api/(config|version))' /var/log/nginx/access.log
One hit is a monitor. Fifty from the same IP in a minute is someone mapping you. The AI pentest notes keep these checks in one list.
Quick recap
- Custom
X-headers likeX-AI-BackendandX-RAG-Providercan name the model and vector database outright. - Public health endpoints often return the model, the version and flags like
mcp_enabled: true, which means the bot can call tools. - Read the whole chat JSON. A
mockid points to a stub, and 297 prompt tokens for "Hello" points to a hidden system prompt of about 285 tokens. - Cross-check every signal. In the lab, the header said ChromaDB while the health endpoint said RAG was off.
- Defenders fix most of it at the proxy and the health route, and can spot the recon as a burst of 404s and HEAD requests.
Next step
You now know the model, that tools are switched on and that roughly 285 tokens of instructions sit in front of every message. Active recon comes next. Pull that system prompt out, as shown in the prompt injection and system prompt leaking post, then ask the bot which tools it can call, as in the function calling post. The recon tells you which of those to start with.
If you've found other headers or routes that give away an AI app's stack, I'd like to hear about them. Drop me a message on LinkedIn and we can compare lists.
References
- OWASP Top 10 for LLM Applications (2025) - LLM07 System Prompt Leakage and LLM06 Excessive Agency.
- MITRE ATLAS AML.T0014 Discover AI Model Family (via MITRE D3FEND).
- SecLists - API wordlists under Discovery/Web-Content/api/.
- curl man page - the -I, -D and -o options.
- nginx proxy_hide_header - stripping upstream headers.
FAQ
What is passive reconnaissance on an LLM app?
It's gathering facts about an AI app using only the requests a normal user or API client would send: reading response headers, calling public health endpoints and sending a harmless chat message. You learn the model, the vector database, whether tools are on and how big the hidden prompt is, without attacking anything yet.
How can you tell an AI app adds a hidden system prompt?
Check usage.prompt_tokens in the chat API's JSON. A one-word message like Hello should cost under 10 tokens with chat formatting. If the server reports far more, such as 297, the gap is text the app adds before your message, which is almost always a system prompt you can try to extract later.
How do you stop an AI app leaking its stack in headers?
Strip custom headers at the reverse proxy, for example proxy_hide_header X-AI-Backend in nginx, and set server_tokens off to hide the version. Keep public health checks to a bare status, drop or alias the model name in API responses, and set CORS to an allowlist of your own origins.
Related reading
- Prompt Injection: Your Filter Blocks Words, Not Intent (what to do with that hidden system prompt)
- LLM Function Calling: The Model Isn't Your Auth Check (where
mcp_enabled: trueleads) - Red Teaming Generative AI: The Model Is the Wrong Target (mapping the whole app before you attack it)
- Browse the whole AI / LLMs track