Admin bot

Configure browser-based admin bot jobs for web challenges.

Edit this page View Markdown

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:

  1. Configure the provider

    Configure rCTF with the browser worker’s URL and a bearer token shared by both services.

  2. 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.

  3. 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.

  4. 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.

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

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

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

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

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

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 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.

  1. Run the admin bot image as a normal Deployment.
  2. Expose the worker with a Service and point adminBot.provider.options.endpoint at it, for example http://adminbot.adminbot.svc.cluster.local:21337.
  3. Add a KEDA TriggerAuthentication and ScaledObject that use the same secret to poll queue depth.

The KEDA-specific pieces look like this:

KEDA admin bot scaler
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: adminbot-platform
namespace: adminbot
spec:
secretTargetRef:
- parameter: token
name: adminbot-secrets
key: RCTF_SECRET_KEY
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: adminbot
namespace: adminbot
spec:
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: 300

Keep 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.

Type to search.