Skip to content

Latest commit

 

History

History
272 lines (210 loc) · 11 KB

adding-a-parser.md

File metadata and controls

272 lines (210 loc) · 11 KB

Adding a New C2 Parser

Guide for adding support for a new C2 framework to Oblique Relay's profile import system.

Overview

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.

Normalized Profile Schema

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.

Step-by-Step

1. Create the parser module

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: {},
    };
  },
};

2. Register in the parser index

Edit src/parsers/index.js:

import newParser from './<name>.js';

const REGISTRY = {
  // ... existing parsers ...
  [newParser.name]: newParser,
};

3. Create parser tests

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');
  });
});

4. Update documentation

  • Add the format name to the /__profile/import params in CLAUDE.md
  • Add the parser file to the Key Files table in CLAUDE.md

Required Test Coverage

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

Security Requirements

These are hard requirements:

  1. escapeRegex() on all extracted strings. Every string that becomes a regex pattern in the profile must be escaped via escapeRegex() from src/util.js. This prevents an operator from accidentally (or maliciously) injecting regex patterns through a C2 config that cause ReDoS or bypass validation.

  2. Request headers only. The profile headers field 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.headers are response headers (excluded)
    • Mythic: ServerHeaders are response headers (only AgentHeaders used)
    • Cobalt Strike: server {} block headers (excluded, only client {} block used)
  3. 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.

How Validation Uses the Profile

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.

E2E Validation

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.

Reference: Existing Parsers

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

Test Utilities

The test helpers in test/helpers.js provide:

  • req(path, opts): build authenticated request (auto-adds X-Auth-Token)
  • reqNoAuth(path, opts): build request without auth
  • SELF: the worker instance for integration tests
  • setupTestHooks(): 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).

Checklist

Before submitting a new parser:

  • src/parsers/<name>.js created with { name, parse(text) } export
  • Registered in src/parsers/index.js REGISTRY
  • test/parsers/<name>.test.js with all required coverage categories
  • All extracted strings escaped via escapeRegex()
  • Response headers excluded from profile headers
  • npm test passes (all existing + new tests)
  • npm run lint passes (0 errors)
  • CLAUDE.md updated (Key Files table, import params)