Edge redirector built on Cloudflare Workers for authorized penetration testing infrastructure.
Sits between implants and your teamserver. Inbound requests are validated against a configurable profile. Traffic that matches gets proxied to the backend. Everything else receives a decoy response, either a 302 redirect to a legitimate site or a static placeholder page. The profile lives in KV and can be updated at runtime without redeployment.
git clone https://github.com/errantpacket/Oblique-Relay.git && cd Oblique-Relay
npm install
# local dev (uses .dev.vars for secrets)
cp .dev.vars.example .dev.vars
npm run dev
# run tests
npm testwrangler login
wrangler secret put BACKEND_URL # https://your-teamserver.internal
wrangler secret put DECOY_URL # https://en.wikipedia.org
wrangler secret put PROFILE_SECRET # shared token for operator endpoints
npm run deployThe profile controls which requests get proxied and which get decoyed. It is stored in the PROFILE KV namespace at key profile:active and can be updated at runtime through the operator API. When no KV profile exists, a hardcoded default is used.
{
"paths": ["/api/v1/status", "/api/v1/data"],
"methods": ["GET", "POST"],
"headers": {"X-Request-ID": "^[a-f0-9-]{36}$"},
"ua_pattern": "Mozilla/5\\.0",
"geo_allow": ["US", "CA"],
"geo_deny": ["CN", "RU"],
"time_window": {"start": "08:00", "end": "22:00", "tz": "America/New_York"},
"jitter_ms": 100,
"backends": {"/content": "https://alt-teamserver.example.com"}
}Every check must pass. The first failure triggers a decoy response.
PROFILE_SECRET is not part of this pipeline. It only gates operator endpoints (/__health, /__profile, etc.). Implant traffic is validated by the profile alone, because C2 frameworks typically do not include custom auth headers in their HTTP callbacks by default.
| Field | Type | Description |
|---|---|---|
paths |
string[] |
URI prefixes that pass validation |
methods |
string[] |
Allowed HTTP methods |
headers |
{name: regex} |
Required request headers with regex patterns |
ua_pattern |
string|null |
Regex the User-Agent must match (null to skip) |
geo_allow |
string[] |
Allowed country codes from CF-IPCountry (empty allows all) |
geo_deny |
string[] |
Blocked country codes (empty blocks none) |
time_window |
object|null |
Active hours as {start, end, tz} in HH:MM (null for always) |
jitter_ms |
number |
Max random delay before responding, in ms (0 to disable) |
backends |
{prefix: url} |
Route by path prefix (unmatched paths use BACKEND_URL) |
All operator endpoints require an X-Auth-Token header matching PROFILE_SECRET.
curl -H "X-Auth-Token: <secret>" https://oblique-relay.example.com/__health
# {"status":"ok","ts":1710000000000,"profile":"kv"}# view current profile
curl -H "X-Auth-Token: <secret>" https://oblique-relay.example.com/__profile
# replace profile
curl -X PUT -H "X-Auth-Token: <secret>" -H "Content-Type: application/json" \
-d '{"paths":["/beacon"],"methods":["GET","POST"],"geo_allow":["US"]}' \
https://oblique-relay.example.com/__profileImport a native C2 framework profile. The parser extracts paths, methods, headers, and user-agent into the relay's normalized format.
# dry run (preview without saving)
curl -X POST -H "X-Auth-Token: <secret>" \
-d @malleable.profile \
"https://oblique-relay.example.com/__profile/import?dry_run=true"
# Cobalt Strike (default format)
curl -X POST -H "X-Auth-Token: <secret>" \
-d @malleable.profile \
"https://oblique-relay.example.com/__profile/import"
# Sliver
curl -X POST -H "X-Auth-Token: <secret>" \
-d @sliver-http.json \
"https://oblique-relay.example.com/__profile/import?format=sliver"
# Mythic
curl -X POST -H "X-Auth-Token: <secret>" \
-d @mythic-profile.json \
"https://oblique-relay.example.com/__profile/import?format=mythic"
# Havoc
curl -X POST -H "X-Auth-Token: <secret>" \
-d @havoc.yaotl \
"https://oblique-relay.example.com/__profile/import?format=havoc"
# PoshC2
curl -X POST -H "X-Auth-Token: <secret>" \
-d @poshc2-config.txt \
"https://oblique-relay.example.com/__profile/import?format=poshc2"
# Oblique Server (private repo)
curl -X POST -H "X-Auth-Token: <secret>" \
-d @oblique-profile.json \
"https://oblique-relay.example.com/__profile/import?format=oblique"Supported parsers:
| Format | Config type | What it extracts |
|---|---|---|
cobalt-strike |
Malleable C2 .profile |
URIs, methods, client headers, useragent |
sliver |
HTTP C2 JSON (native export) | Path segments, methods, implant headers, user_agent |
mythic |
JSON profile | get/post URIs, AgentHeaders, user_agent |
havoc |
Yaotl/HCL-like config | Uris, UserAgent, Headers array |
poshc2 |
Python config | UrlsX, UserAgent, DomainFrontHeader, PayloadCommsHost |
oblique |
JSON (native adapter export) | paths, methods, headers, user_agent |
Fields not present in the C2 config (geo, time window, jitter, backends) stay at their defaults. Configure them separately with PUT /__profile.
Both PUT and import validate against the profile schema. Invalid profiles are rejected with descriptive errors.
curl -H "X-Auth-Token: <secret>" \
"https://oblique-relay.example.com/__metrics" -o metrics.html
# filter by time range and entry limit
curl -H "X-Auth-Token: <secret>" \
"https://oblique-relay.example.com/__metrics?hours=72&limit=1000"Shows valid/decoy counts, valid ratio, requests per hour, top source IPs, paths, countries, and user-agents. Aggregated from the LOG KV namespace.
Per-implant session tracking using Durable Objects with SQLite storage. Sessions are keyed by X-Session-ID header, falling back to CF-Connecting-IP.
# list active sessions
curl -H "X-Auth-Token: <secret>" https://oblique-relay.example.com/__sessions
# view a specific session
curl -H "X-Auth-Token: <secret>" https://oblique-relay.example.com/__sessions/<session-id>Each session tracks:
| Field | Description |
|---|---|
request_count |
Total requests from this session |
first_seen |
Timestamp of first request |
last_seen |
Timestamp of most recent request |
paths |
Last 100 paths accessed with timestamps |
flagged |
Auto-flagged if request count exceeds 1000 |
flag_reason |
Reason for flagging |
Sessions auto-expire after 24 hours of inactivity via DO alarms. A KV-based index (session: prefix in LOG) enables listing without enumerating all Durable Objects.
Every request is logged to the LOG KV namespace with a 7-day TTL. Keys are prefixed valid: or decoy: for filtering.
npm run logs # valid traffic
npm run logs:decoy # rejected traffic
npm run tail # real-time stream146 tests across 12 files covering validation, proxy, operator endpoints, logging, sessions, and all parsers.
npm test # run full suite
npm run test:watch # watch modeThe E2E suites validate the full chain against real C2 servers with no mocks.
Sliver E2E (27 tests): log-based and PTY-driven verification:
- Start Sliver server with HTTP listener and implant generation
- Establish a cloudflared tunnel to the Sliver backend
- Import the real Sliver C2 profile through the relay's parser
- Verify negative-path filtering (invalid paths get decoyed)
- Compile and run a beacon implant through the relay
- Confirm beacon registration in Sliver's server logs
- Execute C2 tasks (whoami, file download) through the relay
- Verify bidirectional traffic (task results returned through relay)
# requires Docker, Sliver v1.7.3 image, and a deployed relay
./test/e2e/validate-sliver.sh <relay-url> <auth-token>Mythic E2E (33 tests): API-driven verification with 9 containers:
- Start Mythic stack (postgres, rabbitmq, server, Hasura GraphQL, nginx, HTTP C2 profile, Poseidon)
- Authenticate via REST API, poll until C2 profile and agent type register via postgres
- Start HTTP C2 profile listener via webhook API
- Establish a cloudflared tunnel to the HTTP C2 profile listener
- Extract C2 profile parameters from postgres, transform to parser format, and import into relay
- Verify negative-path filtering (invalid paths get decoyed, valid Mythic paths proxied)
- Generate Poseidon payload via webhook API with relay as callback host (~30s cross-compilation)
- Run agent on victim container, confirm callback registration and AES key exchange via postgres
- Execute C2 tasks (shell whoami, file download) via webhook API, verify completion and relay traffic
# requires Docker, Mythic GHCR images (latest), and a deployed relay
./test/e2e/validate-mythic.sh <relay-url> <auth-token>A local HTML dashboard for managing the relay from your browser. No server, no build step. Open the file and connect.
open tools/dashboard.htmlFive tabs: Health, Profile (view/edit/import), Metrics (charts and breakdown tables), Logs (filterable raw entries), and Sessions (expandable detail with path history). All tabs support JSON export.
See docs/dashboard.md for full documentation.
| File | Role |
|---|---|
src/worker.js |
Entry point, routing, operator endpoints |
src/validation.js |
Request validation pipeline (method, path, headers, UA, geo, time) |
src/proxy.js |
Backend proxying and decoy responses |
src/profile.js |
Profile loading and management (KV-backed) |
src/logging.js |
Request logging (best-effort, non-blocking) |
src/metrics.js |
HTML metrics dashboard |
src/sessions.js |
Durable Object session tracking |
src/util.js |
Response helpers, timing-safe comparison, escaping |
src/parsers/ |
C2 profile parsers (Cobalt Strike, Sliver, Mythic, Havoc, PoshC2, Oblique) |
tools/dashboard.html |
Local operator dashboard (single HTML file, no deps) |
test/e2e/ |
E2E validation suites (Sliver: 27 tests, Mythic: 33 tests) |
docs/dashboard.md |
Dashboard documentation |
docs/adding-a-parser.md |
Guide for adding new C2 parser support |
| Binding | Type | Purpose |
|---|---|---|
BACKEND_URL |
Secret | Default backend/teamserver URL |
DECOY_URL |
Secret | Redirect target for invalid traffic |
PROFILE_SECRET |
Secret | Operator auth token (gates operator endpoints only) |
LOG |
KV Namespace | Request logging with 7-day TTL |
PROFILE |
KV Namespace | Runtime profile configuration |
SESSIONS |
Durable Object | Per-implant session tracking |
npm run dev # local dev server on :8787
npm run deploy # deploy to Cloudflare
npm run tail # real-time request logs
npm run logs # list valid-traffic KV entries
npm run logs:decoy # list decoy-traffic KV entries
npm run lint # ESLint with security rules
npm run lint:fix # auto-fix lint issues
npm test # 146 tests across 12 files
npm run test:watch # tests in watch modeThere are two separate trust boundaries:
Operator endpoints (/__health, /__profile, /__metrics, /__sessions) are protected by PROFILE_SECRET. Requests must include an X-Auth-Token header with the correct token. Comparison is timing-safe (HMAC-SHA256).
Implant traffic is validated by the profile pipeline only: method, path, headers, user-agent, geo, and time window. There is no shared secret on this path. C2 frameworks typically do not include custom auth headers in their HTTP callbacks by default, so a blanket token check here would reject real implant traffic out of the box. Instead, the profile itself is the security boundary. An attacker would need to know the exact C2 configuration (path prefixes, required headers, UA pattern) to craft requests that pass validation. Even then, the backend C2 server has its own encryption and key exchange, so proxied garbage gets ignored.
Operators who want tighter control can add required headers to the profile. For example, setting "headers": {"X-Custom": "^secret-value$"} forces implant traffic to include that header. This is framework-dependent and must be configured in both the C2 profile and the relay profile.
- Secrets never in source. Use
wrangler secret putor the CF dashboard. - All C2 parser output escaped with
escapeRegexto prevent regex injection - Parsers only extract request headers (what the implant sends), never response headers
- Backend errors fall through to the decoy silently
serverandx-powered-byheaders stripped from proxied responses- HTML output escaped with
escapeHTMLto prevent XSS in the metrics dashboard - Client IP forwarded via
X-Forwarded-For - CF-specific headers (
CF-Connecting-IP,CF-IPCountry,CF-Ray) stripped before proxying - Decoy responses never expose backend URLs or secrets
- ESLint with
eslint-plugin-securityenforced in CI
See docs/adding-a-parser.md for the full guide.
- Create
src/parsers/<name>.jsexporting{ name, parse(text) }that returns a normalized profile - Register it in
src/parsers/index.js - Add tests in
test/parsers/<name>.test.jscovering parsing, edge cases, and rejection - Escape all extracted strings with
escapeRegex() - Only extract request headers, never response headers
MIT. Developed for authorized penetration testing and research. Do what you want. You are responsible for how you use it.