Admin bot
Configure browser-based admin bot jobs for web challenges.
The admin bot lets a web challenge accept participant input and open it in a controlled Chrome or Firefox session. Each challenge supplies a trusted TypeScript handler that prepares the browser, visits the submitted URL, and records logs for the participant and organizers.
Warning (Trusted challenge code)
Challenge handlers run as trusted code inside the admin bot service. Their source is available through the public challenge integration API, so do not put secrets in it. Store the flag in challenge data and read it from ctx.job.flag while the job runs.
How a job runs
An admin bot job moves through the platform in this order:
-
Configure the provider
Configure rCTF with the browser worker’s URL and a bearer token shared by both services.
-
Save challenge code
When an admin saves the handler, the worker builds it and validates the exported
Challenge. rCTF stores the validated version and uses its input definitions in the challenge form. -
Submit a job
rCTF validates the submitted fields, captcha, rate limit, existing jobs, and any required challenge instance before adding the job to the queue.
-
Run the browser handler
The worker takes the next queued job, opens a fresh browser context, runs the saved handler, and reports its logs and result to rCTF.
Backend configuration
The backend config enables the provider and gives rCTF the worker’s URL.
adminBot: provider: name: admin-bots/rctf-ts options: endpoint: http://admin-bot:21337 secretKey: <shared-secret> maxLogsPerUserChallenge: 5admin-bots/rctf-ts is the built-in provider for TypeScript challenge configs. It accepts provider options from config files or environment variables:
| Field or variable | Purpose |
|---|---|
adminBot.provider.options.endpoint |
Base URL of the admin bot worker from the rCTF API container or process. |
adminBot.provider.options.secretKey |
Bearer token shared between the rCTF API and the admin bot worker. |
RCTF_ADMIN_BOT_ENDPOINT |
Environment override for endpoint. |
RCTF_ADMIN_BOT_SECRET_KEY |
Environment override for secretKey. |
adminBot.maxLogsPerUserChallenge |
Number of completed or failed job logs retained per user and challenge. The default is 5. |
Set the same secret on the worker through RCTF_SECRET_KEY. Each service uses it to authenticate requests from the other.
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 validates challenge handlers and polls rCTF for queued jobs.
The deployment files are in deploy/admin-bot/.
deploy/
admin-bot/
- compose.yml Service, volume, tmpfs, resource limit, and network settings
- .env.example Required worker environment variables
- Dockerfile Bun runtime and browser dependencies
The worker service uses these environment variables:
| Variable | Purpose |
|---|---|
RCTF_BASE_URL |
Base URL of the rCTF API from the worker container or process. |
RCTF_SECRET_KEY |
Shared bearer token. This must match the provider secret in rCTF config. |
RCTF_EXTRA_HEADERS |
JSON object of extra headers added to worker-to-API requests. |
BROWSER_CACHE_DIR |
Browser download cache directory. The Docker image defaults to /data/browser-cache/. |
POLL_INTERVAL_MS |
Queue polling interval. The default is 5000. |
PORT |
Worker HTTP port. The default is 21337. |
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, caps container resources, and joins the external rctf_network.
Warning (Network exposure)
Keep the worker on a private network. Although /v1/test requires the shared bearer token, it builds and evaluates trusted challenge code and should not be exposed to the internet.
Resource limits
Each worker process handles one job at a time, so without container limits a single submitted page that allocates memory, spins the CPU, or spawns processes can crash the worker or starve the host, and deny admin bot service for every challenge using that worker.
The bundled Compose file caps the container and reads overrides from deploy/admin-bot/.env:
| Variable | Default | Purpose |
|---|---|---|
ADMIN_BOT_CPU_LIMIT |
2 |
CPU cores available to the worker and its browser. |
ADMIN_BOT_MEMORY_LIMIT |
3G |
Memory limit for the container. |
ADMIN_BOT_PIDS_LIMIT |
1024 |
Maximum processes and threads in the container. |
The tmpfs mounts and /dev/shm count against the memory limit. Their sizes add up to about 1.1G in the bundled file, so keep the memory limit well above that plus browser headroom.
Deployments that bypass the bundled Compose file should apply equivalent limits: browser resource use is controlled by participant-influenced pages, not by the worker.
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 worker validates Challenge when an admin saves the challenge. Invalid regular expressions, missing exports, and unsupported imports appear as errors in the editor.
Challenge config fields
The Challenge constructor accepts these fields:
| Field | Required | Purpose |
|---|---|---|
timeoutMilliseconds |
Yes | Maximum time the handler may run. The worker fails the job on timeout. |
inputs |
Yes | Map of participant input names to regex rules. Input names may be up to 256 characters, and submitted values may be up to 1024 characters. |
handler |
Yes | Async function that receives a ChallengeContext and drives the browser session. |
hooksConfig |
Yes | Browser event logging and tab limit configuration. |
browser |
No | Browser engine. The supported values are chrome and firefox. The default is chrome. |
browserVersion |
No | Browser build passed to @puppeteer/browsers. The default is stable. |
browserArguments |
No | Launch arguments. When set, this replaces the default Chrome arguments. |
puppeteerLaunchOptionsExtra |
No | Extra Puppeteer launch options merged into the worker’s launch call. |
extraPrefsFirefox |
No | Firefox preference overrides. When set, this replaces the default Firefox preferences. |
maxLogLines |
No | Number of log lines buffered for this job. The default is 64. |
maxLogValueChars |
No | Maximum string length in each log field. The default is 2048. |
restrictDomains |
No | PAC-based host and URL allow or deny rules. |
requireInstancerInstancesRunning |
No | Requires 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:
| Field | Purpose |
|---|---|
ctx.logger |
Pino logger scoped to the running job. |
ctx.browserContext |
Fresh Puppeteer browser context for the job. |
ctx.input |
Participant input values after API-side regex validation. |
ctx.output |
Structured log writer shown to participants and admins. |
ctx.job |
Job 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:
| Field | Behavior |
|---|---|
showConsoleLogs |
Captures page, service worker, and extension console messages when supported by the browser. |
showBrowserErrors |
Captures page errors and failed network requests. |
showNavigation |
Captures tab creation, main-frame navigation, service worker creation, and tab close events. |
limitTabsNumber |
Closes 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:
| Method | Path | Purpose |
|---|---|---|
GET |
/api/v2/integrations/challs/:id/admin-bot/config |
Returns source code and file extension for the released challenge config. |
POST |
/api/v2/integrations/challs/:id/admin-bot |
Validates inputs and queues a job. |
GET |
/api/v2/integrations/challs/:id/admin-bot/status |
Returns the latest job, logs when present, and queue position while queued. |
GET |
/api/v2/integrations/challs/:id/admin-bot/history |
Returns completed and failed jobs retained for the current user. |
GET |
/api/v2/integrations/challs/:id/admin-bot/jobs/:jobId/logs |
Returns 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:
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v2/admin/admin-bot/jobs/pull |
Claims the oldest queued job. |
GET |
/api/v2/admin/admin-bot/challenges/:id/source |
Fetches challenge source for the claimed revision. |
POST |
/api/v2/admin/admin-bot/jobs/:id/complete |
Marks a running job as completed and stores logs. |
POST |
/api/v2/admin/admin-bot/jobs/:id/fail |
Marks a running job as failed and stores logs. |
GET |
/api/v2/admin/admin-bot/queue-depth |
Returns 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.
GET /api/v2/admin/admin-bot/queue-depth returns the current queue length in data.depth. Start by targeting 2 to 5 waiting jobs per replica, then adjust the threshold based on job duration and browser resource use.
We are usually using KEDA for this. It can read this endpoint directly with its metrics-api scaler, so you do not need anything else.
A Kubernetes deployment can scale the worker with the following KEDA configuration.
- Run the admin bot image as a normal
Deployment. - Expose the worker with a
Serviceand pointadminBot.provider.options.endpointat it, for examplehttp://adminbot.adminbot.svc.cluster.local:21337. - Add a KEDA
TriggerAuthenticationandScaledObjectthat use the same secret to poll queue depth.
The KEDA-specific pieces look like this:
apiVersion: keda.sh/v1alpha1kind: TriggerAuthenticationmetadata: name: adminbot-platform namespace: adminbotspec: secretTargetRef: - parameter: token name: adminbot-secrets key: RCTF_SECRET_KEY---apiVersion: keda.sh/v1alpha1kind: ScaledObjectmetadata: name: adminbot namespace: adminbotspec: scaleTargetRef: name: adminbot minReplicaCount: 1 maxReplicaCount: 20 pollingInterval: 10 triggers: - type: metrics-api metadata: url: http://rctf-api.rctf.svc.cluster.local/api/v2/admin/admin-bot/queue-depth valueLocation: data.depth targetValue: "2" authMode: bearer authenticationRef: name: adminbot-platform advanced: horizontalPodAutoscalerConfig: behavior: scaleDown: stabilizationWindowSeconds: 300Keep minReplicaCount at 1. Setting it to 0 can still scale workers up for queued jobs, because KEDA polls rCTF directly, but the API’s /v1/test config-validation call has no worker pod to hit while scaled down.