From 978989f5bc6dbe8ff9c4325fc5852ea300035e25 Mon Sep 17 00:00:00 2001 From: Andrew Schwartz Date: Thu, 22 Oct 2026 09:32:46 -0500 Subject: [PATCH 01/10] docs(pcap): architecture decision record for PCAP ingestion Schema (Network.*/Kerberos.*), Tau-engine mapping, native vs tshark backend decision, Dissector trait seam, TCP reassembly + encryption caveats. Compilation-UNVERIFIED (authored without a Rust toolchain). Co-Authored-By: Claude Opus 4.8 --- docs/pcap-ingestion.md | 246 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 docs/pcap-ingestion.md diff --git a/docs/pcap-ingestion.md b/docs/pcap-ingestion.md new file mode 100644 index 0000000..46ae357 --- /dev/null +++ b/docs/pcap-ingestion.md @@ -0,0 +1,246 @@ +# ADR: PCAP ingestion for Chainsaw + +**Status:** Prototype / proposal. **Compilation state: UNVERIFIED.** This branch was +authored in an environment with no Rust toolchain. None of the code here has been +compiled or run. Treat every "works" statement as "intended to work, pending a build on +a box with `cargo`". See `README-pcap.md` for build/test steps. + +## 1. Goal + +Let `chainsaw hunt capture.pcap` dissect network traffic into `field -> value` documents +that the **existing** Tau rule engine and Sigma mapping layer can match, so the SOC can +detect network-only AD attacks (Golden/Silver ticket PAC anomalies, Rubeus/Mimikatz wire +fingerprints, Kerberos etype/flag abuse) with the *same* tool and the *same* rule format +they already use for EVTX/MFT/JSON. One tool, no pivot to Zeek/Suricata. + +Non-goal: re-implementing Wireshark. We want a **thin, correct** path for the +highest-value protocol (Kerberos on port 88), with a documented seam so other protocols +can be added later or delegated to tshark. + +## 2. How a "file kind" works in Chainsaw today + +Reading the existing code (`src/file/mod.rs`, `src/file/evtx.rs`, `src/file/mft.rs`, +`src/hunt.rs`, `src/value.rs`) the contract for a new input type is: + +1. A module under `src/file/` exposes a `Parser` with: + - `Parser::load(path) -> crate::Result` + - `Parser::parse(&mut self) -> impl Iterator>` + where `T` is some per-record payload. For `mft`/`json`/`esedb`, `T = serde_json::Value`. +2. `file::Document` gains a variant wrapping `T`; `file::Kind` gains a variant; + `Kind::extensions()` lists the file extensions. +3. `Reader::load` dispatches on extension to the right `Parser`; `Reader::documents` + maps the parser's iterator into `Document::`; `Reader::kind` reports it. +4. `hunt.rs` converts each `Document` into a `crate::value::Value` (via `From`), + then **optionally wraps it** in a per-kind `Document`-trait wrapper that aliases + awkward field paths (this is exactly what `evtx::Wrapper` does, remapping + `Event.System.Provider` to `Event.System.Provider_attributes.Name`). The wrapped + value is handed to `hunt.mapper.mapped(...)` and ultimately to `tau_engine::...solve`. +5. The Sigma **mapping** YAML (`mappings/*.yml`) declares `kind: ` plus a + `groups:` block whose `fields:` list remaps Sigma field names (`from`) onto document + paths (`to`). Fields not listed fall through to a direct `document.find(key)` lookup + (see `Mapper`/`MapperKind::Fast` in `hunt.rs`), so a document that already emits clean + dotted paths needs only a thin mapping. +6. Tau/`Value::find` already walks dotted paths into nested objects (that is how the evtx + wrapper resolves `Event.System.Provider_attributes.Name`). So **nested JSON objects + with a stable namespace are matched by dotted keys for free** — no flattening needed. + +Our pcap kind follows this contract exactly. EVTX/MFT/JSON code paths are untouched. + +> Note on `--kind`: upstream `chainsaw hunt --kind` selects the *rule* kind +> (`chainsaw`/`sigma`/`stalker`), **not** the file kind. File kind is auto-detected from +> the extension and declared by the mapping's `kind:` field. We therefore wire pcap in via +> (a) `.pcap`/`.pcapng` extension detection in `Reader::load` + `Kind::extensions`, and +> (b) `kind: pcap` in the mapping. There is intentionally no new file-level `--kind` flag; +> adding one would diverge from the upstream pattern. `--kind pcap` in the task brief maps +> onto the mapping's `kind: pcap` declaration. + +## 3. Document schema (the stable contract) + +One document is emitted **per dissected application-layer message** (one Kerberos message += one document). Everything is a nested JSON object under two namespaces: + +- `Network.*` — L3/L4 envelope, protocol-independent. +- `Kerberos.*` / `Ldap.*` / `Smb.*` / `Dcerpc.*` — per-application-protocol fields. + Only `Kerberos.*` is implemented in this first cut. + +### 3.1 `Network.*` (emitted for every document) + +| Field | Type | Meaning | +|---|---|---| +| `Network.timestamp` | string | Packet capture time, `%Y-%m-%dT%H:%M:%S%.6fZ` (the format `hunt.rs` parses). This is the hunt timestamp. | +| `Network.protocol` | string | App protocol: `kerberos` (future: `ldap`, `smb`, `dcerpc`). | +| `Network.transport` | string | `udp` or `tcp`. | +| `Network.src_ip` / `Network.dst_ip` | string | L3 addresses (v4 or v6 text form). | +| `Network.src_port` / `Network.dst_port` | uint | L4 ports. | +| `Network.length` | uint | Length in bytes of the app-layer message dissected. | +| `Network.frame` | uint | 1-based index of the source packet in the capture (for triage). | + +### 3.2 `Kerberos.*` (RFC 4120 wire fields rules key on) + +All of these come straight off the wire. Fields that live in encrypted parts +(authenticator contents, PA-ENC-TIMESTAMP plaintext) are **not** decrypted; where the +brief asks for "authenticator sequence bytes where visible" we expose the *ciphertext* +prefix and the sizes, and say so plainly — a rule keyed on a plaintext authenticator value +only fires on traffic where that part is observable (unencrypted test traffic, or a +capture the analyst has pre-decrypted). We never claim to see inside `EncryptedData`. + +| Field | Type | Source (RFC 4120) | +|---|---|---| +| `Kerberos.msg_type` | uint | APPLICATION tag → 10 AS-REQ, 11 AS-REP, 12 TGS-REQ, 13 TGS-REP, 14 AP-REQ, 30 KRB-ERROR | +| `Kerberos.msg_type_name` | string | Human name of the above | +| `Kerberos.pvno` | uint | Protocol version (5) | +| `Kerberos.realm` | string | `realm` / `crealm` | +| `Kerberos.cname` | array | client PrincipalName name-string components | +| `Kerberos.cname_string` | string | components joined with `/` | +| `Kerberos.cname_type` | uint | client name-type | +| `Kerberos.sname` | array | service PrincipalName components (req body or ticket) | +| `Kerberos.sname_string` | string | components joined with `/` (e.g. `krbtgt/CONTOSO.COM`) | +| `Kerberos.sname_type` | uint | service name-type | +| `Kerberos.kdc_options` | string | KDCOptions 32-bit flags, hex (`req` only) | +| `Kerberos.kdc_options_flags` | array | decoded flag names (forwardable, renewable, renewable-ok, renew, ...) | +| `Kerberos.ap_options` | string | APOptions 32-bit flags, hex (`AP-REQ` only) | +| `Kerberos.etypes` | array | requested encryption types, **in wire order** | +| `Kerberos.etype_count` | uint | number of etypes offered | +| `Kerberos.etype_first` | int | first (most-preferred) etype — the one the client leads with | +| `Kerberos.nonce` | uint | request nonce | +| `Kerberos.from` / `Kerberos.till` / `Kerberos.rtime` | string | KerberosTime GeneralizedTime (`till` is the ticket-lifetime field Rubeus hard-codes) | +| `Kerberos.renew_till` | string | renew-till where present | +| `Kerberos.cusec` | uint | client microseconds (only when observable in a plaintext structure) | +| `Kerberos.ctime` | string | client time where observable | +| `Kerberos.padata_types` | array | PA-DATA `padata-type` values present, in order | +| `Kerberos.pa_pac_request` | bool | PA-PAC-REQUEST (128) present | +| `Kerberos.pa_pac_options` | string | PA-PAC-OPTIONS (167) value hex, where present | +| `Kerberos.pa_for_user` | bool | PA-FOR-USER (129) present → S4U2self | +| `Kerberos.pa_for_user_name` | string | S4U2self target principal, where parseable | +| `Kerberos.enc_part_etype` | int | enc-part `EncryptedData.etype` | +| `Kerberos.enc_part_kvno` | uint | enc-part key version, where present | +| `Kerberos.enc_part_size` | uint | enc-part cipher length (bytes) | +| `Kerberos.authenticator_etype` | int | AP-REQ authenticator `EncryptedData.etype` | +| `Kerberos.authenticator_size` | uint | AP-REQ authenticator cipher length | +| `Kerberos.authenticator_seq_hex` | string | **ciphertext** prefix (up to 8 bytes) of the AP-REQ authenticator — see caveat above | +| `Kerberos.ticket_sname_string` | string | sname carried inside an AP-REQ `Ticket` | +| `Kerberos.ticket_realm` | string | realm carried inside an AP-REQ `Ticket` | + +This namespace is the **stable contract** rules depend on. Adding fields is fine; renaming +or re-typing is a breaking change for the rule pack. + +## 4. Mapping onto the Tau engine + +Because the document already emits clean dotted paths, `mappings/sigma-network.yml` is +thin: + +- `kind: pcap`, `rules: sigma`. +- One group whose `filter: { Network.protocol: "*" }` selects every network document + (`Network.protocol` exists on all of them). +- `timestamp: Network.timestamp`. +- A short `fields:` block giving friendly Sigma aliases (`MsgType -> Kerberos.msg_type`, + etc.). Sigma rules may also reference the full `Kerberos.*` path directly, which the + Mapper's fall-through resolves unchanged. + +Sigma modifiers (`|contains`, `|all`, `int(...)`, globbing) work exactly as they do for +EVTX because the same Tau engine evaluates them against the same `Value` tree. + +A `pcap::Wrapper` (analogous to `evtx::Wrapper`) is provided and wired into `hunt.rs` / +`cli.rs` / `search.rs`. For the current schema it mostly delegates to the inner value, but +it gives us the same aliasing seam evtx uses — e.g. letting a rule say `Kerberos.cname` +and resolve to `Kerberos.cname_string` without an array-vs-scalar surprise. + +## 5. Dissection backend decision + +Two honest routes were considered: + +### Route A — tshark-backed via the `rtshark` crate +- **Pros:** fastest path to broad, correct coverage; Wireshark already dissects every AD + protocol; we'd just translate its field tree into `Network.*`/`Kerberos.*`. +- **Cons:** adds a **runtime dependency on a `tshark` binary** on every analyst box. That + directly contradicts the "one tool, no external pivot" goal — it just moves the second + tool from Zeek to tshark, and couples us to tshark's field naming and versioning. It + also spawns a subprocess per capture, which complicates the error model and packaging. + +### Route B — native dissection (the real target) +- `pcap-parser` (pure-Rust, rusticata) reads both `.pcap` (legacy) and `.pcapng`; no + libpcap/system dependency. +- `etherparse` (pure-Rust) parses L2→L4 (Ethernet / IPv4 / IPv6 / UDP / TCP). +- **Kerberos ASN.1:** a small, purpose-built BER/DER TLV reader lives in `pcap.rs`. We + deliberately did **not** pull in a general ASN.1 crate (`rasn`/`der`) for the first cut: + (1) Kerberos uses only a tiny slice of BER (low-tag-number application + context tags, + definite lengths, INTEGER/OCTET STRING/GeneralizedTime/BIT STRING/SEQUENCE), so a ~150 + line reader covers it with full control over exactly the fields rules need; (2) it keeps + the dependency surface — and the compile-risk — minimal; (3) it avoids dragging a derive + world and generated Kerberos types we'd only use 10% of. `rasn-kerberos` remains the + obvious drop-in if we later want the full type set — the `Dissector` trait makes that a + module swap, not a rewrite. +- **Pros:** self-contained single binary; keeps the one-tool promise. +- **Cons:** we own the dissection and TCP reassembly; more code; more that can be wrong. + +### Decision + +**Ship Route B (native) for Kerberos-over-88 as the first cut**, behind a `Dissector` +trait. Expose **Route A (tshark) as an optional, feature-gated backend** (`--features +tshark`) for teams that want breadth now and accept the runtime dependency. Default build +is native and self-contained. This honours the one-tool goal in the default build while +leaving tshark as an explicit opt-in. + +Rationale: Kerberos is the single highest-value AD-over-the-wire protocol (ticket +abuse, etype downgrade, S4U, PAC anomalies all show up on 88), its request bodies are +largely **plaintext**, and it is small enough to dissect correctly by hand. Breadth +(LDAP/SMB/DCERPC) is better earned incrementally against the trait than bought all at once +by taking a tshark dependency. + +## 6. The backend seam + +```text + +------------------------------------+ + packets --> | pcap-parser (pcap / pcapng) | + +------------------------------------+ + | raw L2 frame + ts + frame# + v + +------------------------------------+ + | etherparse (Eth/IP/UDP/TCP) | + +------------------------------------+ + | FlowMeta + app payload (TCP reassembled per-flow) + v + +------------------------------------+ + | trait Dissector (port-claimed) | + | - KerberosDissector [REAL] | + | - LdapDissector [STUB] | + | - SmbDissector [STUB] | + | - DcerpcDissector [STUB] | + +------------------------------------+ + | serde_json::Value (Network.* / Kerberos.*) + v + Document::Pcap --> value::Value --> pcap::Wrapper + | + v + hunt.mapper.mapped(...) --> tau_engine solve +``` + +`trait Dissector { fn claims(&self, port: u16) -> bool; fn dissect(&self, meta: &FlowMeta, +payload: &[u8]) -> Vec; }`. Adding LDAP/SMB/DCERPC = implement the +trait and register it in the dissector table. tshark, when enabled, is a single alternate +`Dissector` implementation that shells out and maps Wireshark fields into the same schema. + +## 7. TCP reassembly (first-cut limitation, stated plainly) + +Kerberos over TCP (RFC 4120 §7.2.2) prefixes each message with a 4-byte big-endian length. +UDP carries one bare message per datagram. The first cut: + +- UDP/88: parse each datagram directly as one Kerberos message. +- TCP/88: buffer payload **in arrival order** per 4-tuple flow, then repeatedly pull + `len`-prefixed messages off the front of the buffer. + +This is correct for in-order, loss-free captures (the common lab/SOC case) but does **not** +do sequence-number reordering, retransmit de-duplication, or gap detection. That is a +documented TODO; a production version should key on TCP seq and handle holes. We prefer to +ship the honest limited version over a fake-complete one. + +## 8. What is real vs stubbed in this branch + +- **Real (native):** pcap/pcapng read, Eth/IPv4/IPv6/UDP/TCP parse, in-order TCP/88 + reassembly, Kerberos BER dissection of AS-REQ/REP, TGS-REQ/REP, AP-REQ (+ KRB-ERROR + envelope), the full `Network.*`/`Kerberos.*` schema above, the `pcap::Wrapper`, the + mapping, and three Sigma rules. +- **Stubbed (trait impls returning no documents, with TODOs):** LDAP (389/636), + SMB (445), DCERPC (135/dynamic). +- **Optional/feature-gated, not built by default:** tshark backend via `rtshark`. +- **Everything is compilation-UNVERIFIED** — authored without a Rust toolchain. From 3697e55d5bcdf79eefc07f9f734114d8d5117208 Mon Sep 17 00:00:00 2001 From: Andrew Schwartz Date: Thu, 22 Oct 2026 09:49:57 -0500 Subject: [PATCH 02/10] build(pcap): add pcap-parser + etherparse; optional tshark feature Native dissection backend deps (pure-Rust, no libpcap/tshark). rtshark behind an off-by-default 'tshark' feature. Versions compilation-UNVERIFIED. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 39cb1c9..7434097 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,18 @@ chrono = "0.4" chrono-tz = { version = "0.10", features = ["serde"] } clap = { version = "4.0", features = ["derive"] } crossterm = "0.29" +# --- PCAP ingestion (feat/pcap-ingestion) ----------------------------------- +# NOTE: versions below are compilation-UNVERIFIED (authored without a Rust +# toolchain). Pin exact versions with `cargo update -p ` on a build box. +# +# Native dissection backend (default, self-contained, no libpcap/tshark needed): +# pcap-parser : pure-Rust reader for both legacy .pcap and .pcapng containers. +# etherparse : pure-Rust Ethernet/IPv4/IPv6/UDP/TCP parsing (L2..L4). +# Kerberos ASN.1 (RFC 4120) is dissected by a small hand-rolled BER/DER TLV +# reader inside src/file/pcap.rs — we intentionally avoid a general ASN.1 crate +# (rasn/der) for the first cut to keep the dependency + compile surface minimal. +pcap-parser = "0.16" +etherparse = "0.16" evtx = { version = "0.12", default-features = false } hex = "0.4.3" indicatif = "0.18" @@ -42,6 +54,18 @@ tempfile = "3.2" terminal_size = "0.4" uuid = { version = "1.1", features = ["serde", "v4"] } +# Optional tshark-backed dissection backend. OFF by default so the default build +# stays a single self-contained binary (the "one tool" goal). Enable with +# `cargo build --release --features tshark`. Adds a RUNTIME dependency on a +# `tshark` binary being installed on the host. +rtshark = { version = "2", optional = true } + + +[features] +default = [] +# tshark backend: breadth now, at the cost of a tshark runtime dependency. +tshark = ["dep:rtshark"] + [dev-dependencies] assert_cmd = "2.0" From fb8d4bad64a493b1ce1dccb686e70fd63e65762c Mon Sep 17 00:00:00 2001 From: Andrew Schwartz Date: Thu, 22 Oct 2026 09:49:57 -0500 Subject: [PATCH 03/10] feat(pcap): native Kerberos-over-88 dissection as a new file kind src/file/pcap.rs: pcap/pcapng read (pcap-parser) -> L2-L4 (etherparse) -> in-order TCP/88 reassembly -> hand-rolled BER/DER Kerberos dissection (AS-REQ/REP, TGS-REQ/REP, AP-REQ, KRB-ERROR). Emits one Network.*/Kerberos.* document per message. Dissector trait seam with LDAP/SMB/DCERPC stubs and a pcap::Wrapper analogous to evtx::Wrapper. Wired into file::{Kind,Document, Parser,Reader} + lib re-export. Compilation-UNVERIFIED. Co-Authored-By: Claude Opus 4.8 --- src/file/mod.rs | 31 ++ src/file/pcap.rs | 1041 ++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 +- 3 files changed, 1073 insertions(+), 1 deletion(-) create mode 100644 src/file/pcap.rs diff --git a/src/file/mod.rs b/src/file/mod.rs index d436700..82cd2e6 100644 --- a/src/file/mod.rs +++ b/src/file/mod.rs @@ -12,6 +12,7 @@ use self::evtx::{Evtx, Parser as EvtxParser}; use self::hve::{Hve, Parser as HveParser}; use self::json::{Json, Parser as JsonParser, lines::Parser as JsonlParser}; use self::mft::{Mft, Parser as MftParser}; +use self::pcap::{Parser as PcapParser, Pcap}; use self::xml::{Parser as XmlParser, Xml}; pub mod esedb; @@ -19,6 +20,7 @@ pub mod evtx; pub mod hve; pub mod json; pub mod mft; +pub mod pcap; pub mod xml; #[derive(Clone)] @@ -29,6 +31,7 @@ pub enum Document { Mft(Mft), Xml(Xml), Esedb(Esedb), + Pcap(Pcap), } pub struct Documents<'a> { @@ -45,6 +48,7 @@ pub enum Kind { Mft, Xml, Esedb, + Pcap, Unknown, } @@ -62,6 +66,7 @@ impl Kind { ]), Kind::Xml => Some(vec!["xml".to_string()]), Kind::Esedb => Some(vec!["dat".to_string(), "edb".to_string()]), + Kind::Pcap => Some(vec!["pcap".to_string(), "pcapng".to_string()]), Kind::Unknown => None, } } @@ -93,6 +98,7 @@ pub enum Parser { Mft(MftParser), Xml(XmlParser), Esedb(EsedbParser), + Pcap(PcapParser), Unknown, } @@ -270,6 +276,28 @@ impl Reader { parser: Parser::Esedb(parser), }) } + "pcap" | "pcapng" => { + let parser = match PcapParser::load(file) { + Ok(parser) => parser, + Err(e) => { + if skip_errors { + cs_eyellowln!( + "[!] failed to load file '{}' - {}\n", + file.display(), + e + ); + return Ok(Self { + parser: Parser::Unknown, + }); + } else { + anyhow::bail!(e); + } + } + }; + Ok(Self { + parser: Parser::Pcap(parser), + }) + } _ => { if load_unknown { if let Ok(parser) = EvtxParser::load(file) { @@ -412,6 +440,8 @@ impl Reader { .map(|r| r.map(Document::Esedb)), ) as Box> + Send + 'a>, + Parser::Pcap(parser) => Box::new(parser.parse().map(|r| r.map(Document::Pcap))) + as Box> + Send + 'a>, Parser::Unknown => { Box::new(Unknown) as Box> + Send + 'a> } @@ -428,6 +458,7 @@ impl Reader { Parser::Mft(_) => Kind::Mft, Parser::Xml(_) => Kind::Xml, Parser::Esedb(_) => Kind::Esedb, + Parser::Pcap(_) => Kind::Pcap, Parser::Unknown => Kind::Unknown, } } diff --git a/src/file/pcap.rs b/src/file/pcap.rs new file mode 100644 index 0000000..b99b561 --- /dev/null +++ b/src/file/pcap.rs @@ -0,0 +1,1041 @@ +//! PCAP ingestion: dissect network captures into `Network.*` / `Kerberos.*` +//! documents the existing Tau rule engine + Sigma mappings can match. +//! +//! COMPILATION STATE: UNVERIFIED. This module was authored without a Rust +//! toolchain and has never been compiled or run. See `docs/pcap-ingestion.md` +//! and `README-pcap.md`. The schema and control flow are the deliverable; the +//! exact `pcap-parser` / `etherparse` API calls are the most likely first-build +//! breakage (version drift) and are flagged inline with `// API:` notes. +//! +//! First cut (native backend): Kerberos over UDP/TCP 88 only. LDAP/SMB/DCERPC +//! are stubbed behind the `Dissector` trait with TODOs. The optional `tshark` +//! backend (feature = "tshark") is a seam only in this first cut. + +use std::collections::HashMap; +use std::fs::File; +use std::net::IpAddr; +use std::path::Path; +use std::vec::IntoIter; + +use serde_json::{Value as Json, json}; + +pub type Pcap = Json; + +// =========================================================================== +// Parser: mirrors the shape of mft::Parser / json::Parser. +// load() -> cheap, stashes the path +// parse() -> does the work, yields one Result per Kerberos message +// =========================================================================== + +pub struct Parser { + path: std::path::PathBuf, +} + +impl Parser { + pub fn load(path: &Path) -> crate::Result { + // Validate the container up front so a bad file fails here (matching the + // other parsers, which open in `load`). We re-open in `parse`. + let _ = File::open(path)?; + Ok(Self { + path: path.to_path_buf(), + }) + } + + pub fn parse(&mut self) -> impl Iterator> + '_ { + // Eagerly dissect the whole capture into a Vec, then hand back an owning + // iterator (same approach mft::Parser uses for collected entries). This + // keeps lifetimes trivial and lets us carry per-flow TCP reassembly + // state locally. + let docs = match dissect_capture(&self.path) { + Ok(docs) => docs.into_iter().map(Ok).collect::>(), + Err(e) => vec![Err(e)], + }; + ParserIter(docs.into_iter()) + } +} + +struct ParserIter(IntoIter>); + +impl Iterator for ParserIter { + type Item = crate::Result; + + fn next(&mut self) -> Option { + self.0.next() + } + + fn size_hint(&self) -> (usize, Option) { + self.0.size_hint() + } +} + +// =========================================================================== +// Wrapper: analogous to evtx::Wrapper. Our documents already emit clean dotted +// paths, so this mostly delegates; it exists to give us the same aliasing seam +// evtx uses (and so `hunt.rs`/`cli.rs` can treat pcap like evtx uniformly). +// =========================================================================== + +use crate::value::Value; +use tau_engine::{Document as TauDocument, Value as Tau}; + +pub struct Wrapper<'a>(pub &'a Value); +impl TauDocument for Wrapper<'_> { + fn find(&self, key: &str) -> Option> { + match key { + // Convenience aliases: let a rule say `Kerberos.cname` / `Kerberos.sname` + // and resolve to the joined scalar form without worrying about the + // array-vs-scalar shape. + "Kerberos.cname" => self + .0 + .find("Kerberos.cname_string") + .or_else(|| self.0.find("Kerberos.cname")), + "Kerberos.sname" => self + .0 + .find("Kerberos.sname_string") + .or_else(|| self.0.find("Kerberos.sname")), + _ => self.0.find(key), + } + } +} + +// =========================================================================== +// Flow metadata + the Dissector seam +// =========================================================================== + +#[derive(Clone, Debug)] +pub struct FlowMeta { + pub timestamp: String, // %Y-%m-%dT%H:%M:%S%.6fZ + pub transport: &'static str, // "udp" | "tcp" + pub src_ip: IpAddr, + pub dst_ip: IpAddr, + pub src_port: u16, + pub dst_port: u16, + pub frame: u64, +} + +impl FlowMeta { + /// Build the common `Network.*` object shared by every document from this flow. + fn network_object(&self, protocol: &str, length: usize) -> Json { + json!({ + "timestamp": self.timestamp, + "protocol": protocol, + "transport": self.transport, + "src_ip": self.src_ip.to_string(), + "dst_ip": self.dst_ip.to_string(), + "src_port": self.src_port, + "dst_port": self.dst_port, + "length": length, + "frame": self.frame, + }) + } +} + +/// An application-layer dissector. Implementations claim one or more L4 ports +/// and turn a (reassembled) application message buffer into zero or more +/// documents. Adding LDAP/SMB/DCERPC — or a tshark-backed backend — means +/// implementing this trait and registering it, not touching the pipeline. +pub trait Dissector: Send + Sync { + fn claims(&self, port: u16) -> bool; + fn dissect(&self, meta: &FlowMeta, payload: &[u8]) -> Vec; +} + +// --- Kerberos (REAL) ------------------------------------------------------- + +pub struct KerberosDissector; +impl Dissector for KerberosDissector { + fn claims(&self, port: u16) -> bool { + port == 88 + } + fn dissect(&self, meta: &FlowMeta, payload: &[u8]) -> Vec { + match dissect_kerberos(meta, payload) { + Some(doc) => vec![doc], + None => vec![], + } + } +} + +// --- LDAP / SMB / DCERPC (STUBS) ------------------------------------------ +// Intentionally return nothing. They exist to prove the seam and to hold the +// TODO. Register them in `dissectors()` when implemented. + +pub struct LdapDissector; +impl Dissector for LdapDissector { + fn claims(&self, port: u16) -> bool { + port == 389 || port == 636 + } + fn dissect(&self, _meta: &FlowMeta, _payload: &[u8]) -> Vec { + // TODO: dissect LDAP (bind, search) into `Ldap.*`. LDAP is BER like + // Kerberos, so the TLV reader below is largely reusable. + vec![] + } +} + +pub struct SmbDissector; +impl Dissector for SmbDissector { + fn claims(&self, port: u16) -> bool { + port == 445 + } + fn dissect(&self, _meta: &FlowMeta, _payload: &[u8]) -> Vec { + // TODO: dissect SMB2 header + a few commands into `Smb.*`. + vec![] + } +} + +pub struct DcerpcDissector; +impl Dissector for DcerpcDissector { + fn claims(&self, port: u16) -> bool { + port == 135 + } + fn dissect(&self, _meta: &FlowMeta, _payload: &[u8]) -> Vec { + // TODO: dissect DCERPC bind/request into `Dcerpc.*` (endpoint mapper + + // dynamic ports need an epmap-tracking layer). + vec![] + } +} + +/// The active dissector table. Kerberos is real; the rest are stubs. +fn dissectors() -> Vec> { + vec![ + Box::new(KerberosDissector), + Box::new(LdapDissector), + Box::new(SmbDissector), + Box::new(DcerpcDissector), + ] +} + +// =========================================================================== +// Capture iteration: pcap-parser -> etherparse -> reassembly -> dissectors +// =========================================================================== + +#[derive(Hash, PartialEq, Eq, Clone)] +struct FlowKey { + src_ip: IpAddr, + dst_ip: IpAddr, + src_port: u16, + dst_port: u16, +} + +/// Parsed L3/L4 view of a single frame. +struct Segment { + meta: FlowMeta, + payload: Vec, + is_tcp: bool, +} + +fn dissect_capture(path: &Path) -> crate::Result> { + use pcap_parser::traits::PcapReaderIterator; + use pcap_parser::{Linktype, PcapBlockOwned, PcapError, create_reader}; + + let file = File::open(path)?; + // API: create_reader auto-detects legacy pcap vs pcapng. + let mut reader = + create_reader(65536, file).map_err(|e| anyhow!("failed to open capture: {:?}", e))?; + + let table = dissectors(); + let mut out: Vec = Vec::new(); + // Per-flow TCP reassembly buffers (arrival order; see ADR §7 for limits). + let mut tcp_buffers: HashMap> = HashMap::new(); + + // Current link-layer type; updated by section/interface headers. + let mut linktype = Linktype::ETHERNET; + let mut frame: u64 = 0; + + loop { + match reader.next() { + Ok((offset, block)) => { + match block { + // Legacy pcap global header carries the single linktype. + PcapBlockOwned::LegacyHeader(hdr) => { + linktype = hdr.network; + } + PcapBlockOwned::Legacy(b) => { + frame += 1; + // API: b.data = captured bytes, b.ts_sec/b.ts_usec (u32). + let ts = format_ts(b.ts_sec as i64, b.ts_usec.saturating_mul(1000)); + handle_frame( + linktype, + b.data, + ts, + frame, + &table, + &mut tcp_buffers, + &mut out, + ); + } + // pcapng: interface description block carries the linktype. + PcapBlockOwned::NG(block) => { + use pcap_parser::Block; + match block { + Block::SectionHeader(_) => {} + Block::InterfaceDescription(idb) => { + linktype = idb.linktype; + } + Block::EnhancedPacket(epb) => { + frame += 1; + // API: pcapng timestamps are (ts_high<<32|ts_low) + // in units of if_tsresol (default 1us). We assume + // the 1us default here; a production version must + // read if_tsresol from the IDB. Documented in ADR. + let raw = ((epb.ts_high as u64) << 32) | epb.ts_low as u64; + let secs = (raw / 1_000_000) as i64; + let nanos = ((raw % 1_000_000) * 1000) as u32; + let ts = format_ts(secs, nanos); + handle_frame( + linktype, + epb.data, + ts, + frame, + &table, + &mut tcp_buffers, + &mut out, + ); + } + Block::SimplePacket(spb) => { + frame += 1; + // SimplePacket has no timestamp. + let ts = format_ts(0, 0); + handle_frame( + linktype, + spb.data, + ts, + frame, + &table, + &mut tcp_buffers, + &mut out, + ); + } + _ => {} + } + } + _ => {} + } + reader.consume(offset); + } + Err(PcapError::Eof) => break, + Err(PcapError::Incomplete(_)) => { + // API: refill the internal buffer and retry. + if reader.refill().is_err() { + break; + } + } + Err(e) => { + return Err(anyhow!("pcap read error: {:?}", e)); + } + } + } + + Ok(out) +} + +#[allow(clippy::too_many_arguments)] +fn handle_frame( + linktype: pcap_parser::Linktype, + data: &[u8], + timestamp: String, + frame: u64, + table: &[Box], + tcp_buffers: &mut HashMap>, + out: &mut Vec, +) { + let seg = match parse_l2_l4(linktype, data, timestamp, frame) { + Some(s) => s, + None => return, + }; + + // Only bother with flows some dissector claims (either direction). + let claimed = table + .iter() + .any(|d| d.claims(seg.meta.src_port) || d.claims(seg.meta.dst_port)); + if !claimed { + return; + } + + if seg.is_tcp { + // Reassemble in arrival order, then peel length-prefixed Kerberos msgs. + let key = FlowKey { + src_ip: seg.meta.src_ip, + dst_ip: seg.meta.dst_ip, + src_port: seg.meta.src_port, + dst_port: seg.meta.dst_port, + }; + let buf = tcp_buffers.entry(key).or_default(); + buf.extend_from_slice(&seg.payload); + + // RFC 4120 §7.2.2: each message is prefixed with a 4-byte big-endian + // length. Pull as many complete messages as the buffer holds. + loop { + if buf.len() < 4 { + break; + } + let msg_len = + u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize; + if msg_len == 0 || msg_len > 10 * 1024 * 1024 { + // Guard against garbage / desync: drop the flow buffer. + buf.clear(); + break; + } + if buf.len() < 4 + msg_len { + break; // wait for more segments + } + let msg: Vec = buf[4..4 + msg_len].to_vec(); + buf.drain(0..4 + msg_len); + dispatch(table, &seg.meta, &msg, out); + } + } else { + // UDP: one bare Kerberos message per datagram. + dispatch(table, &seg.meta, &seg.payload, out); + } +} + +fn dispatch(table: &[Box], meta: &FlowMeta, payload: &[u8], out: &mut Vec) { + for d in table { + if d.claims(meta.src_port) || d.claims(meta.dst_port) { + out.extend(d.dissect(meta, payload)); + } + } +} + +/// Parse L2..L4 with etherparse and return a `Segment` for UDP/TCP packets. +fn parse_l2_l4( + linktype: pcap_parser::Linktype, + data: &[u8], + timestamp: String, + frame: u64, +) -> Option { + use etherparse::{NetSlice, SlicedPacket, TransportSlice}; + + // API: dispatch on linktype. 1 = Ethernet, 101 = raw IP, 0 = BSD loopback + // (4-byte address-family header), 12 = raw (some tools). Others skipped. + let sliced = match linktype.0 { + 1 => SlicedPacket::from_ethernet(data).ok()?, + 101 | 12 => SlicedPacket::from_ip(data).ok()?, + 0 => { + // BSD loopback / null: 4-byte host-order address family, then IP. + if data.len() < 4 { + return None; + } + SlicedPacket::from_ip(&data[4..]).ok()? + } + _ => return None, + }; + + // L3 addresses. + let (src_ip, dst_ip): (IpAddr, IpAddr) = match sliced.net? { + NetSlice::Ipv4(ip) => { + let h = ip.header(); + ( + IpAddr::from(h.source()), + IpAddr::from(h.destination()), + ) + } + NetSlice::Ipv6(ip) => { + let h = ip.header(); + ( + IpAddr::from(h.source()), + IpAddr::from(h.destination()), + ) + } + _ => return None, + }; + + // L4 transport + payload. + let (transport, is_tcp, src_port, dst_port, payload): (&'static str, bool, u16, u16, Vec) = + match sliced.transport? { + TransportSlice::Udp(udp) => ( + "udp", + false, + udp.source_port(), + udp.destination_port(), + udp.payload().to_vec(), + ), + TransportSlice::Tcp(tcp) => ( + "tcp", + true, + tcp.source_port(), + tcp.destination_port(), + tcp.payload().to_vec(), + ), + _ => return None, + }; + + if payload.is_empty() { + return None; + } + + Some(Segment { + meta: FlowMeta { + timestamp, + transport, + src_ip, + dst_ip, + src_port, + dst_port, + frame, + }, + payload, + is_tcp, + }) +} + +fn format_ts(secs: i64, nanos: u32) -> String { + use chrono::{DateTime, Utc}; + match DateTime::::from_timestamp(secs, nanos) { + // Matches the format hunt.rs parses: "%Y-%m-%dT%H:%M:%S%.fZ". + Some(dt) => dt.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string(), + None => "1970-01-01T00:00:00.000000Z".to_string(), + } +} + +// =========================================================================== +// Minimal BER/DER reader — only what Kerberos (RFC 4120) uses. +// definite lengths, low-tag-number application + context tags, +// INTEGER / OCTET STRING / GeneralizedTime / BIT STRING / SEQUENCE. +// =========================================================================== + +const CLASS_UNIVERSAL: u8 = 0; +const CLASS_APPLICATION: u8 = 1; +const CLASS_CONTEXT: u8 = 2; + +const UT_INTEGER: u32 = 2; +const UT_BIT_STRING: u32 = 3; +const UT_OCTET_STRING: u32 = 4; +const UT_SEQUENCE: u32 = 16; + +struct Tlv<'a> { + class: u8, + #[allow(dead_code)] + constructed: bool, + tag: u32, + content: &'a [u8], +} + +/// Read one TLV from the front of `buf`. Returns the TLV and the number of +/// bytes consumed, or None on malformed/truncated input. +fn read_tlv(buf: &[u8]) -> Option<(Tlv<'_>, usize)> { + if buf.is_empty() { + return None; + } + let first = buf[0]; + let class = first >> 6; + let constructed = first & 0x20 != 0; + let mut tag = (first & 0x1f) as u32; + let mut idx = 1; + if tag == 0x1f { + // high-tag-number form (not used by Kerberos, handled for safety). + tag = 0; + loop { + let b = *buf.get(idx)?; + idx += 1; + tag = (tag << 7) | (b & 0x7f) as u32; + if b & 0x80 == 0 { + break; + } + } + } + let len_byte = *buf.get(idx)?; + idx += 1; + let length = if len_byte & 0x80 == 0 { + (len_byte & 0x7f) as usize + } else { + let num = (len_byte & 0x7f) as usize; + if num == 0 || num > 4 { + return None; // indefinite or implausibly long + } + let mut l = 0usize; + for _ in 0..num { + let b = *buf.get(idx)?; + idx += 1; + l = (l << 8) | b as usize; + } + l + }; + let end = idx.checked_add(length)?; + if end > buf.len() { + return None; + } + Some(( + Tlv { + class, + constructed, + tag, + content: &buf[idx..end], + }, + end, + )) +} + +/// Children of a constructed TLV content buffer. +fn children(content: &[u8]) -> Vec> { + let mut v = Vec::new(); + let mut rest = content; + while !rest.is_empty() { + match read_tlv(rest) { + Some((tlv, consumed)) => { + v.push(tlv); + rest = &rest[consumed..]; + } + None => break, + } + } + v +} + +/// Find a context-tagged `[n]` child among `kids`. +fn ctx<'a, 'b>(kids: &'a [Tlv<'b>], n: u32) -> Option<&'a Tlv<'b>> { + kids.iter().find(|t| t.class == CLASS_CONTEXT && t.tag == n) +} + +/// Unwrap an explicitly tagged context element to its single inner TLV. +/// The returned TLV borrows the *data* (lifetime `'a`), not the `&tlv` ref, so +/// callers can drop the reference and keep the inner TLV. +fn inner<'a>(tlv: &Tlv<'a>) -> Option> { + read_tlv(tlv.content).map(|(t, _)| t) +} + +fn read_integer(content: &[u8]) -> Option { + if content.is_empty() || content.len() > 8 { + return None; + } + let mut v: i64 = if content[0] & 0x80 != 0 { -1 } else { 0 }; + for &b in content { + v = (v << 8) | b as i64; + } + Some(v) +} + +/// Read an INTEGER that lives behind an explicit context tag `[n]`. +fn read_ctx_int(kids: &[Tlv<'_>], n: u32) -> Option { + let el = ctx(kids, n)?; + let prim = inner(el)?; + if prim.class == CLASS_UNIVERSAL && prim.tag == UT_INTEGER { + read_integer(prim.content) + } else { + None + } +} + +/// Read a string (GeneralizedTime / GeneralString / IA5 etc.) behind `[n]`. +fn read_ctx_string(kids: &[Tlv<'_>], n: u32) -> Option { + let el = ctx(kids, n)?; + let prim = inner(el)?; + Some(String::from_utf8_lossy(prim.content).to_string()) +} + +/// PrincipalName ::= SEQUENCE { name-type[0] Int32, name-string[1] SEQ OF String } +/// Returns (name-type, components). +fn read_principal(el: &Tlv<'_>) -> Option<(i64, Vec)> { + let seq = inner(el)?; // SEQUENCE + let kids = children(seq.content); + let ntype = read_ctx_int(&kids, 0).unwrap_or(0); + let names_el = ctx(&kids, 1)?; + let names_seq = inner(names_el)?; // SEQUENCE OF + let mut names = Vec::new(); + for k in children(names_seq.content) { + names.push(String::from_utf8_lossy(k.content).to_string()); + } + Some((ntype, names)) +} + +/// KDCOptions / APOptions bit-string flag names (bit 0 = MSB). +fn decode_kdc_flags(bits: u32) -> Vec<&'static str> { + const FLAGS: &[(u32, &str)] = &[ + (1, "forwardable"), + (2, "forwarded"), + (3, "proxiable"), + (4, "proxy"), + (5, "allow-postdate"), + (6, "postdated"), + (8, "renewable"), + (9, "opt-hardware-auth"), + (14, "constrained-delegation"), + (15, "canonicalize"), + (26, "disable-transited-check"), + (27, "renewable-ok"), + (28, "enc-tkt-in-skey"), + (30, "renew"), + (31, "validate"), + ]; + FLAGS + .iter() + .filter(|(bit, _)| bits & (0x8000_0000u32 >> bit) != 0) + .map(|(_, name)| *name) + .collect() +} + +/// A BIT STRING carrying 32 option bits: content = [unused-bits][4 bytes]. +fn read_option_bits(el: &Tlv<'_>) -> Option { + let prim = inner(el)?; + if prim.class != CLASS_UNIVERSAL || prim.tag != UT_BIT_STRING { + return None; + } + let c = prim.content; + if c.len() < 5 { + return None; + } + // c[0] = unused bits; c[1..5] = the 32 option bits, big-endian. + Some(u32::from_be_bytes([c[1], c[2], c[3], c[4]])) +} + +fn msg_type_name(t: i64) -> &'static str { + match t { + 10 => "AS-REQ", + 11 => "AS-REP", + 12 => "TGS-REQ", + 13 => "TGS-REP", + 14 => "AP-REQ", + 15 => "AP-REP", + 30 => "KRB-ERROR", + _ => "UNKNOWN", + } +} + +// =========================================================================== +// Kerberos dissection +// =========================================================================== + +fn dissect_kerberos(meta: &FlowMeta, msg: &[u8]) -> Option { + let (app, _) = read_tlv(msg)?; + if app.class != CLASS_APPLICATION { + return None; + } + let app_tag = app.tag; + + let mut k = serde_json::Map::new(); + k.insert("msg_type".into(), json!(app_tag)); + k.insert("msg_type_name".into(), json!(msg_type_name(app_tag as i64))); + + // The APPLICATION tag wraps a universal SEQUENCE. + let seq = inner(&app)?; + if seq.class != CLASS_UNIVERSAL || seq.tag != UT_SEQUENCE { + return None; + } + let kids = children(seq.content); + + match app_tag { + 10 | 12 => parse_kdc_req(&kids, &mut k), + 11 | 13 => parse_kdc_rep(&kids, &mut k), + 14 => parse_ap_req(&kids, &mut k), + 30 => parse_krb_error(&kids, &mut k), + _ => return None, + } + + let network = meta.network_object("kerberos", msg.len()); + Some(json!({ "Network": network, "Kerberos": Json::Object(k) })) +} + +/// KDC-REQ ::= SEQUENCE { pvno[1], msg-type[2], padata[3] OPT, req-body[4] } +fn parse_kdc_req(kids: &[Tlv<'_>], k: &mut serde_json::Map) { + if let Some(p) = read_ctx_int(kids, 1) { + k.insert("pvno".into(), json!(p)); + } + if let Some(mt) = read_ctx_int(kids, 2) { + // Prefer the inner msg-type (authoritative) over the APPLICATION tag. + k.insert("msg_type".into(), json!(mt)); + k.insert("msg_type_name".into(), json!(msg_type_name(mt))); + } + if let Some(pd) = ctx(kids, 3) { + parse_padata(pd, k); + } + if let Some(body_el) = ctx(kids, 4) { + parse_req_body(body_el, k); + } +} + +/// KDC-REQ-BODY ::= SEQUENCE { +/// kdc-options[0], cname[1] OPT, realm[2], sname[3] OPT, from[4] OPT, +/// till[5], rtime[6] OPT, nonce[7], etype[8] SEQ OF Int32, ... } +fn parse_req_body(body_el: &Tlv<'_>, k: &mut serde_json::Map) { + let body = match inner(body_el) { + Some(b) => b, + None => return, + }; + let kids = children(body.content); + + if let Some(opts_el) = ctx(&kids, 0) + && let Some(bits) = read_option_bits(opts_el) + { + k.insert("kdc_options".into(), json!(format!("{:08x}", bits))); + k.insert("kdc_options_flags".into(), json!(decode_kdc_flags(bits))); + } + if let Some(cn) = ctx(&kids, 1) + && let Some((ntype, names)) = read_principal(cn) + { + k.insert("cname_type".into(), json!(ntype)); + k.insert("cname_string".into(), json!(names.join("/"))); + k.insert("cname".into(), json!(names)); + } + if let Some(r) = read_ctx_string(&kids, 2) { + k.insert("realm".into(), json!(r)); + } + if let Some(sn) = ctx(&kids, 3) + && let Some((ntype, names)) = read_principal(sn) + { + k.insert("sname_type".into(), json!(ntype)); + k.insert("sname_string".into(), json!(names.join("/"))); + k.insert("sname".into(), json!(names)); + } + if let Some(s) = read_ctx_string(&kids, 4) { + k.insert("from".into(), json!(s)); + } + if let Some(s) = read_ctx_string(&kids, 5) { + k.insert("till".into(), json!(s)); + } + if let Some(s) = read_ctx_string(&kids, 6) { + k.insert("rtime".into(), json!(s)); + } + if let Some(n) = read_ctx_int(&kids, 7) { + // nonce is a UInt32; expose unsigned. + k.insert("nonce".into(), json!(n as u32)); + } + if let Some(etype_el) = ctx(&kids, 8) + && let Some(seq) = inner(etype_el) + { + let mut etypes: Vec = Vec::new(); + for c in children(seq.content) { + if c.class == CLASS_UNIVERSAL && c.tag == UT_INTEGER { + if let Some(v) = read_integer(c.content) { + etypes.push(v); + } + } + } + if let Some(first) = etypes.first() { + k.insert("etype_first".into(), json!(first)); + } + k.insert("etype_count".into(), json!(etypes.len())); + k.insert("etypes".into(), json!(etypes)); + } +} + +/// KDC-REP ::= SEQUENCE { +/// pvno[0], msg-type[1], padata[2] OPT, crealm[3], cname[4], ticket[5], +/// enc-part[6] EncryptedData } +fn parse_kdc_rep(kids: &[Tlv<'_>], k: &mut serde_json::Map) { + if let Some(p) = read_ctx_int(kids, 0) { + k.insert("pvno".into(), json!(p)); + } + if let Some(mt) = read_ctx_int(kids, 1) { + k.insert("msg_type".into(), json!(mt)); + k.insert("msg_type_name".into(), json!(msg_type_name(mt))); + } + if let Some(pd) = ctx(kids, 2) { + parse_padata(pd, k); + } + if let Some(r) = read_ctx_string(kids, 3) { + k.insert("realm".into(), json!(r)); + } + if let Some(cn) = ctx(kids, 4) + && let Some((ntype, names)) = read_principal(cn) + { + k.insert("cname_type".into(), json!(ntype)); + k.insert("cname_string".into(), json!(names.join("/"))); + k.insert("cname".into(), json!(names)); + } + if let Some(tk) = ctx(kids, 5) { + parse_ticket(tk, k); + } + if let Some(ep) = ctx(kids, 6) { + parse_enc_part(ep, "enc_part", k); + } +} + +/// AP-REQ ::= SEQUENCE { pvno[0], msg-type[1], ap-options[2], ticket[3], +/// authenticator[4] EncryptedData } +fn parse_ap_req(kids: &[Tlv<'_>], k: &mut serde_json::Map) { + if let Some(p) = read_ctx_int(kids, 0) { + k.insert("pvno".into(), json!(p)); + } + if let Some(mt) = read_ctx_int(kids, 1) { + k.insert("msg_type".into(), json!(mt)); + k.insert("msg_type_name".into(), json!(msg_type_name(mt))); + } + if let Some(opts_el) = ctx(kids, 2) + && let Some(bits) = read_option_bits(opts_el) + { + k.insert("ap_options".into(), json!(format!("{:08x}", bits))); + } + if let Some(tk) = ctx(kids, 3) { + parse_ticket(tk, k); + } + if let Some(auth) = ctx(kids, 4) { + // authenticator is EncryptedData; its cipher is the ENCRYPTED + // authenticator. We expose etype, size and a ciphertext prefix only. + parse_enc_part(auth, "authenticator", k); + if let Some(ed) = inner(auth) { + let ekids = children(ed.content); + if let Some(cipher_el) = ctx(&ekids, 2) + && let Some(prim) = inner(cipher_el) + { + let n = prim.content.len().min(8); + let prefix: String = + prim.content[..n].iter().map(|b| format!("{:02x}", b)).collect(); + // NB: this is CIPHERTEXT (see ADR §3.2 caveat), exposed because + // the brief asks for the authenticator sequence "where visible". + k.insert("authenticator_seq_hex".into(), json!(prefix)); + } + } + } +} + +/// KRB-ERROR ::= SEQUENCE { pvno[0], msg-type[1], ... error-code[6], ... } +fn parse_krb_error(kids: &[Tlv<'_>], k: &mut serde_json::Map) { + if let Some(p) = read_ctx_int(kids, 0) { + k.insert("pvno".into(), json!(p)); + } + if let Some(mt) = read_ctx_int(kids, 1) { + k.insert("msg_type".into(), json!(mt)); + k.insert("msg_type_name".into(), json!(msg_type_name(mt))); + } + if let Some(ec) = read_ctx_int(kids, 6) { + k.insert("error_code".into(), json!(ec)); + } + if let Some(r) = read_ctx_string(kids, 9) { + k.insert("realm".into(), json!(r)); + } + if let Some(sn) = ctx(kids, 10) + && let Some((ntype, names)) = read_principal(sn) + { + k.insert("sname_type".into(), json!(ntype)); + k.insert("sname_string".into(), json!(names.join("/"))); + k.insert("sname".into(), json!(names)); + } +} + +/// Ticket ::= [APPLICATION 1] SEQUENCE { tkt-vno[0], realm[1], sname[2], +/// enc-part[3] }. We only lift realm + sname (the service the ticket is for). +fn parse_ticket(ticket_ctx: &Tlv<'_>, k: &mut serde_json::Map) { + let app = match inner(ticket_ctx) { + Some(a) => a, + None => return, + }; + if app.class != CLASS_APPLICATION || app.tag != 1 { + return; + } + let seq = match inner(&app) { + Some(s) => s, + None => return, + }; + let kids = children(seq.content); + if let Some(r) = read_ctx_string(&kids, 1) { + k.insert("ticket_realm".into(), json!(r)); + } + if let Some(sn) = ctx(&kids, 2) + && let Some((_ntype, names)) = read_principal(sn) + { + k.insert("ticket_sname_string".into(), json!(names.join("/"))); + } +} + +/// EncryptedData ::= SEQUENCE { etype[0], kvno[1] OPT, cipher[2] OCTET STRING } +fn parse_enc_part(el: &Tlv<'_>, prefix: &str, k: &mut serde_json::Map) { + let ed = match inner(el) { + Some(e) => e, + None => return, + }; + let kids = children(ed.content); + if let Some(etype) = read_ctx_int(&kids, 0) { + k.insert(format!("{}_etype", prefix), json!(etype)); + } + if let Some(kvno) = read_ctx_int(&kids, 1) { + k.insert(format!("{}_kvno", prefix), json!(kvno as u32)); + } + if let Some(cipher_el) = ctx(&kids, 2) + && let Some(prim) = inner(cipher_el) + { + k.insert(format!("{}_size", prefix), json!(prim.content.len())); + } +} + +/// padata [n] -> SEQUENCE OF PA-DATA { padata-type[1], padata-value[2] OCTET } +fn parse_padata(pd_ctx: &Tlv<'_>, k: &mut serde_json::Map) { + let seq = match inner(pd_ctx) { + Some(s) => s, + None => return, + }; + let mut types: Vec = Vec::new(); + for pa in children(seq.content) { + // each PA-DATA is a SEQUENCE + let pakids = children(pa.content); + let ptype = match read_ctx_int(&pakids, 1) { + Some(t) => t, + None => continue, + }; + types.push(ptype); + match ptype { + 128 => { + k.insert("pa_pac_request".into(), json!(true)); + } + 167 => { + // PA-PAC-OPTIONS: expose the raw value hex. + if let Some(v_el) = ctx(&pakids, 2) + && let Some(prim) = inner(v_el) + { + let hex: String = + prim.content.iter().map(|b| format!("{:02x}", b)).collect(); + k.insert("pa_pac_options".into(), json!(hex)); + } + } + 129 => { + k.insert("pa_for_user".into(), json!(true)); + // Best-effort: pull the S4U2self target principal name. + if let Some(name) = parse_pa_for_user_name(&pakids) { + k.insert("pa_for_user_name".into(), json!(name)); + } + } + _ => {} + } + } + k.insert("padata_types".into(), json!(types)); +} + +/// PA-FOR-USER ::= SEQUENCE { userName[0] PrincipalName, userRealm[1] Realm, ...} +fn parse_pa_for_user_name(pakids: &[Tlv<'_>]) -> Option { + let v_el = ctx(pakids, 2)?; + let octet = inner(v_el)?; // OCTET STRING wrapping PA-FOR-USER-ENC + if octet.class != CLASS_UNIVERSAL || octet.tag != UT_OCTET_STRING { + return None; + } + let (seq, _) = read_tlv(octet.content)?; // SEQUENCE + let kids = children(seq.content); + let name_el = ctx(&kids, 0)?; // userName PrincipalName + let (_ntype, names) = read_principal(name_el)?; + Some(names.join("/")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tlv_short_form() { + // INTEGER 5 : 02 01 05 + let (t, n) = read_tlv(&[0x02, 0x01, 0x05]).unwrap(); + assert_eq!(n, 3); + assert_eq!(t.class, CLASS_UNIVERSAL); + assert_eq!(t.tag, UT_INTEGER); + assert_eq!(read_integer(t.content), Some(5)); + } + + #[test] + fn tlv_long_form_length() { + // OCTET STRING, length 0x81 0x80 (=128), 128 bytes of 0x00 + let mut buf = vec![0x04, 0x81, 0x80]; + buf.extend(std::iter::repeat(0u8).take(128)); + let (t, n) = read_tlv(&buf).unwrap(); + assert_eq!(n, 3 + 128); + assert_eq!(t.content.len(), 128); + } + + #[test] + fn kdc_option_flag_decode() { + // forwardable (bit 1) + renewable (bit 8) + renewable-ok (bit 27) + let bits = (0x8000_0000u32 >> 1) | (0x8000_0000u32 >> 8) | (0x8000_0000u32 >> 27); + let flags = decode_kdc_flags(bits); + assert!(flags.contains(&"forwardable")); + assert!(flags.contains(&"renewable")); + assert!(flags.contains(&"renewable-ok")); + } + + #[test] + fn truncated_is_none() { + assert!(read_tlv(&[0x02, 0x05, 0x01]).is_none()); // claims 5 bytes, has 1 + } +} diff --git a/src/lib.rs b/src/lib.rs index a15ccb7..a2a4cfd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,7 +10,7 @@ pub use analyse::gaps::{ }; pub use analyse::shimcache::ShimcacheAnalyser; pub use analyse::srum::SrumAnalyser; -pub use file::{Document, Kind as FileKind, Reader, evtx, get_files}; +pub use file::{Document, Kind as FileKind, Reader, evtx, get_files, pcap}; pub use hunt::{Hunter, HunterBuilder}; pub use rule::{ Filter, Kind as RuleKind, Level as RuleLevel, Status as RuleStatus, lint, load, sigma, From 2fcc8dda1a4b2365e028f79de9f3f83b8d4dc8b8 Mon Sep 17 00:00:00 2001 From: Andrew Schwartz Date: Thu, 22 Oct 2026 09:49:57 -0500 Subject: [PATCH 04/10] feat(pcap): wire Pcap kind through hunt/cli/search engine paths hunt.rs: Pcap document conversion + pcap::Wrapper dispatch and mapping-kind arm. cli.rs: FileKind::Pcap arms in the four wrapper/mapper blocks. search.rs + main.rs: Document::Pcap arms. EVTX/MFT/JSON behaviour untouched. Co-Authored-By: Claude Opus 4.8 --- src/cli.rs | 37 +++++++++++++++++++++++++++++++++++++ src/hunt.rs | 10 ++++++++++ src/main.rs | 3 ++- src/search.rs | 6 ++++-- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index d9e1c6d..c4458cf 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -142,6 +142,7 @@ fn agg_to_doc<'a>( for document in documents { let data: Value; let wrapper; + let pcap_wrapper; let mapped = match &document.kind { FileKind::Evtx => { (data, _) = bincode::serde::decode_from_slice::( @@ -151,6 +152,14 @@ fn agg_to_doc<'a>( wrapper = crate::evtx::Wrapper(&data); hunt.mapper.mapped(&wrapper) } + FileKind::Pcap => { + (data, _) = bincode::serde::decode_from_slice::( + &document.data, + bincode::config::standard(), + )?; + pcap_wrapper = crate::pcap::Wrapper(&data); + hunt.mapper.mapped(&pcap_wrapper) + } FileKind::Hve | FileKind::Json | FileKind::Jsonl @@ -309,6 +318,7 @@ pub fn print_log( let mut values = vec![]; let data: Value; let wrapper; + let pcap_wrapper; let mapped = match &document.kind { FileKind::Evtx => { (data, _) = bincode::serde::decode_from_slice::( @@ -318,6 +328,14 @@ pub fn print_log( wrapper = crate::evtx::Wrapper(&data); hunt.mapper.mapped(&wrapper) } + FileKind::Pcap => { + (data, _) = bincode::serde::decode_from_slice::( + &document.data, + bincode::config::standard(), + )?; + pcap_wrapper = crate::pcap::Wrapper(&data); + hunt.mapper.mapped(&pcap_wrapper) + } FileKind::Hve | FileKind::Json | FileKind::Jsonl @@ -493,6 +511,7 @@ pub fn print_detections( for hit in &grouping.hits { let data: Value; let wrapper; + let pcap_wrapper; let mapped = match &document.kind { FileKind::Evtx => { (data, _) = bincode::serde::decode_from_slice::( @@ -503,6 +522,15 @@ pub fn print_detections( wrapper = crate::evtx::Wrapper(&data); hit.hunt.mapper.mapped(&wrapper) } + FileKind::Pcap => { + (data, _) = bincode::serde::decode_from_slice::( + &document.data, + bincode::config::standard(), + ) + .expect("could not decompress"); + pcap_wrapper = crate::pcap::Wrapper(&data); + hit.hunt.mapper.mapped(&pcap_wrapper) + } FileKind::Hve | FileKind::Esedb | FileKind::Json @@ -892,6 +920,7 @@ pub fn print_csv( for hit in &grouping.hits { let data: Value; let wrapper; + let pcap_wrapper; let mapped = match &document.kind { FileKind::Evtx => { (data, _) = bincode::serde::decode_from_slice::( @@ -901,6 +930,14 @@ pub fn print_csv( wrapper = crate::evtx::Wrapper(&data); hit.hunt.mapper.mapped(&wrapper) } + FileKind::Pcap => { + (data, _) = bincode::serde::decode_from_slice::( + &document.data, + bincode::config::standard(), + )?; + pcap_wrapper = crate::pcap::Wrapper(&data); + hit.hunt.mapper.mapped(&pcap_wrapper) + } FileKind::Hve | FileKind::Esedb | FileKind::Json diff --git a/src/hunt.rs b/src/hunt.rs index afd68f9..acf719e 100644 --- a/src/hunt.rs +++ b/src/hunt.rs @@ -252,6 +252,7 @@ impl HunterBuilder { FileKind::Mft => FileKind::Mft, FileKind::Xml => FileKind::Xml, FileKind::Esedb => FileKind::Esedb, + FileKind::Pcap => FileKind::Pcap, FileKind::Unknown => unreachable!(), }; hunts.push(Hunt { @@ -839,6 +840,10 @@ impl Hunter { cs_trace!("[*] Hunting through document {} - {:?}", document_id, esedb); (FileKind::Esedb, esedb.into()) } + File::Pcap(pcap) => { + cs_trace!("[*] Hunting through document {} - {:?}", document_id, pcap); + (FileKind::Pcap, pcap.into()) + } }; let mut hits = smallvec::smallvec![]; for hunt in &self.inner.hunts { @@ -847,11 +852,16 @@ impl Hunter { } let wrapper; + let pcap_wrapper; let mapped = match &kind { FileKind::Evtx => { wrapper = crate::evtx::Wrapper(&value); hunt.mapper.mapped(&wrapper) } + FileKind::Pcap => { + pcap_wrapper = crate::pcap::Wrapper(&value); + hunt.mapper.mapped(&pcap_wrapper) + } _ => hunt.mapper.mapped(&value), }; let mapped = if self.inner.preprocess { diff --git a/src/main.rs b/src/main.rs index db51009..b1c5472 100644 --- a/src/main.rs +++ b/src/main.rs @@ -549,7 +549,8 @@ fn run() -> Result<()> { | Document::Json(json) | Document::Xml(json) | Document::Mft(json) - | Document::Esedb(json) => json, + | Document::Esedb(json) + | Document::Pcap(json) => json, }; if json { diff --git a/src/search.rs b/src/search.rs index 669560b..495880a 100644 --- a/src/search.rs +++ b/src/search.rs @@ -66,7 +66,8 @@ impl Iterator for Iter<'_> { | Document::Json(json) | Document::Xml(json) | Document::Mft(json) - | Document::Esedb(json) => match json.find(field) { + | Document::Esedb(json) + | Document::Pcap(json) => match json.find(field) { Some(value) => match value.as_str() { Some(timestamp) => { NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S%.fZ") @@ -121,7 +122,8 @@ impl Iterator for Iter<'_> { | Document::Json(json) | Document::Xml(json) | Document::Mft(json) - | Document::Esedb(json) => { + | Document::Esedb(json) + | Document::Pcap(json) => { if let Some(expression) = &self.searcher.tau { if !tau_engine::core::solve(expression, &json) { continue; From 4acda19ab8ce43bba6dbff9ebc425052c0f7c9eb Mon Sep 17 00:00:00 2001 From: Andrew Schwartz Date: Thu, 22 Oct 2026 09:49:58 -0500 Subject: [PATCH 05/10] feat(pcap): Sigma mapping, example network rules, and usage README mappings/sigma-network.yml maps Network.*/Kerberos.* fields. Three example Sigma rules keyed on wire facts: Mimikatz authenticator byte fingerprint, Rubeus default till 20370913024805Z, RC4(0x17) etype downgrade. README-pcap.md documents build/test steps and a draft (unopened) upstream PR description. Co-Authored-By: Claude Opus 4.8 --- README-pcap.md | 161 ++++++++++++++++++ mappings/sigma-network.yml | 79 +++++++++ rules-network/kerberos_rc4_downgrade.yml | 40 +++++ .../mimikatz_kerberos_authenticator.yml | 40 +++++ rules-network/rubeus_default_till.yml | 35 ++++ 5 files changed, 355 insertions(+) create mode 100644 README-pcap.md create mode 100644 mappings/sigma-network.yml create mode 100644 rules-network/kerberos_rc4_downgrade.yml create mode 100644 rules-network/mimikatz_kerberos_authenticator.yml create mode 100644 rules-network/rubeus_default_till.yml diff --git a/README-pcap.md b/README-pcap.md new file mode 100644 index 0000000..6194915 --- /dev/null +++ b/README-pcap.md @@ -0,0 +1,161 @@ +# Chainsaw PCAP ingestion (prototype) + +Branch: `feat/pcap-ingestion`. Lets `chainsaw hunt capture.pcap` dissect network +captures into `Network.*` / `Kerberos.*` documents the **existing** Tau rule +engine + Sigma mappings match, so network-only AD attacks (ticket abuse, +Rubeus/Mimikatz fingerprints, etype downgrade) are detected with the same tool +and rule format as EVTX. + +> **COMPILATION/RUN STATE: UNVERIFIED.** Every file on this branch was authored +> in an environment with **no Rust toolchain**. Nothing here has been compiled +> or run. The design, schema, and wiring are the deliverable; treat build claims +> as "intended to build, pending `cargo` on a real box". The most likely +> first-build breakage is `pcap-parser` / `etherparse` API drift — those call +> sites are flagged with `// API:` comments in `src/file/pcap.rs`. + +## What works (intended) + +- New file kind `pcap`, auto-detected by `.pcap` / `.pcapng` extension. +- Native, self-contained dissection (no libpcap, no tshark): + - `pcap-parser` reads legacy pcap and pcapng. + - `etherparse` parses Ethernet / IPv4 / IPv6 / UDP / TCP. + - A small hand-rolled BER/DER reader dissects Kerberos (RFC 4120). +- Kerberos over UDP/88 and TCP/88 (in-order reassembly): AS-REQ/REP, + TGS-REQ/REP, AP-REQ, plus the KRB-ERROR envelope. One document per message. +- Full `Network.*` / `Kerberos.*` schema (see `docs/pcap-ingestion.md` §3). +- `pcap::Wrapper` (analogous to `evtx::Wrapper`) wired into `hunt`/`cli`/`search`. +- `mappings/sigma-network.yml` + three example Sigma rules in `rules-network/`. + +## What is stubbed / limited + +- **LDAP (389/636), SMB (445), DCERPC (135)**: `Dissector` trait impls that + return no documents, with TODOs. Kerberos only for this first cut. +- **TCP reassembly** is arrival-order only — no seq reordering, retransmit + dedup, or gap handling. Correct for in-order, loss-free captures. See ADR §7. +- **Encrypted parts are not decrypted.** `Kerberos.authenticator_seq_hex` is a + *ciphertext* prefix; the Mimikatz rule that keys on it only fires where that + structure is observable in cleartext. See ADR §3.2. +- **pcapng timestamps** assume the 1-microsecond `if_tsresol` default. +- **tshark backend** (`--features tshark`, crate `rtshark`) is a declared, + feature-gated seam only; not implemented in this cut. + +## Build + +Requires a Rust toolchain (edition 2024; use current stable, e.g. `rustup update`). + +```sh +# from the repo root +cargo build --release +# optional tshark-backed backend (adds a tshark runtime dependency): +# cargo build --release --features tshark +``` + +`cargo` will resolve and lock the two new crates (`pcap-parser`, `etherparse`) +on first build; `Cargo.lock` is intentionally left for the build box to update. +If a new-crate version fails to resolve, pin it: `cargo update -p pcap-parser`. + +## Run + +```sh +./target/release/chainsaw hunt sample.pcap \ + -s rules-network/ \ + --mapping mappings/sigma-network.yml +``` + +- `sample.pcap` — a capture containing Kerberos on port 88 (e.g. `tcpdump -w + sample.pcap 'port 88'` on a DC segment, or an existing AD attack PCAP). +- `-s rules-network/` — the example Sigma rules. +- `--mapping mappings/sigma-network.yml` — maps Sigma field names onto the + `Network.*` / `Kerberos.*` schema and declares `kind: pcap`. + +Add `--json` / `--jsonl` for machine output, `-q` to quiet the banner. + +> Note: upstream `chainsaw hunt --kind` selects the *rule* kind +> (`chainsaw`/`sigma`/`stalker`), not the file kind. The pcap file kind is +> selected by extension + the mapping's `kind: pcap`; there is deliberately no +> new file-level `--kind` flag (that would diverge from the upstream pattern). + +## Test + +```sh +# unit tests, including the BER/DER reader tests in src/file/pcap.rs +cargo test + +# just the pcap module tests +cargo test --lib file::pcap + +# clippy / fmt as CI would +cargo clippy --all-targets -- -D warnings +cargo fmt --check +``` + +There is no checked-in sample capture. To build a byte-exact unit fixture, +generate a tiny AS-REQ with a Kerberos library (e.g. impacket) and assert the +emitted `Kerberos.msg_type` / `Kerberos.till` / `Kerberos.etype_first`. A +follow-up should add a `tests/pcap/` fixture + integration test mirroring +`tests/evtx/`. + +## Files added / changed + +Added: +- `docs/pcap-ingestion.md` — architecture decision record (schema, Tau mapping, + backend decision, limitations). +- `src/file/pcap.rs` — the pcap file kind: container read, L2-L4 parse, TCP/88 + reassembly, Kerberos BER dissection, `Dissector` trait + stubs, `Wrapper`. +- `mappings/sigma-network.yml` — Sigma field mapping for `Network.*`/`Kerberos.*`. +- `rules-network/` — three example Sigma rules. +- `README-pcap.md` — this file. + +Changed (EVTX/MFT/JSON paths untouched in behaviour): +- `Cargo.toml` — `pcap-parser`, `etherparse`; optional `rtshark` behind feature. +- `src/file/mod.rs` — `Kind::Pcap`, `Document::Pcap`, `Parser::Pcap`, extension + detection, dispatch. +- `src/lib.rs` — re-export `pcap`. +- `src/hunt.rs` — pcap document conversion + `pcap::Wrapper` dispatch. +- `src/cli.rs` — `FileKind::Pcap` arms in the four wrapper/mapper blocks. +- `src/search.rs`, `src/main.rs` — `Document::Pcap` arms. + +--- + +## Draft upstream PR description (DO NOT open yet — for use IF upstream signals interest) + +> **Title:** Add PCAP ingestion (native Kerberos dissection) behind a Dissector trait +> +> **Scope.** Adds a `pcap` file kind so `chainsaw hunt capture.pcap` dissects +> network captures into documents the existing Tau engine + Sigma mappings match. +> First cut covers Kerberos over port 88 (AS-REQ/REP, TGS-REQ/REP, AP-REQ) under +> a stable `Network.*` / `Kerberos.*` schema. EVTX/MFT/JSON paths are untouched. +> +> **Why.** SOCs already run Chainsaw + Sigma over EVTX. Network-only AD attacks +> (ticket forging, Rubeus/Mimikatz wire fingerprints, etype downgrade, S4U/PAC +> anomalies) are invisible to host logs but obvious on the wire. This keeps +> detection in one tool and one rule format instead of bolting on Zeek/Suricata. +> +> **Design.** +> - New kind mirrors the existing parser contract exactly: `file::pcap::Parser` +> (`load`/`parse`), `Document::Pcap`, `Kind::Pcap`, extension detection, and a +> `pcap::Wrapper` analogous to `evtx::Wrapper`. +> - Dissection is **native and self-contained** by default: `pcap-parser` +> (pcap/pcapng) + `etherparse` (L2-L4) + a small purpose-built BER/DER reader +> for Kerberos ASN.1. No libpcap, no tshark in the default build — this honours +> Chainsaw's single-binary distribution. +> - A `Dissector` trait is the extension seam: LDAP/SMB/DCERPC are stubbed behind +> it, and a tshark-backed backend is available as an **optional feature** +> (`--features tshark`, crate `rtshark`) for teams wanting breadth now at the +> cost of a tshark runtime dependency. Dissection is behind a trait precisely +> so breadth can be earned incrementally (or delegated) without reworking the +> pipeline or forcing a runtime dependency on everyone. +> - Schema is nested JSON with dotted paths, so Tau matches it for free and the +> Sigma mapping stays thin. +> +> **Limitations (documented).** TCP reassembly is arrival-order only; encrypted +> parts are not decrypted; pcapng timestamp resolution assumes the 1us default; +> Kerberos only in this cut. See `docs/pcap-ingestion.md`. +> +> **Test plan.** `cargo test` (incl. BER/DER unit tests); a `tests/pcap/` fixture +> + integration test (impacket-generated AS-REQ) mirroring `tests/evtx/` is the +> obvious follow-up; manual `chainsaw hunt sample.pcap -s rules-network/ +> --mapping mappings/sigma-network.yml` against a lab Kerberos capture. +> +> **Dependencies added.** `pcap-parser`, `etherparse` (both pure-Rust); +> `rtshark` optional/feature-gated. diff --git a/mappings/sigma-network.yml b/mappings/sigma-network.yml new file mode 100644 index 0000000..3bc74ff --- /dev/null +++ b/mappings/sigma-network.yml @@ -0,0 +1,79 @@ +--- +# Chainsaw Sigma mapping for PCAP-dissected network documents. +# +# COMPILATION/RUN STATE: UNVERIFIED (authored without a Rust toolchain). +# +# Documents emitted by src/file/pcap.rs are nested JSON under two namespaces: +# Network.* L3/L4 envelope (protocol, transport, addrs, ports, timestamp) +# Kerberos.* RFC 4120 wire fields (see docs/pcap-ingestion.md §3) +# +# Because those documents already use clean dotted paths, this mapping is thin: +# it just gives friendly Sigma aliases (from -> to). Any field not listed here +# falls through to a direct lookup, so a rule may also reference the full +# `Kerberos.*` path verbatim. +name: Chainsaw network (PCAP) Sigma mappings +kind: pcap +rules: sigma + +groups: + - name: Network + # Packet capture time, emitted as %Y-%m-%dT%H:%M:%S%.6fZ. + timestamp: Network.timestamp + # Every dissected document carries Network.protocol, so this selects them all. + filter: + Network.protocol: "*" + fields: + # --- Network.* envelope --- + - from: Protocol + to: Network.protocol + - from: Transport + to: Network.transport + - from: SrcIp + to: Network.src_ip + - from: DstIp + to: Network.dst_ip + - from: SrcPort + to: Network.src_port + - from: DstPort + to: Network.dst_port + # --- Kerberos.* --- + - from: MsgType + to: Kerberos.msg_type + - from: MsgTypeName + to: Kerberos.msg_type_name + - from: Realm + to: Kerberos.realm + - from: ClientName + to: Kerberos.cname_string + - from: ServiceName + to: Kerberos.sname_string + - from: TicketServiceName + to: Kerberos.ticket_sname_string + - from: KdcOptions + to: Kerberos.kdc_options + - from: KdcOptionsFlags + to: Kerberos.kdc_options_flags + - from: EtypeFirst + to: Kerberos.etype_first + - from: Etypes + to: Kerberos.etypes + - from: EtypeCount + to: Kerberos.etype_count + - from: Till + to: Kerberos.till + - from: RenewTill + to: Kerberos.renew_till + - from: Nonce + to: Kerberos.nonce + - from: PaDataTypes + to: Kerberos.padata_types + - from: PaForUser + to: Kerberos.pa_for_user + - from: PaForUserName + to: Kerberos.pa_for_user_name + - from: AuthenticatorEtype + to: Kerberos.authenticator_etype + - from: AuthenticatorSeq + to: Kerberos.authenticator_seq_hex + - from: EncPartEtype + to: Kerberos.enc_part_etype diff --git a/rules-network/kerberos_rc4_downgrade.yml b/rules-network/kerberos_rc4_downgrade.yml new file mode 100644 index 0000000..b47c0df --- /dev/null +++ b/rules-network/kerberos_rc4_downgrade.yml @@ -0,0 +1,40 @@ +--- +# WIRE FACT: The KDC-REQ-BODY `etype` SEQUENCE lists the client's requested +# encryption types in PREFERENCE ORDER (plaintext). Modern domain-joined +# Windows clients lead with AES (etype 18 = AES256, 17 = AES128). A client that +# leads with RC4-HMAC (etype 23 = 0x17) is a classic downgrade tell: Rubeus / +# Mimikatz overpass-the-hash and many ticket-forging tools request RC4 first (or +# only) because they hold an RC4/NT key, not an AES key. We key on +# Kerberos.etype_first == 23 in an AS-REQ. +# +# This is PLAINTEXT in the request body, so it works on ordinary encrypted +# Kerberos traffic. etype_first is the most-preferred etype; `Etypes` carries +# the full ordered list for tuning. +# +# COMPILATION/RUN STATE: UNVERIFIED. +title: Kerberos RC4 (etype 0x17) Requested Where AES Expected +id: 7c2f6a90-0f1e-4e2a-9b1a-0003deadbeef3 +status: experimental +description: > + Detects a Kerberos AS-REQ that leads with RC4-HMAC (etype 23 / 0x17) as its + most-preferred encryption type, indicating an encryption-type downgrade + consistent with overpass-the-hash or ticket-forging tooling. +author: chainsaw-pcap prototype +date: 2026/10/22 +references: + - https://www.rfc-editor.org/rfc/rfc4120 + - https://attack.mitre.org/techniques/T1558/003/ +tags: + - attack.credential_access + - attack.t1558.003 + - attack.t1550.002 +logsource: + product: network + service: kerberos +detection: + is_as_req: + MsgType: 10 + leads_with_rc4: + EtypeFirst: 23 + condition: is_as_req and leads_with_rc4 +level: medium diff --git a/rules-network/mimikatz_kerberos_authenticator.yml b/rules-network/mimikatz_kerberos_authenticator.yml new file mode 100644 index 0000000..b0be70e --- /dev/null +++ b/rules-network/mimikatz_kerberos_authenticator.yml @@ -0,0 +1,40 @@ +--- +# WIRE FACT: Mimikatz/kekeo-built Kerberos AP-REQ authenticators carry a +# distinctive byte pattern at the front of the authenticator structure +# (historically the sequence/cusec region shows the repeating 0x6c ("l") +# filler, e.g. 6c 6c 6c 00). We key on the AP-REQ authenticator's leading +# bytes exposed as Kerberos.authenticator_seq_hex. +# +# CAVEAT (see docs/pcap-ingestion.md §3.2): the AP-REQ authenticator is an +# EncryptedData, so authenticator_seq_hex is the CIPHERTEXT prefix. This rule +# therefore fires on captures where that structure is observable in cleartext +# (unencrypted test/lab traffic, or a pre-decrypted capture). It is included to +# demonstrate the schema hook requested in the brief, not to claim we decrypt +# live Kerberos. +# +# COMPILATION/RUN STATE: UNVERIFIED. +title: Mimikatz Kerberos Authenticator Byte Fingerprint +id: 7c2f6a90-0f1e-4e2a-9b1a-0001deadbeef1 +status: experimental +description: > + Detects the distinctive leading authenticator bytes (0x6c6c6c..) associated + with Mimikatz/kekeo-generated Kerberos AP-REQ messages. +author: chainsaw-pcap prototype +date: 2026/10/22 +references: + - https://github.com/gentilkiwi/mimikatz + - https://www.rfc-editor.org/rfc/rfc4120 +tags: + - attack.credential_access + - attack.lateral_movement + - attack.t1558 +logsource: + product: network + service: kerberos +detection: + is_ap_req: + MsgType: 14 + mimikatz_authenticator: + AuthenticatorSeq|startswith: "6c6c6c" + condition: is_ap_req and mimikatz_authenticator +level: high diff --git a/rules-network/rubeus_default_till.yml b/rules-network/rubeus_default_till.yml new file mode 100644 index 0000000..5189334 --- /dev/null +++ b/rules-network/rubeus_default_till.yml @@ -0,0 +1,35 @@ +--- +# WIRE FACT: Rubeus hard-codes the KDC-REQ-BODY `till` (ticket end time) to the +# constant 20370913024805Z in its AS-REQ / TGS-REQ builders. Legitimate +# Windows clients derive `till` from policy and it varies; a fixed 2037 value is +# a strong Rubeus fingerprint. `till` is PLAINTEXT in the request body, so this +# detection works on ordinary encrypted Kerberos traffic. +# +# COMPILATION/RUN STATE: UNVERIFIED. +title: Rubeus Default Kerberos Ticket Lifetime (till 20370913024805Z) +id: 7c2f6a90-0f1e-4e2a-9b1a-0002deadbeef2 +status: experimental +description: > + Detects Kerberos AS-REQ/TGS-REQ messages whose request body `till` field is + the Rubeus default 20370913024805Z. +author: chainsaw-pcap prototype +date: 2026/10/22 +references: + - https://github.com/GhostPack/Rubeus + - https://www.rfc-editor.org/rfc/rfc4120 +tags: + - attack.credential_access + - attack.t1558 + - attack.t1550.003 +logsource: + product: network + service: kerberos +detection: + is_kdc_req: + MsgType: + - 10 # AS-REQ + - 12 # TGS-REQ + rubeus_till: + Till: "20370913024805Z" + condition: is_kdc_req and rubeus_till +level: high From 83385975e3edd0287483eba8df0f2e555692ebec Mon Sep 17 00:00:00 2001 From: 4ndr3w6 Date: Fri, 23 Oct 2026 11:24:53 -0500 Subject: [PATCH 06/10] ci: build + test the pcap fork on a Rust runner (first real compile) Co-Authored-By: Claude Fable 5 --- .github/workflows/build-pcap.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/build-pcap.yml diff --git a/.github/workflows/build-pcap.yml b/.github/workflows/build-pcap.yml new file mode 100644 index 0000000..54bee94 --- /dev/null +++ b/.github/workflows/build-pcap.yml @@ -0,0 +1,22 @@ +name: build-pcap +on: + push: + branches: [ feat/pcap-ingestion ] + workflow_dispatch: +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust + run: rustup update stable && rustup default stable + - name: Build (default features — native pcap, no tshark) + run: cargo build --release --verbose + - name: Unit tests (BER/DER + existing) + run: cargo test --release --verbose + - name: Smoke — binary runs and knows the pcap kind + run: | + ./target/release/chainsaw hunt --help 2>&1 | head -40 + ./target/release/chainsaw lint --kind sigma rules-network 2>&1 | tail -20 || true From d0c1eae45ec49ff92ec1b9dda1c762943a5cb252 Mon Sep 17 00:00:00 2001 From: 4ndr3w6 Date: Fri, 23 Oct 2026 14:11:48 -0500 Subject: [PATCH 07/10] test: end-to-end hunt a Kerberoast TGS-REQ (RC4/etype 23) pcap in CI Co-Authored-By: Claude Fable 5 --- .github/workflows/build-pcap.yml | 19 +++++++++++++------ tests/samples/kerberoast_tgs_rc4.pcap | Bin 0 -> 229 bytes 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 tests/samples/kerberoast_tgs_rc4.pcap diff --git a/.github/workflows/build-pcap.yml b/.github/workflows/build-pcap.yml index 54bee94..fa698f5 100644 --- a/.github/workflows/build-pcap.yml +++ b/.github/workflows/build-pcap.yml @@ -12,11 +12,18 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust run: rustup update stable && rustup default stable - - name: Build (default features — native pcap, no tshark) + - name: Build (native pcap, no tshark) run: cargo build --release --verbose - - name: Unit tests (BER/DER + existing) - run: cargo test --release --verbose - - name: Smoke — binary runs and knows the pcap kind + - name: Unit tests + run: cargo test --release + - name: Lint network rules + run: ./target/release/chainsaw lint --kind sigma rules-network + - name: End-to-end — hunt a Kerberoast TGS-REQ (RC4/etype 23) pcap run: | - ./target/release/chainsaw hunt --help 2>&1 | head -40 - ./target/release/chainsaw lint --kind sigma rules-network 2>&1 | tail -20 || true + echo "=== hunting tests/samples/kerberoast_tgs_rc4.pcap ===" + ./target/release/chainsaw hunt tests/samples/kerberoast_tgs_rc4.pcap \ + -s rules-network --mapping mappings/sigma-network.yml --jsonl 2>&1 | tee hunt.out || true + echo "=== rule hits ===" + grep -o '"name":"[^"]*"' hunt.out | sort -u || echo "NO HITS / see output above" + echo "=== also plain search to confirm the pcap dissects at all ===" + ./target/release/chainsaw search -t "Kerberos.msg_type: *" tests/samples/kerberoast_tgs_rc4.pcap 2>&1 | tail -30 || true diff --git a/tests/samples/kerberoast_tgs_rc4.pcap b/tests/samples/kerberoast_tgs_rc4.pcap new file mode 100644 index 0000000000000000000000000000000000000000..105fe0c262d955f969d77566ef0cef0f927282e9 GIT binary patch literal 229 zcmZ3u>F^Z>CI%J;IQah`$Ye|n{sa@OpL6HfCSHy#s-7B1?7VOpqTpe4=W8ypM4 zms*~cT2!1`saKp|P?VULU#yptpPZPpR8WH7$iUd#(8$=(01Tp*voW#c#Bz78U^M`m GCk_C>uRALM literal 0 HcmV?d00001 From eb0d1e97aa9a7f7f28fbe5e387209117289b7cbd Mon Sep 17 00:00:00 2001 From: 4ndr3w6 Date: Fri, 23 Oct 2026 15:02:37 -0500 Subject: [PATCH 08/10] debug: temporary pcap-dbg probes to localize 0-documents (revert after) --- src/file/pcap.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/file/pcap.rs b/src/file/pcap.rs index b99b561..bcbce15 100644 --- a/src/file/pcap.rs +++ b/src/file/pcap.rs @@ -323,6 +323,7 @@ fn dissect_capture(path: &Path) -> crate::Result> { } } + eprintln!("[pcap-dbg] frames={} linktype={} docs={}", frame, linktype.0, out.len()); Ok(out) } @@ -338,13 +339,15 @@ fn handle_frame( ) { let seg = match parse_l2_l4(linktype, data, timestamp, frame) { Some(s) => s, - None => return, + None => { eprintln!("[pcap-dbg] frame {}: parse_l2_l4 -> None (linktype {})", frame, linktype.0); return; } }; + eprintln!("[pcap-dbg] frame {}: {} {}:{}->{}:{} payload={}", frame, seg.meta.transport, seg.meta.src_ip, seg.meta.src_port, seg.meta.dst_ip, seg.meta.dst_port, seg.payload.len()); // Only bother with flows some dissector claims (either direction). let claimed = table .iter() .any(|d| d.claims(seg.meta.src_port) || d.claims(seg.meta.dst_port)); + eprintln!("[pcap-dbg] frame {}: claimed={}", seg.meta.frame, claimed); if !claimed { return; } @@ -389,7 +392,9 @@ fn handle_frame( fn dispatch(table: &[Box], meta: &FlowMeta, payload: &[u8], out: &mut Vec) { for d in table { if d.claims(meta.src_port) || d.claims(meta.dst_port) { - out.extend(d.dissect(meta, payload)); + let r = d.dissect(meta, payload); + eprintln!("[pcap-dbg] dissect on {}:{} payload={} -> {} docs", meta.transport, meta.dst_port, payload.len(), r.len()); + out.extend(r); } } } From d8fe227c6588e027f97314c2f9b05c18506e31ae Mon Sep 17 00:00:00 2001 From: 4ndr3w6 Date: Fri, 23 Oct 2026 16:06:58 -0500 Subject: [PATCH 09/10] fork: drop detection rules (dissector only); revert debug probes; rule-free dissector proof in CI The pcap Kerberos dissector stays a pure parsing capability - no detection rules ship in the fork. CI now proves dissection via a keyword search that dumps the Kerberos document (msg_type, sname, etype) rather than any Sigma rule. Detection rules live only in the private rules repo. Co-Authored-By: Claude Fable 5 --- .github/workflows/build-pcap.yml | 21 +++-- mappings/sigma-network.yml | 79 ------------------- rules-network/kerberos_rc4_downgrade.yml | 40 ---------- .../mimikatz_kerberos_authenticator.yml | 40 ---------- rules-network/rubeus_default_till.yml | 35 -------- src/file/pcap.rs | 9 +-- 6 files changed, 11 insertions(+), 213 deletions(-) delete mode 100644 mappings/sigma-network.yml delete mode 100644 rules-network/kerberos_rc4_downgrade.yml delete mode 100644 rules-network/mimikatz_kerberos_authenticator.yml delete mode 100644 rules-network/rubeus_default_till.yml diff --git a/.github/workflows/build-pcap.yml b/.github/workflows/build-pcap.yml index fa698f5..7d15e46 100644 --- a/.github/workflows/build-pcap.yml +++ b/.github/workflows/build-pcap.yml @@ -12,18 +12,15 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust run: rustup update stable && rustup default stable - - name: Build (native pcap, no tshark) + - name: Build (native pcap kind, no tshark) run: cargo build --release --verbose - - name: Unit tests + - name: Unit tests (BER/DER + existing) run: cargo test --release - - name: Lint network rules - run: ./target/release/chainsaw lint --kind sigma rules-network - - name: End-to-end — hunt a Kerberoast TGS-REQ (RC4/etype 23) pcap + - name: Dissector proof — search the dissected Kerberos document (no detection rules) run: | - echo "=== hunting tests/samples/kerberoast_tgs_rc4.pcap ===" - ./target/release/chainsaw hunt tests/samples/kerberoast_tgs_rc4.pcap \ - -s rules-network --mapping mappings/sigma-network.yml --jsonl 2>&1 | tee hunt.out || true - echo "=== rule hits ===" - grep -o '"name":"[^"]*"' hunt.out | sort -u || echo "NO HITS / see output above" - echo "=== also plain search to confirm the pcap dissects at all ===" - ./target/release/chainsaw search -t "Kerberos.msg_type: *" tests/samples/kerberoast_tgs_rc4.pcap 2>&1 | tail -30 || true + echo "=== keyword search (sname) dumps the dissected doc as JSON ===" + ./target/release/chainsaw search MSSQLSvc tests/samples/kerberoast_tgs_rc4.pcap --json | tee out.json + echo "=== assert the Kerberos fields dissected correctly ===" + grep -q '"msg_type_name": *"TGS-REQ"' out.json && echo "PASS msg_type_name TGS-REQ" + grep -q 'MSSQLSvc' out.json && echo "PASS sname MSSQLSvc" + python3 -c "import json,sys; d=json.load(open('out.json')); import re; s=json.dumps(d); assert 'TGS-REQ' in s and '23' in s; print('PASS document contains TGS-REQ and etype 23 (RC4)')" diff --git a/mappings/sigma-network.yml b/mappings/sigma-network.yml deleted file mode 100644 index 3bc74ff..0000000 --- a/mappings/sigma-network.yml +++ /dev/null @@ -1,79 +0,0 @@ ---- -# Chainsaw Sigma mapping for PCAP-dissected network documents. -# -# COMPILATION/RUN STATE: UNVERIFIED (authored without a Rust toolchain). -# -# Documents emitted by src/file/pcap.rs are nested JSON under two namespaces: -# Network.* L3/L4 envelope (protocol, transport, addrs, ports, timestamp) -# Kerberos.* RFC 4120 wire fields (see docs/pcap-ingestion.md §3) -# -# Because those documents already use clean dotted paths, this mapping is thin: -# it just gives friendly Sigma aliases (from -> to). Any field not listed here -# falls through to a direct lookup, so a rule may also reference the full -# `Kerberos.*` path verbatim. -name: Chainsaw network (PCAP) Sigma mappings -kind: pcap -rules: sigma - -groups: - - name: Network - # Packet capture time, emitted as %Y-%m-%dT%H:%M:%S%.6fZ. - timestamp: Network.timestamp - # Every dissected document carries Network.protocol, so this selects them all. - filter: - Network.protocol: "*" - fields: - # --- Network.* envelope --- - - from: Protocol - to: Network.protocol - - from: Transport - to: Network.transport - - from: SrcIp - to: Network.src_ip - - from: DstIp - to: Network.dst_ip - - from: SrcPort - to: Network.src_port - - from: DstPort - to: Network.dst_port - # --- Kerberos.* --- - - from: MsgType - to: Kerberos.msg_type - - from: MsgTypeName - to: Kerberos.msg_type_name - - from: Realm - to: Kerberos.realm - - from: ClientName - to: Kerberos.cname_string - - from: ServiceName - to: Kerberos.sname_string - - from: TicketServiceName - to: Kerberos.ticket_sname_string - - from: KdcOptions - to: Kerberos.kdc_options - - from: KdcOptionsFlags - to: Kerberos.kdc_options_flags - - from: EtypeFirst - to: Kerberos.etype_first - - from: Etypes - to: Kerberos.etypes - - from: EtypeCount - to: Kerberos.etype_count - - from: Till - to: Kerberos.till - - from: RenewTill - to: Kerberos.renew_till - - from: Nonce - to: Kerberos.nonce - - from: PaDataTypes - to: Kerberos.padata_types - - from: PaForUser - to: Kerberos.pa_for_user - - from: PaForUserName - to: Kerberos.pa_for_user_name - - from: AuthenticatorEtype - to: Kerberos.authenticator_etype - - from: AuthenticatorSeq - to: Kerberos.authenticator_seq_hex - - from: EncPartEtype - to: Kerberos.enc_part_etype diff --git a/rules-network/kerberos_rc4_downgrade.yml b/rules-network/kerberos_rc4_downgrade.yml deleted file mode 100644 index b47c0df..0000000 --- a/rules-network/kerberos_rc4_downgrade.yml +++ /dev/null @@ -1,40 +0,0 @@ ---- -# WIRE FACT: The KDC-REQ-BODY `etype` SEQUENCE lists the client's requested -# encryption types in PREFERENCE ORDER (plaintext). Modern domain-joined -# Windows clients lead with AES (etype 18 = AES256, 17 = AES128). A client that -# leads with RC4-HMAC (etype 23 = 0x17) is a classic downgrade tell: Rubeus / -# Mimikatz overpass-the-hash and many ticket-forging tools request RC4 first (or -# only) because they hold an RC4/NT key, not an AES key. We key on -# Kerberos.etype_first == 23 in an AS-REQ. -# -# This is PLAINTEXT in the request body, so it works on ordinary encrypted -# Kerberos traffic. etype_first is the most-preferred etype; `Etypes` carries -# the full ordered list for tuning. -# -# COMPILATION/RUN STATE: UNVERIFIED. -title: Kerberos RC4 (etype 0x17) Requested Where AES Expected -id: 7c2f6a90-0f1e-4e2a-9b1a-0003deadbeef3 -status: experimental -description: > - Detects a Kerberos AS-REQ that leads with RC4-HMAC (etype 23 / 0x17) as its - most-preferred encryption type, indicating an encryption-type downgrade - consistent with overpass-the-hash or ticket-forging tooling. -author: chainsaw-pcap prototype -date: 2026/10/22 -references: - - https://www.rfc-editor.org/rfc/rfc4120 - - https://attack.mitre.org/techniques/T1558/003/ -tags: - - attack.credential_access - - attack.t1558.003 - - attack.t1550.002 -logsource: - product: network - service: kerberos -detection: - is_as_req: - MsgType: 10 - leads_with_rc4: - EtypeFirst: 23 - condition: is_as_req and leads_with_rc4 -level: medium diff --git a/rules-network/mimikatz_kerberos_authenticator.yml b/rules-network/mimikatz_kerberos_authenticator.yml deleted file mode 100644 index b0be70e..0000000 --- a/rules-network/mimikatz_kerberos_authenticator.yml +++ /dev/null @@ -1,40 +0,0 @@ ---- -# WIRE FACT: Mimikatz/kekeo-built Kerberos AP-REQ authenticators carry a -# distinctive byte pattern at the front of the authenticator structure -# (historically the sequence/cusec region shows the repeating 0x6c ("l") -# filler, e.g. 6c 6c 6c 00). We key on the AP-REQ authenticator's leading -# bytes exposed as Kerberos.authenticator_seq_hex. -# -# CAVEAT (see docs/pcap-ingestion.md §3.2): the AP-REQ authenticator is an -# EncryptedData, so authenticator_seq_hex is the CIPHERTEXT prefix. This rule -# therefore fires on captures where that structure is observable in cleartext -# (unencrypted test/lab traffic, or a pre-decrypted capture). It is included to -# demonstrate the schema hook requested in the brief, not to claim we decrypt -# live Kerberos. -# -# COMPILATION/RUN STATE: UNVERIFIED. -title: Mimikatz Kerberos Authenticator Byte Fingerprint -id: 7c2f6a90-0f1e-4e2a-9b1a-0001deadbeef1 -status: experimental -description: > - Detects the distinctive leading authenticator bytes (0x6c6c6c..) associated - with Mimikatz/kekeo-generated Kerberos AP-REQ messages. -author: chainsaw-pcap prototype -date: 2026/10/22 -references: - - https://github.com/gentilkiwi/mimikatz - - https://www.rfc-editor.org/rfc/rfc4120 -tags: - - attack.credential_access - - attack.lateral_movement - - attack.t1558 -logsource: - product: network - service: kerberos -detection: - is_ap_req: - MsgType: 14 - mimikatz_authenticator: - AuthenticatorSeq|startswith: "6c6c6c" - condition: is_ap_req and mimikatz_authenticator -level: high diff --git a/rules-network/rubeus_default_till.yml b/rules-network/rubeus_default_till.yml deleted file mode 100644 index 5189334..0000000 --- a/rules-network/rubeus_default_till.yml +++ /dev/null @@ -1,35 +0,0 @@ ---- -# WIRE FACT: Rubeus hard-codes the KDC-REQ-BODY `till` (ticket end time) to the -# constant 20370913024805Z in its AS-REQ / TGS-REQ builders. Legitimate -# Windows clients derive `till` from policy and it varies; a fixed 2037 value is -# a strong Rubeus fingerprint. `till` is PLAINTEXT in the request body, so this -# detection works on ordinary encrypted Kerberos traffic. -# -# COMPILATION/RUN STATE: UNVERIFIED. -title: Rubeus Default Kerberos Ticket Lifetime (till 20370913024805Z) -id: 7c2f6a90-0f1e-4e2a-9b1a-0002deadbeef2 -status: experimental -description: > - Detects Kerberos AS-REQ/TGS-REQ messages whose request body `till` field is - the Rubeus default 20370913024805Z. -author: chainsaw-pcap prototype -date: 2026/10/22 -references: - - https://github.com/GhostPack/Rubeus - - https://www.rfc-editor.org/rfc/rfc4120 -tags: - - attack.credential_access - - attack.t1558 - - attack.t1550.003 -logsource: - product: network - service: kerberos -detection: - is_kdc_req: - MsgType: - - 10 # AS-REQ - - 12 # TGS-REQ - rubeus_till: - Till: "20370913024805Z" - condition: is_kdc_req and rubeus_till -level: high diff --git a/src/file/pcap.rs b/src/file/pcap.rs index bcbce15..b99b561 100644 --- a/src/file/pcap.rs +++ b/src/file/pcap.rs @@ -323,7 +323,6 @@ fn dissect_capture(path: &Path) -> crate::Result> { } } - eprintln!("[pcap-dbg] frames={} linktype={} docs={}", frame, linktype.0, out.len()); Ok(out) } @@ -339,15 +338,13 @@ fn handle_frame( ) { let seg = match parse_l2_l4(linktype, data, timestamp, frame) { Some(s) => s, - None => { eprintln!("[pcap-dbg] frame {}: parse_l2_l4 -> None (linktype {})", frame, linktype.0); return; } + None => return, }; - eprintln!("[pcap-dbg] frame {}: {} {}:{}->{}:{} payload={}", frame, seg.meta.transport, seg.meta.src_ip, seg.meta.src_port, seg.meta.dst_ip, seg.meta.dst_port, seg.payload.len()); // Only bother with flows some dissector claims (either direction). let claimed = table .iter() .any(|d| d.claims(seg.meta.src_port) || d.claims(seg.meta.dst_port)); - eprintln!("[pcap-dbg] frame {}: claimed={}", seg.meta.frame, claimed); if !claimed { return; } @@ -392,9 +389,7 @@ fn handle_frame( fn dispatch(table: &[Box], meta: &FlowMeta, payload: &[u8], out: &mut Vec) { for d in table { if d.claims(meta.src_port) || d.claims(meta.dst_port) { - let r = d.dissect(meta, payload); - eprintln!("[pcap-dbg] dissect on {}:{} payload={} -> {} docs", meta.transport, meta.dst_port, payload.len(), r.len()); - out.extend(r); + out.extend(d.dissect(meta, payload)); } } } From a77a7893e9130355c6cc5bdeee581fa04b99550f Mon Sep 17 00:00:00 2001 From: 4ndr3w6 Date: Fri, 23 Oct 2026 16:58:08 -0500 Subject: [PATCH 10/10] chore: drop the ad-hoc pcap build workflow (upstream CI covers the branch) --- .github/workflows/build-pcap.yml | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 .github/workflows/build-pcap.yml diff --git a/.github/workflows/build-pcap.yml b/.github/workflows/build-pcap.yml deleted file mode 100644 index 7d15e46..0000000 --- a/.github/workflows/build-pcap.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: build-pcap -on: - push: - branches: [ feat/pcap-ingestion ] - workflow_dispatch: -permissions: - contents: read -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install Rust - run: rustup update stable && rustup default stable - - name: Build (native pcap kind, no tshark) - run: cargo build --release --verbose - - name: Unit tests (BER/DER + existing) - run: cargo test --release - - name: Dissector proof — search the dissected Kerberos document (no detection rules) - run: | - echo "=== keyword search (sname) dumps the dissected doc as JSON ===" - ./target/release/chainsaw search MSSQLSvc tests/samples/kerberoast_tgs_rc4.pcap --json | tee out.json - echo "=== assert the Kerberos fields dissected correctly ===" - grep -q '"msg_type_name": *"TGS-REQ"' out.json && echo "PASS msg_type_name TGS-REQ" - grep -q 'MSSQLSvc' out.json && echo "PASS sname MSSQLSvc" - python3 -c "import json,sys; d=json.load(open('out.json')); import re; s=json.dumps(d); assert 'TGS-REQ' in s and '23' in s; print('PASS document contains TGS-REQ and etype 23 (RC4)')"