Admin bot
Configure browser-based admin bot jobs for web challenges.
The admin bot integration runs trusted TypeScript challenge handlers in a separate browser worker. It’s meant for web challenges where a participant submits input and rCTF needs a controlled browser session to visit the challenge with challenge-specific state.
Warning (Not battle-tested under high load)
Most of rCTF v2 has run large public events, but the admin bot hasn’t been pushed under that kind of traffic yet. The implementation works and we expect it to hold up, but expect rougher edges than the rest of the platform. Load-test before relying on it for a high-volume event, and watch the queue depth and worker logs during the CTF.
If you hit issues, please open one on GitHub. And if you did run the admin bot through a public CTF without trouble, we’d love a PR fixing anything you had to patch locally, adding the missing documentation, or just deleting this note.
Warning (Trusted challenge code)
Admin bot source is trusted operator code. rCTF bundles and evaluates it inside the admin bot service, and the released challenge source is reachable through the public challenge integration API. Store secrets in challenge data and read the flag from ctx.job.flag at runtime.
Request lifecycle#
An admin bot job moves through the platform in this order:
The rCTF API gets configured with an admin bot provider. The provider points at the browser worker and uses a shared bearer token for service authentication.
The admin challenge editor sends the TypeScript source to the browser worker’s /v1/test endpoint. The worker builds the source, validates the exported Challenge, returns the public input schema, and lets the API store a config revision.
A participant submits values for the configured inputs. The API checks required inputs, regex rules, captcha, rate limit state, active job state, and optional instancer state before queueing the job.
The worker polls rCTF for queued jobs, fetches the matching challenge source revision, launches Chrome or Firefox, creates a fresh browser context, runs the handler, stores logs, and reports success or failure.
Backend configuration#
The backend config turns the provider on and tells rCTF where the admin bot worker is reachable from the API process:
adminBot: provider: name: admin-bot/rctf-js options: endpoint: http://admin-bot:21337 secretKey: <shared-secret> maxLogsPerUserChallenge: 5admin-bot/rctf-js is the built-in provider for TypeScript challenge configs. It accepts provider options from config files or environment variables:
The same shared secret has to go to the worker as RCTF_SECRET_KEY. The API uses it when validating service routes, and the worker uses it when authenticating back to the API.
Protect participant submissions with captcha by adding the adminBotSubmit action:
captcha: protectedEndpoints: - adminBotSubmitWorker service#
The worker is a separate Bun service under apps/admin-bot/. It exposes a protected test endpoint for config validation, and polls rCTF for queued jobs.
The bundled deployment files live under deploy/admin-bot/:
deploy/
admin-bot/
- compose.yml Service, volume, tmpfs, and network settings
- .env.example Required worker environment variables
- Dockerfile Bun runtime and browser dependencies
The worker service uses these environment variables:
A minimal worker environment looks like this:
RCTF_BASE_URL=http://rctf:80RCTF_SECRET_KEY=<shared-secret>POLL_INTERVAL_MS=5000PORT=21337Run the bundled Compose service from the repository root:
docker compose -f deploy/admin-bot/compose.yml up -dThe Compose file binds the worker to 127.0.0.1:21337, mounts a persistent browser cache, uses tmpfs for browser scratch data, drops Linux capabilities, and joins the external rctf_network.
Warning (Network exposure)
Keep the worker reachable only from trusted infrastructure. The /v1/test endpoint
is bearer-authenticated, but it still builds trusted challenge source. Don’t expose the worker as
a public internet service.
Challenge source#
Admin bot challenge code exports a Challenge instance. The loader only allows imports that resolve to the admin bot type module, such as ../src/types, ../types, ./types, ./src/types, src/types, and types.
This example restricts participant input to one challenge origin, stores the flag in browser local storage for that origin, visits the submitted URL, and writes a challenge log line:
import { sleep } from 'bun'import { Challenge, type ChallengeContext } from '../src/types'
export const challenge = new Challenge({ // required: timeoutMilliseconds: 30_000,
inputs: { url: { pattern: '^http(s?)://.*' }, },
handler: async (ctx: ChallengeContext): Promise<void> => { const url = ctx.input.url! const page = await ctx.browserContext.newPage()
ctx.output.info('challenge', 'hello from my challenge!', { optional: 'values', that: 'will be', displayed: 'separately!', }) ctx.output.warn('challenge', 'warn!') ctx.output.error('challenge', 'error!') ctx.output.fatal('challenge', 'fatal!')
try { await page.goto(url) } catch (e) { // Without this, the error propagated is that something went wrong. // Ideally, there would be automagic so you don't need to do this, // but page navigations throw a normal error, and searching in a string is... ugly. // @see https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/cdp/Frame.ts#L210-L212 ctx.output.fatal('challenge', `failed to visit provided URL: ${e}`, { url, }) return } await sleep(15_000) await page.close() },
hooksConfig: { showConsoleLogs: true, showBrowserErrors: true, showNavigation: true, limitTabsNumber: -1, // no limit },
// optional: browser: 'chrome', // vvv argv array. by default we apply some default argv values, // but if you override this we'll not add anything to the list! browserArguments: undefined, browserVersion: 'stable', puppeteerLaunchOptionsExtra: undefined, // Record<string, unknown> // vvv Record<string, unknown>. by default we apply some default values, // but if you override this we'll not add anything to this mapping! extraPrefsFirefox: undefined,
maxLogValueChars: 4096, // limit number of characters within strings in logs maxLogLines: 64, // limit the number of lines stored per submission
restrictDomains: { // note on case sensitivity: // - `host` is always lowercased by the browser // - `url` param preserves the original casing of the path/query // // note on dns rebinding bypasses: // pac rules match on host/url strings only, and do not resolve DNS. // a blocklist-only config (disallowRegex without a catch-all) can be // bypassed by aliases that resolve to internal IPs (e.g. 127.0.0.1.nip.io). // to prevent this, pair allowRegex with a catch-all disallowRegex // so that only explicitly allowed hosts can be reached.
// allow example.com, but not any other example.com subdomain host: { allowRegex: [{ pattern: '^example\\.com$' }], disallowRegex: [{ pattern: '^.*\\.example\\.com$' }], }, // allow example.com/kek, but not any other urls on example.com url: { allowRegex: [{ pattern: 'example\\.com\\/kek' }], disallowRegex: [{ pattern: 'example\\.com' }], }, },
// 1. If challenge has no instancer configured, this value will be ignored. // 2. The values in `ChallengeContext.job.instancerInstances` will not be filled, // unless this variable is set to true. // 3. This provides no guarantee that instances will still be alive by the time the handler executes, // because the platform would not prevent someone from stopping the instance once the job is queued. requireInstancerInstancesRunning: false,})The exported Challenge is validated when the challenge is saved. Invalid regex patterns, missing exports, and unsupported imports come back to the admin challenge editor as config errors.
Challenge config fields#
The Challenge constructor accepts these fields:
By default, Chrome starts with --no-sandbox, --disable-jit, --disable-wasm, and --disable-dev-shm-usage. Firefox starts with JIT and Wasm disabled through preferences. Replacing browserArguments or extraPrefsFirefox removes those defaults.
Challenge context#
The handler receives a ChallengeContext:
Use ctx.output.info(), ctx.output.warn(), ctx.output.error(), and ctx.output.fatal() for participant-visible logs:
ctx.output.info('challenge', 'visited page', { url: ctx.input.url,})Stored logs are newline-delimited JSON. The API accepts up to 1048576 characters of logs per completion or failure report, and older logs are pruned according to adminBot.maxLogsPerUserChallenge.
Browser hooks#
hooksConfig controls automatic browser logging:
Chrome supports extension page logging and service worker console logging. Firefox doesn’t support those two hook surfaces in the current worker.
Domain restrictions#
restrictDomains builds a browser PAC file from regex rules. Host rules run before URL rules, and allow rules run before deny rules within each scope.
Use an allowlist plus a catch-all deny rule for challenges where the bot should only contact the challenge origin:
restrictDomains: { host: { allowRegex: [{ pattern: '^challenge\\.example\\.com$' }], disallowRegex: [{ pattern: '.*' }], },}PAC rules match browser host and URL strings. They don’t resolve DNS, so a denylist-only rule can miss aliases that resolve to internal IP addresses.
Instancer integration#
requireInstancerInstancesRunning ties admin bot jobs to challenge instances. Before queueing the job, the API checks that the user’s instance is running and that its remaining lifetime is at least timeoutMilliseconds.
When the check passes, the handler receives displayed endpoints in ctx.job.instancerInstances:
for (const endpoint of ctx.job.instancerInstances) { ctx.output.info('challenge', 'instance endpoint', endpoint)}The check only happens before queueing. A participant can still stop the instance before the worker gets to the job, so handlers should tolerate failed connections and report a clear log message.
Participant behavior#
Released challenges expose adminBotInputs in the public challenge list. The challenge page renders one field per configured input.
The participant-side API uses these endpoints:
Each user can have one queued or running admin bot job per challenge. Submissions are rate-limited to one request every ten seconds per user and challenge.
Service API#
The worker uses the service-authenticated admin API:
Each worker process runs one job at a time. Multiple worker processes can poll the same API when more browser throughput is needed.
Scaling under load#
Admin bot jobs are bound by browser session throughput, not CPU. A worker spends most of its wall time waiting on Puppeteer (page loads, redirects, the configured timeout), so steady-state CPU stays low even when the queue is backing up. Autoscaling on CPU will react late and under-provision, so scale on queue depth instead.
The GET /api/v2/admin/admin-bot/queue-depth endpoint returns the number of queued jobs and is built for exactly this. The usual setup is to scrape it (via an exporter or small sidecar) into Prometheus, set a target queue depth per replica (somewhere around 5 to 10), and let a KEDA ScaledObject or HPA External metric scale workers up and back down.
Note (No production-ready manifests yet)
We don’t ship a Helm chart, Kustomize overlay, or Terraform module for an autoscaled admin bot fleet. The queue-depth endpoint exists, but wiring it to KEDA/HPA and a scrape job is left to the deployer for now. If you build one and want to upstream it, see the note at the top of this page.