rCTF Docs
Overview

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:

Configure the provider

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.

Save challenge code

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.

Submit a job

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.

Run the browser handler

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:

rctf.d/admin-bot.yaml
adminBot:
provider:
name: admin-bot/rctf-js
options:
endpoint: http://admin-bot:21337
secretKey: <shared-secret>
maxLogsPerUserChallenge: 5

admin-bot/rctf-js is the built-in provider for TypeScript challenge configs. It accepts provider options from config files or environment variables:

Field or variablePurpose
adminBot.provider.options.endpointBase URL of the admin bot worker from the rCTF API container or process.
adminBot.provider.options.secretKeyBearer token shared between the rCTF API and the admin bot worker.
RCTF_ADMIN_BOT_ENDPOINTEnvironment override for endpoint.
RCTF_ADMIN_BOT_SECRET_KEYEnvironment override for secretKey.
adminBot.maxLogsPerUserChallengeNumber of completed or failed job logs retained per user and challenge. The default is 5.

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:

rctf.d/captcha.yaml
captcha:
protectedEndpoints:
- adminBotSubmit

Worker 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:

VariablePurpose
RCTF_BASE_URLBase URL of the rCTF API from the worker container or process.
RCTF_SECRET_KEYShared bearer token. This must match the provider secret in rCTF config.
RCTF_EXTRA_HEADERSJSON object of extra headers added to worker-to-API requests.
BROWSER_CACHE_DIRBrowser download cache directory. The Docker image defaults to /data/browser-cache/.
POLL_INTERVAL_MSQueue polling interval. The default is 5000.
PORTWorker HTTP port. The default is 21337.

A minimal worker environment looks like this:

deploy/admin-bot/.env
RCTF_BASE_URL=http://rctf:80
RCTF_SECRET_KEY=<shared-secret>
POLL_INTERVAL_MS=5000
PORT=21337

Run the bundled Compose service from the repository root:

Terminal window
docker compose -f deploy/admin-bot/compose.yml up -d

The 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:

admin-bot.ts
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:

FieldRequiredPurpose
timeoutMillisecondsYesMaximum time the handler may run. The worker fails the job on timeout.
inputsYesMap of participant input names to regex rules. Input names may be up to 256 characters, and submitted values may be up to 1024 characters.
handlerYesAsync function that receives a ChallengeContext and drives the browser session.
hooksConfigYesBrowser event logging and tab limit configuration.
browserNoBrowser engine. The supported values are chrome and firefox. The default is chrome.
browserVersionNoBrowser build passed to @puppeteer/browsers. The default is stable.
browserArgumentsNoLaunch arguments. When set, this replaces the default Chrome arguments.
puppeteerLaunchOptionsExtraNoExtra Puppeteer launch options merged into the worker’s launch call.
extraPrefsFirefoxNoFirefox preference overrides. When set, this replaces the default Firefox preferences.
maxLogLinesNoNumber of log lines buffered for this job. The default is 64.
maxLogValueCharsNoMaximum string length in each log field. The default is 2048.
restrictDomainsNoPAC-based host and URL allow or deny rules.
requireInstancerInstancesRunningNoRequires a running instancer instance before queuing the job and passes displayed endpoints into ctx.job.instancerInstances.

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:

FieldPurpose
ctx.loggerPino logger scoped to the running job.
ctx.browserContextFresh Puppeteer browser context for the job.
ctx.inputParticipant input values after API-side regex validation.
ctx.outputStructured log writer shown to participants and admins.
ctx.jobJob metadata, including challenge ID, user ID, config revision, submitted time, flag, and optional instancer endpoints.

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:

FieldBehavior
showConsoleLogsCaptures page, service worker, and extension console messages when supported by the browser.
showBrowserErrorsCaptures page errors and failed network requests.
showNavigationCaptures tab creation, main-frame navigation, service worker creation, and tab close events.
limitTabsNumberCloses the browser when too many page targets are opened. Use -1 for no limit and 0 to prevent page tabs.

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:

MethodPathPurpose
GET/api/v2/integrations/challs/:id/admin-bot/configReturns source code and file extension for the released challenge config.
POST/api/v2/integrations/challs/:id/admin-botValidates inputs and queues a job.
GET/api/v2/integrations/challs/:id/admin-bot/statusReturns the latest job, logs when present, and queue position while queued.
GET/api/v2/integrations/challs/:id/admin-bot/historyReturns completed and failed jobs retained for the current user.
GET/api/v2/integrations/challs/:id/admin-bot/jobs/:jobId/logsReturns stored logs for one retained job.

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:

MethodPathPurpose
POST/api/v2/admin/admin-bot/jobs/pullClaims the oldest queued job.
GET/api/v2/admin/admin-bot/challenges/:id/sourceFetches challenge source for the claimed revision.
POST/api/v2/admin/admin-bot/jobs/:id/completeMarks a running job as completed and stores logs.
POST/api/v2/admin/admin-bot/jobs/:id/failMarks a running job as failed and stores logs.
GET/api/v2/admin/admin-bot/queue-depthReturns the number of queued jobs.

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.

Esc

Start typing to search the docs.