Guide for adding support for a new C2 framework to Oblique Relay's profile import system.
The parser system converts native C2 profile configs into a normalized profile that the relay uses for request validation. Each parser is a single JS module that extracts paths, methods, headers, and user-agent patterns from the C2's config format.
Raw C2 config → parser.parse(text) → normalized profile → schema validation → KV storage
The relay then uses the profile to decide: does this incoming request look like real implant traffic? If yes, proxy to backend. If no, send to decoy.
Every parser must return this exact shape:
{
paths: ['/callback', '/api/tasks'], // array of path prefixes (must start with /)
methods: ['GET', 'POST'], // array of uppercase HTTP methods
headers: { 'X-Custom': 'value' }, // object: header name → regex pattern (escaped)
ua_pattern: 'Mozilla/5\\.0.*', // regex string or null
geo_allow: [], // 2-letter country codes (empty = allow all)
geo_deny: [], // 2-letter country codes
time_window: null, // null or { start: "HH:MM", end: "HH:MM", tz: "UTC" }
jitter_ms: 0, // non-negative integer
backends: {}, // path prefix → backend URL (empty = use default)
}Schema validation is handled by src/parsers/schema.js, so your parser just needs to return valid data. The importProfile dispatcher in src/parsers/index.js validates the output automatically.
Create src/parsers/<name>.js:
/**
* <FrameworkName> C2 profile parser.
*
* <Brief description of the config format and what you extract.>
*
* Security: all extracted strings are escaped via escapeRegex before
* being stored as regex patterns in the profile, preventing regex injection.
*/
import { escapeRegex } from '../util.js';
export default {
name: '<name>', // must match the ?format= query parameter
parse(text) {
// 1. Parse the raw config text (JSON, YAML, custom format, etc.)
let config;
try {
config = JSON.parse(text); // or your format's parsing logic
} catch (_err) {
throw new Error('<Name> profile must be valid JSON', { cause: _err });
}
const paths = [];
const methods = ['GET', 'POST'];
const headers = {};
let uaPattern = null;
// 2. Extract paths (these become startsWith() prefixes for validation)
// Normalize: ensure each starts with /
// Deduplicate: skip if already in array
for (const p of config.paths || []) {
if (typeof p !== 'string' || !p) continue;
const normalized = p.startsWith('/') ? p : `/${p}`;
if (!paths.includes(normalized)) paths.push(normalized);
}
// 3. Extract request headers ONLY (headers the implant sends)
// NEVER include response headers (headers the C2 server sends back)
// Escape all values to prevent regex injection
for (const [k, v] of Object.entries(config.headers || {})) {
if (typeof v === 'string') headers[k] = escapeRegex(v);
}
// 4. Extract user-agent pattern (if the framework sets one)
if (config.user_agent) {
uaPattern = escapeRegex(config.user_agent);
}
// 5. Return normalized profile
return {
paths,
methods,
headers,
ua_pattern: uaPattern,
geo_allow: [],
geo_deny: [],
time_window: null,
jitter_ms: 0,
backends: {},
};
},
};Edit src/parsers/index.js:
import newParser from './<name>.js';
const REGISTRY = {
// ... existing parsers ...
[newParser.name]: newParser,
};Create test/parsers/<name>.test.js:
import { describe, it, expect } from 'vitest';
import { req, SELF, setupTestHooks } from '../helpers.js';
setupTestHooks();
describe('Malleable Profile Import - <Name>', () => {
it('parses a typical config', async () => {
const config = JSON.stringify({ /* realistic example */ });
const resp = await SELF.fetch(
req('/__profile/import?format=<name>&dry_run=true', {
method: 'POST',
body: config,
})
);
expect(resp.status).toBe(200);
const data = await resp.json();
expect(data.profile.paths).toContain('/expected/path');
expect(data.profile.methods).toContain('GET');
});
it('extracts headers correctly', async () => { /* ... */ });
it('handles edge cases', async () => { /* ... */ });
it('rejects invalid input', async () => {
const resp = await SELF.fetch(
req('/__profile/import?format=<name>&dry_run=true', {
method: 'POST',
body: 'not valid',
})
);
expect(resp.status).toBe(400);
const data = await resp.json();
expect(data.error).toBe('parse error');
});
it('rejects config with no paths', async () => {
const config = JSON.stringify({ /* config missing paths */ });
const resp = await SELF.fetch(
req('/__profile/import?format=<name>&dry_run=true', {
method: 'POST',
body: config,
})
);
expect(resp.status).toBe(400);
expect((await resp.json()).error).toBe('no URIs found in profile');
});
});- Add the format name to the
/__profile/importparams inCLAUDE.md - Add the parser file to the Key Files table in
CLAUDE.md
Every parser must have tests for:
| Category | What to test |
|---|---|
| Happy path | Typical config with paths, headers, UA. Verify all fields extracted |
| Path normalization | Paths without leading / get normalized (api → /api) |
| Deduplication | Duplicate paths are collapsed |
| Header escaping | Regex special chars in headers are escaped (1.0 → 1\\.0) |
| Request vs response headers | Response headers (server sends) are NOT included |
| User-Agent extraction | UA moves to ua_pattern, not headers |
| Empty/null UA | Missing or empty UA → ua_pattern: null |
| Invalid input | Malformed input → 400 with parse error |
| No paths | Valid parse but empty paths → 400 with no URIs found in profile |
| Dry run | ?dry_run=true returns profile without writing to KV |
These are hard requirements:
-
escapeRegex()on all extracted strings. Every string that becomes a regex pattern in the profile must be escaped viaescapeRegex()fromsrc/util.js. This prevents an operator from accidentally (or maliciously) injecting regex patterns through a C2 config that cause ReDoS or bypass validation. -
Request headers only. The profile
headersfield defines headers that incoming requests MUST have. If you include response headers (headers the C2 server sends back to implants), every request will be rejected because browsers/implants don't send those headers. This is the most common parser bug. Examples:- Sliver:
server_config.headersare response headers (excluded) - Mythic:
ServerHeadersare response headers (onlyAgentHeadersused) - Cobalt Strike:
server {}block headers (excluded, onlyclient {}block used)
- Sliver:
-
Never trust the config format. C2 configs are operator-controlled input. Validate types before using (
typeof === 'string'), handle missing fields gracefully, and don't assume array vs string format.
Understanding how the relay validates requests helps you write a correct parser:
1. Method check: request.method must be in profile.methods
2. Path check: url.pathname must startsWith() one of profile.paths
3. Header check: for each profile.headers[name], request must have
that header and its value must match the regex pattern
4. UA check: if profile.ua_pattern is set, User-Agent must match
5. Geo check: CF-IPCountry checked against geo_allow/geo_deny
6. Time check: current time must be within time_window (if set)
Note that paths are prefix-matched. If your C2 generates URLs like /api/v1/callback/abc123, the path /api/v1/callback will match. You don't need to enumerate every possible URL, just the stable prefixes.
Two parsers have full end-to-end validation suites that test the entire chain from real C2 infrastructure through the relay to a live agent:
| Suite | Script | What it validates |
|---|---|---|
| Sliver | test/e2e/validate-sliver.sh |
27 tests: profile import, negative-path filtering, beacon registration, C2 tasking (whoami + file download) |
| Mythic | test/e2e/validate-mythic.sh |
33 tests: 9-container stack, profile import from postgres, Poseidon payload build, callback registration, AES key exchange, C2 tasking |
E2E tests require Docker, the relevant C2 framework images, and a deployed relay instance. They are not part of the unit test suite (npm test) and must be run manually.
If your parser is for a framework that supports Docker-based deployment, consider adding an E2E suite. See the existing scripts for patterns.
| Parser | Config format | Path source | Header source | E2E | Notes |
|---|---|---|---|---|---|
cobalt-strike |
Text (Malleable C2) | set uri in http-get/post/stager blocks |
client { header } blocks |
Filters browser-default headers | |
sliver |
JSON (native export) | implant_config.paths (segments) |
implant_config.headers (array or object) |
27 tests | server_config.headers excluded; empty UA → null |
mythic |
JSON | get.URI / post.URI (string or array) |
AgentHeaders |
33 tests | ServerHeaders excluded; UA extracted to ua_pattern |
havoc |
Text (Yaotl/HCL-like) | Uris / Urls key |
Headers array or Header single |
Comment stripping for /* */, //, # |
|
poshc2 |
Text (Python config) | UrlsX / Urlx key |
DomainFrontHeader → Host, Referrer → Referer |
PayloadCommsHost → backends |
|
oblique |
JSON (native adapter) | paths (array or string) |
headers object |
UA in headers moves to ua_pattern; top-level user_agent takes precedence |
The test helpers in test/helpers.js provide:
req(path, opts): build authenticated request (auto-addsX-Auth-Token)reqNoAuth(path, opts): build request without authSELF: the worker instance for integration testssetupTestHooks(): call at top of describe block; activates fetchMock, clears KV profile before each test
Tests run via npm test (vitest with Cloudflare Workers pool + miniflare bindings).
Before submitting a new parser:
-
src/parsers/<name>.jscreated with{ name, parse(text) }export - Registered in
src/parsers/index.jsREGISTRY -
test/parsers/<name>.test.jswith all required coverage categories - All extracted strings escaped via
escapeRegex() - Response headers excluded from profile
headers -
npm testpasses (all existing + new tests) -
npm run lintpasses (0 errors) -
CLAUDE.mdupdated (Key Files table, import params)