DuckDB OAST Extension
February 10, 2026 · View on GitHub
A DuckDB extension for validating, decoding, and extracting OAST (Out-of-Band Application Security Testing) domains directly in SQL queries.
Features
Scalar Functions (JSON returns)
oast_validate(domain)- Check if a string is a valid OAST domain (returns BOOLEAN)oast_decode_json(domain)- Decode OAST metadata (timestamp, machine ID, PID, counter, etc.) (returns JSON)oast_extract(text)- Find all OAST domains in arbitrary text (returns JSON array)oast_extract_decode(text)- Extract and decode OAST domains in one call (returns JSON array)
SQL Macros (STRUCT returns)
Struct Macros (multi-field, efficient):
oast_struct(domain)- Full decode to STRUCT with all fieldsoast_summary(domain)- Compact STRUCT with ksort, campaign, machine_id, ts
Field Accessor Macros (single-field convenience):
oast_timestamp(domain)- Extract timestamp as TIMESTAMP WITH TIME ZONEoast_campaign(domain)- Extract campaign identifier (VARCHAR)oast_ksort(domain)- Extract K-sort prefix (VARCHAR)oast_machine_id(domain)- Extract machine ID (VARCHAR)
Extraction Helpers:
oast_count(text)- Count OAST domains in text (BIGINT)oast_has_oast(text)- Check if text contains OAST domains (BOOLEAN)oast_extract_structs(text)- Extract and decode all domains to LIST(STRUCT)oast_first(text)- Extract and decode first OAST domain from text (STRUCT)
Table Macros (use with SELECT * FROM):
oast_decode_tbl(domain)- Decode a domain into a row with all fields + timestampoast_extract_tbl(text)- Extract and decode all domains from text into rows
Supports OAST domains from: oast.pro, oast.live, oast.site, oast.online, oast.fun, oast.me, interact.sh, interactsh.com. (More planned.)
Installation
Build from Source
git clone https://codeberg.org/hrbrmstr/go-roast
cd duckdb-oast
make configure
make release
The extension binary will be at build/release/oast.duckdb_extension.
Load in DuckDB
duckdb -unsigned
LOAD './build/release/oast.duckdb_extension';
Usage Examples
Validate OAST Domains
-- Check if domain is valid OAST
SELECT oast_validate('c58bduhe008dovpvhvugcfemp9yyyyyyn.oast.pro');
-- Returns: true
SELECT oast_validate('invalid-domain.com');
-- Returns: false
Decode OAST Metadata
Option 1: Table macro (recommended -- use with SELECT * or LATERAL joins)
-- All decoded fields as columns, including a proper TIMESTAMP
SELECT * FROM oast_decode_tbl('c58bduhe008dovpvhvugcfemp9yyyyyyn.oast.pro');
-- Join decoded fields alongside existing table columns
SELECT t.client_ip, d.campaign, d.ksort, d.timestamp
FROM sensor_logs t, LATERAL oast_decode_tbl(t.domain) d;
Option 2: STRUCT output (for expressions and filters)
-- Access individual fields with dot notation
SELECT
oast_struct(domain).ksort,
oast_struct(domain).campaign
FROM sensor_logs;
-- All fields via unnest
SELECT unnest(oast_struct(domain)) FROM sensor_logs;
Option 2: JSON output (for compatibility)
-- Decode to JSON string
SELECT oast_decode_json('c58bduhe008dovpvhvugcfemp9yyyyyyn.oast.pro');
-- Returns JSON:
-- {
-- "original": "c58bduhe008dovpvhvugcfemp9yyyyyyn.oast.pro",
-- "valid": true,
-- "timestamp": 1632679674,
-- "machine_id": "2e:00:10",
-- "pid": 56447,
-- "counter": 4165629,
-- "ksort": "c58bdu",
-- "campaign": "he008",
-- "nonce": "cfemp9yyyyyyn"
-- }
-- Extract specific fields
SELECT
json_extract_string(oast_decode_json(domain), '$.ksort') as ksort,
json_extract_string(oast_decode_json(domain), '$.campaign') as campaign,
json_extract(oast_decode_json(domain), '$.timestamp') as timestamp
FROM sensor_logs;
Extract OAST Domains from Text
-- Count OAST domains in log files (fast)
SELECT line, oast_count(line) as num_domains
FROM read_csv('logs/access.log', header=false, columns={'line': 'VARCHAR'})
WHERE oast_count(line) > 0;
-- Extract domains (returns JSON array)
SELECT oast_extract(line) as domains
FROM read_csv('logs/access.log', header=false, columns={'line': 'VARCHAR'})
WHERE oast_count(line) > 0;
-- Returns: ["c58bduhe008dovpvhvugcfemp9yyyyyyn.oast.pro"]
Extract and Decode in One Call
-- Table macro: decoded domains as rows with proper columns
SELECT * FROM oast_extract_tbl('Log entry with c58bduhe008dovpvhvugcfemp9yyyyyyn.oast.pro and c5aov2fh0s0006ocs40gcfemp9yyyyyyn.oast.fun');
-- First domain only (common single-domain-per-line case)
SELECT oast_first('Log entry with c58bduhe008dovpvhvugcfemp9yyyyyyn.oast.pro').campaign;
-- Returns: he008
-- JSON output (for compatibility)
SELECT oast_extract_decode('Log entry with c58bduhe008dovpvhvugcfemp9yyyyyyn.oast.pro and c5aov2fh0s0006ocs40gcfemp9yyyyyyn.oast.fun');
-- Returns JSON array with decoded metadata for both domains
Performance Guide: Choosing the Right Function
When working with OAST domains in DuckDB, choose based on how many fields you need:
Single field (filters, GROUP BY on one key)
Use the field accessor macros. They parse JSON once per call, which is fine for one field.
SELECT * FROM sessions WHERE oast_campaign(domain) = 'he008';
-- Time-based filtering
SELECT * FROM sessions
WHERE oast_timestamp(domain) > '2025-01-01'::TIMESTAMP;
Multiple fields from the same domain
Use the table macro for the cleanest syntax. Falls back to oast_struct() subquery if needed.
-- Best: table macro with LATERAL join (parses once, clean column access)
SELECT t.*, d.campaign, d.ksort, d.machine_id
FROM sessions t, LATERAL oast_decode_tbl(t.domain) d
WHERE d.valid;
-- Alternative: struct subquery (parses once)
SELECT d.campaign, d.ksort, d.machine_id
FROM (SELECT oast_struct(domain) AS d FROM sessions WHERE oast_validate(domain));
-- Bad: parses three times
SELECT oast_campaign(domain), oast_ksort(domain), oast_machine_id(domain)
FROM sessions WHERE oast_validate(domain);
All fields
Use the table macro for SELECT *, or unnest() with struct macros.
-- Table macro (includes proper timestamp column)
SELECT * FROM sessions t, LATERAL oast_decode_tbl(t.domain) d WHERE d.valid;
-- Struct macro + unnest
SELECT unnest(oast_struct(domain)) FROM sessions WHERE oast_validate(domain);
-- Just the commonly used fields
SELECT unnest(oast_summary(domain)) FROM sessions WHERE oast_validate(domain);
Extracting from text
Use oast_extract_tbl() for the full pipeline from raw text to decoded rows.
-- Table macro: each domain becomes a row with typed columns
SELECT t.line, d.campaign, d.timestamp
FROM raw_logs t, LATERAL oast_extract_tbl(t.payload) d;
-- Single domain per line (common case)
SELECT oast_first(payload).campaign
FROM raw_logs WHERE oast_has_oast(payload);
-- Struct list approach (when you need the list itself)
SELECT unnest(oast_extract_structs(payload), recursive := true)
FROM raw_logs WHERE oast_has_oast(payload);
Threat Hunting Workflows
Filter Valid OAST Hits from Sensor Data
-- Load sensor session data
CREATE TABLE sensor_sessions AS
SELECT * FROM read_csv('sensor_data.csv');
-- Find all valid OAST callbacks with decoded metadata
SELECT
t.timestamp as log_ts,
t.client_ip,
t.domain,
d.campaign,
d.timestamp as oast_ts
FROM sensor_sessions t, LATERAL oast_decode_tbl(t.domain) d
WHERE d.valid
ORDER BY t.timestamp DESC;
Time-Based Analysis
-- Correlate OAST callbacks by timestamp
SELECT
date_trunc('hour', d.timestamp) as hour,
count(*) as callback_count,
d.campaign
FROM sensor_sessions t, LATERAL oast_decode_tbl(t.domain) d
WHERE d.valid
GROUP BY hour, d.campaign
ORDER BY hour DESC;
K-Sort Deduplication
-- Deduplicate callbacks using K-sort identifier
SELECT
d.ksort,
d.campaign,
min(d.timestamp) as first_seen,
max(d.timestamp) as last_seen,
count(*) as total_hits
FROM sensor_sessions t, LATERAL oast_decode_tbl(t.domain) d
WHERE d.valid
GROUP BY d.ksort, d.campaign;
Extract from Arbitrary Logs
-- Scan web server logs: extract and decode all OAST domains as rows
SELECT t.line, d.campaign, d.ksort, d.timestamp
FROM read_csv('logs/access.log', header=false, columns={'line': 'VARCHAR'}) t,
LATERAL oast_extract_tbl(t.line) d;
-- Quick scan: first domain per line only
SELECT line, oast_first(line).campaign as campaign
FROM read_csv('logs/access.log', header=false, columns={'line': 'VARCHAR'})
WHERE oast_has_oast(line);
-- JSON output (for raw domain lists)
SELECT line, oast_extract(line) as found_domains
FROM read_csv('logs/access.log', header=false, columns={'line': 'VARCHAR'})
WHERE oast_has_oast(line);
Function Reference
SQL Macros (Ergonomic Wrappers)
Struct Macros (efficient multi-field access)
oast_struct(domain VARCHAR) -> STRUCT
Decodes OAST domain and returns native STRUCT for ergonomic field access. Parses JSON once.
- Input: OAST domain (subdomain or FQDN)
- Returns: STRUCT with fields:
original: Original inputvalid: Whether decode succeededts: Unix timestamp (BIGINT)machine_id: 3-byte machine identifier (VARCHAR hex)pid: Process ID (INTEGER)counter: Counter value (INTEGER)ksort: First 6 chars for K-sorting (VARCHAR)campaign: Campaign identifier (VARCHAR)nonce: z-base-32 nonce (VARCHAR)
- NULL handling: Returns NULL for NULL input
Example:
-- Field access
SELECT oast_struct(domain).ksort, oast_struct(domain).ts FROM logs;
-- Star expansion via unnest
SELECT unnest(oast_struct(domain)) FROM logs;
oast_summary(domain VARCHAR) -> STRUCT
Returns compact STRUCT with just the commonly used fields. Parses JSON once.
- Input: OAST domain (subdomain or FQDN)
- Returns: STRUCT with fields:
ksort: K-sort prefix (VARCHAR)campaign: Campaign identifier (VARCHAR)machine_id: Machine ID (VARCHAR)ts: Unix timestamp (BIGINT)
- NULL handling: Returns NULL for NULL input
Example:
SELECT unnest(oast_summary(domain)) FROM logs WHERE oast_validate(domain);
Field Accessor Macros (single-field convenience)
Each of these parses JSON independently. Use for single-field access in filters or sorting.
oast_timestamp(domain VARCHAR) -> TIMESTAMP WITH TIME ZONE
Returns decoded timestamp as a proper DuckDB TIMESTAMP.
Example:
SELECT * FROM logs WHERE oast_timestamp(domain) > '2025-01-01'::TIMESTAMP;
oast_campaign(domain VARCHAR) -> VARCHAR
Extracts the campaign identifier field.
Example:
SELECT * FROM logs WHERE oast_campaign(domain) = 'he008';
oast_ksort(domain VARCHAR) -> VARCHAR
Extracts the K-sort prefix (first 6 chars of preamble).
Example:
SELECT DISTINCT oast_ksort(domain) FROM logs WHERE oast_validate(domain);
oast_machine_id(domain VARCHAR) -> VARCHAR
Extracts the machine ID field.
Example:
SELECT oast_machine_id(domain), count(*) FROM logs GROUP BY oast_machine_id(domain);
Extraction Helper Macros
oast_count(text VARCHAR) -> BIGINT
Counts OAST domains in arbitrary text.
- Input: Text to search
- Returns: Number of OAST domains found
- NULL handling: Returns NULL for NULL input
Example:
-- Filter rows with OAST domains
SELECT * FROM logs WHERE oast_count(payload) > 0;
-- Aggregate by count
SELECT oast_count(payload) as n, count(*) as rows
FROM logs GROUP BY oast_count(payload);
oast_has_oast(text VARCHAR) -> BOOLEAN
Quick predicate for filtering rows that contain OAST domains. Equivalent to oast_count(text) > 0.
Example:
SELECT line FROM read_csv('access.log', header=false, columns={'line':'VARCHAR'})
WHERE oast_has_oast(line);
oast_extract_structs(text VARCHAR) -> LIST(STRUCT)
Extract all OAST domains from text, decode each, return as list of structs. Full pipeline in one call.
- Input: Text to search
- Returns: LIST(STRUCT) with same fields as
oast_struct() - NULL handling: Returns NULL for NULL input
Example:
-- Extract, decode, and flatten to rows
SELECT unnest(oast_extract_structs(payload), recursive := true) FROM raw_logs WHERE oast_has_oast(payload);
-- Access specific fields
SELECT s.campaign FROM (SELECT unnest(oast_extract_structs(payload)) AS s FROM raw_logs);
oast_first(text VARCHAR) -> STRUCT
Extract and decode the first OAST domain found in text. Returns a single STRUCT (not a list). Convenient for the common one-domain-per-line pattern.
- Input: Text to search
- Returns: STRUCT with same fields as
oast_struct(), or NULL if no OAST domains found - NULL handling: Returns NULL for NULL input
Example:
-- Single-field access on first domain
SELECT oast_first(line).campaign
FROM read_csv('access.log', header=false, columns={'line':'VARCHAR'})
WHERE oast_has_oast(line);
Table Macros
Table macros return result sets and are used in FROM clauses. They support SELECT * and LATERAL joins naturally.
oast_decode_tbl(domain VARCHAR) -> TABLE
Decodes an OAST domain into a single-row table with all fields plus a proper timestamp column (TIMESTAMP WITH TIME ZONE).
- Input: OAST domain (subdomain or FQDN)
- Returns: Row with fields:
original,valid,ts,machine_id,pid,counter,ksort,campaign,nonce,timestamp
Example:
-- Standalone
SELECT * FROM oast_decode_tbl('c58bduhe008dovpvhvugcfemp9yyyyyyn.oast.pro');
-- LATERAL join with existing table
SELECT t.client_ip, d.campaign, d.timestamp
FROM sensor_logs t, LATERAL oast_decode_tbl(t.domain) d
WHERE d.valid;
oast_extract_tbl(text VARCHAR) -> TABLE
Extracts all OAST domains from text, decodes each, and returns one row per domain with all fields plus a proper timestamp column.
- Input: Text to search
- Returns: One row per domain found, with fields:
original,valid,ts,machine_id,pid,counter,ksort,campaign,nonce,timestamp
Example:
-- Standalone
SELECT * FROM oast_extract_tbl('log with c58bduhe008dovpvhvugcfemp9yyyyyyn.oast.pro embedded');
-- LATERAL join for log scanning
SELECT t.line, d.campaign, d.timestamp
FROM read_csv('access.log', header=false, columns={'line':'VARCHAR'}) t,
LATERAL oast_extract_tbl(t.line) d;
Scalar Functions (JSON/Boolean Returns)
oast_validate(domain VARCHAR) -> BOOLEAN
Checks if the input is a valid OAST domain (subdomain + known OAST domain suffix).
- Input: Domain string (subdomain or FQDN)
- Returns:
trueif valid OAST domain,falseotherwise - NULL handling: Returns
NULLforNULLinput
oast_decode_json(domain VARCHAR) -> VARCHAR
Decodes OAST preamble and returns metadata as JSON string.
- Input: OAST domain (subdomain or FQDN)
- Returns: JSON object with fields:
original: Original input stringvalid: Whether decode succeededts: Unix timestamp (seconds)machine_id: 3-byte machine identifier (hex format)pid: Process IDcounter: 24-bit counter valueksort: First 6 chars of preamble (for K-sorting)campaign: Chars 7-11 of preamble (campaign identifier)nonce: z-base-32 nonce portionerror: Error message if invalid
- NULL handling: Returns
NULLforNULLinput
oast_extract(text VARCHAR) -> VARCHAR
Extracts all OAST domains found in arbitrary text.
- Input: Text to search
- Returns: JSON array of OAST domain strings
- NULL handling: Returns
NULLforNULLinput
oast_extract_decode(text VARCHAR) -> VARCHAR
Extracts and decodes all OAST domains in text (combines extract + decode).
- Input: Text to search
- Returns: JSON array of decoded OAST objects
- NULL handling: Returns
NULLforNULLinput
Build System
Requirements
- CMake 3.5+
- C99 compiler
- Python 3 (for build scripts)
- Git (for version metadata)
Make Targets
make configure- Set up build environment (Python venv, dependencies)make debug- Build debug version with symbolsmake release- Build optimized release versionmake test_debug- Run SQLLogicTest suite (debug)make test_release- Run SQLLogicTest suite (release)make clean- Remove build artifactsmake clean_all- Full clean including configure
Build Variables
Set in Makefile:
EXTENSION_NAME- Extension name (default: oast)USE_UNSTABLE_C_API- Use unstable DuckDB C API (default: 0)TARGET_DUCKDB_VERSION- DuckDB version to target (default: v1.2.0)
Architecture
C Source Layout
src/
├── oast_extension.c # Extension entry point
├── oast_functions.c # DuckDB function registration
├── oast_decode.c # Preamble decoding (base32hex -> XID fields)
├── oast_extract.c # Domain extraction (hand-rolled matcher)
├── oast_validate.c # Domain validation
├── oast_base32.c # Base32hex encoding utilities
├── oast_domains.c # Known OAST domain list
└── include/ # Header files
Design Decisions
- No external dependencies - Pure C implementation, no regex libraries
- Hand-rolled extractor - Avoids POSIX regex or PCRE2 dependencies
- JSON string returns - Stable C API compatible, works with DuckDB's JSON functions
- Stable C API only - Uses DuckDB stable C API (v1.2.0+)
Testing
Run Test Suite
make test_debug
make test_release
Test Coverage
- Validation with valid/invalid domains
- Decoding with test vectors from Go reference implementation
- Extraction from text with embedded domains
- NULL input handling
- Batch processing with CSV data
Performance
Binary sizes:
- Debug build: ~55KB
- Release build: ~51KB (stripped)
The extension uses vectorized processing for batch operations, processing entire DuckDB data chunks at once.
Related Projects
This extension ports the OAST decode/extract logic from go-roast, a Go-based MCP server for OAST domain analysis.
License
MIT
Contributing
Issues and pull requests welcome.