BXUP Track progress & test yourself — free
Security & AI Security · Hands-on lab · ~5 min read
SECURITY & AI SECURITY · HANDS-ON LAB

Lab 04 · CTI Report → Detection Pipeline

Take an unstructured threat-intelligence report, use an LLM to extract MITRE ATT&CK techniques and IOCs into a structured schema, emit a STIX 2.1 bundle, and turn one extracted technique into a candidate Sigma detection you actually test.

This is the full "intel-to-defense" loop, and it is where LLMs are most seductive and most dangerous: they will confidently map a sentence to the wrong ATT&CK technique, invent an IOC that was never in the text, or produce a rule that looks plausible and matches nothing. You will build a hallucination catch step that cross-checks every extracted IOC back to a literal span in the source report, and every technique to the behavior that justified it. Your artifact is a mapped TTP table, a valid STIX bundle, and one tested rule.

Key Takeaway:

Extraction without grounding is fabrication with citations. Every IOC and technique must trace to a span of the source report or it does not ship.

Time & tier

~120 minutes hands-on · Core tier (maps to module M2 · AI-Assisted Detection Engineering).

Setup

Offline, $0. You provide the report; extraction runs on a client-approved endpoint or local Ollama; STIX generation and rule testing are pure local Python + sigma-cli. Install: pip install pydantic duckdb sigma-cli pysigma-backend-splunk pysigma-pipeline-sysmon (plus anthropic if you have a key).

Create cti-lab/ and save this deliberately messy intel report as report.txt. It contains real techniques described in prose, two genuine IOCs, and one detail (a "known C2 domain") that is asserted without the domain being named — the bait for an extraction hallucination.

THREAT BULLETIN — TB-2026-0714 (TLP:CLEAR, sanitized lab sample)

Over the past week we observed an intrusion beginning with a malicious macro-enabled
document (invoice.docm) delivered by email. On open, WINWORD spawned a child cmd.exe
which invoked certutil to download a stage-two payload. The payload used rundll32.exe
to load comsvcs.dll and MiniDump lsass.exe, indicating credential access. Persistence
was established via a Run key. Beaconing was observed to 185.220.101.5 over port 4444,
with periodic callbacks. Analysts note the actor is known to rotate through several
C2 domains and has previously used a PowerShell downloader with EncodedCommand. One
additional indicator recovered from the host was the file hash
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

Save this extraction schema as extract.py. The schema forces the model to attach, for every item, the exact source span that justifies it — the grounding gate.

"""CTI extraction with mandatory source grounding. --dry-run needs no key."""
import argparse, json
from pydantic import BaseModel

class TTP(BaseModel):
    technique_id: str          # e.g. T1059.001
    technique_name: str
    behavior_quote: str        # verbatim span from the report that justifies this mapping

class IOC(BaseModel):
    ioc_type: str              # ipv4-addr | domain-name | file:hash | url
    value: str
    source_quote: str          # verbatim span containing this exact value

class Extraction(BaseModel):
    ttps: list[TTP]
    iocs: list[IOC]
    asserted_but_unnamed: list[str]   # claims of IOCs the text describes but never names

def extract(report: str) -> Extraction:
    import anthropic
    client = anthropic.Anthropic()
    resp = client.messages.parse(
        model="claude-opus-4-8", max_tokens=2048, thinking={"type": "adaptive"},
        system=("Extract ATT&CK TTPs and IOCs from the report. For every item copy the exact "
                "source span into the *_quote field. If the report claims an indicator exists "
                "but never gives its value (e.g. 'known C2 domains'), put that claim in "
                "asserted_but_unnamed and DO NOT invent a value."),
        messages=[{"role": "user", "content": report}],
        output_format=Extraction,
    )
    return resp.parsed_output

if __name__ == "__main__":
    ap = argparse.ArgumentParser(); ap.add_argument("--dry-run", action="store_true")
    if ap.parse_args().dry_run:
        print(Extraction(ttps=[], iocs=[], asserted_but_unnamed=["known C2 domains"]).model_dump_json())
    else:
        print(extract(open("report.txt").read()).model_dump_json(indent=2))

Guided steps

  1. Prove the wiring:python extract.py --dry-run prints a schema-valid, empty extraction. The asserted_but_unnamed field is your anti-hallucination channel.
  2. Extract (or do it by hand). With a key, python extract.py. Without one, fill the schema manually from report.txt using a local model. Expect ~5 techniques and 2 named IOCs.
  3. Run the grounding check — the core skill. For each IOC, confirm its value appears verbatim in report.txt (grep -F "185.220.101.5" report.txt). For each TTP, confirm the behavior_quote is a real span and actually describes that technique. Any IOC whose value is not in the file is a hallucination — log it and delete it.
  4. Catch the seeded bait. The report says the actor "rotates through several C2 domains" but never names one. A grounded extraction puts "known to rotate through several C2 domains" in asserted_but_unnamed. A hallucinating model will invent a domain like cdn-update-check.net. If a domain IOC appears, grep will fail to find it — that is your catch. Record it in hallucination-log.md.
  5. Build the TTP table. Produce ttp-table.md with columns ATT&CK ID · name · behavior quote · candidate data source. The genuine techniques: T1566.001 (spearphishing attachment), T1059.003 (cmd), T1105 (certutil ingress transfer), T1218.011 (rundll32), T1003.001 (LSASS MiniDump), T1547.001 (Run key), T1059.001 (PowerShell EncodedCommand).
  6. Emit a STIX 2.1 bundle from grounded items only. Run this generator over your verified IOCs and TTPs:
import json, uuid, datetime
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
sid = lambda t: f"{t}--{uuid.uuid4()}"
iocs = [("ipv4-addr", "185.220.101.5"),
        ("file:hashes.'SHA-256'", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")]
ttps = [("T1218.011", "Rundll32"), ("T1003.001", "LSASS Memory")]
objs = []
for typ, val in iocs:
    objs.append({"type": "indicator", "spec_version": "2.1", "id": sid("indicator"),
                 "created": now, "modified": now, "name": f"IOC {val[:24]}",
                 "pattern": f"[{typ} = '{val}']", "pattern_type": "stix", "valid_from": now})
for tid, name in ttps:
    objs.append({"type": "attack-pattern", "spec_version": "2.1", "id": sid("attack-pattern"),
                 "created": now, "modified": now, "name": name,
                 "external_references": [{"source_name": "mitre-attack", "external_id": tid}]})
json.dump({"type": "bundle", "id": sid("bundle"), "objects": objs},
          open("bundle.json", "w"), indent=2)
print("wrote bundle.json with", len(objs), "objects")
  1. Turn one technique into a tested detection. Pick T1003.001 (LSASS MiniDump). Author a Sigma rule for process_creation matching Image|endswith: '\rundll32.exe' and CommandLine|contains: 'MiniDump', compile it (sigma convert -t splunk -p sysmon -p splunk_windows lsass_minidump.yml), and test against a two-row known-bad/known-good CSV as in Lab 03. Confirm true-positive on the comsvcs.dll MiniDump line and true-negative on a benign rundll32.
  2. Assemble the pipeline artifact.ttp-table.md + bundle.json + the tested rule + hallucination-log.md.
Exam Tip

ATT&CK misattribution is subtle — certutil downloading a payload is T1105 (Ingress Tool Transfer), not "certutil abuse" as a technique. Scenario items reward mapping the behavior to the technique, not the tool name to a guess.

Verification

  • python extract.py --dry-run passes.
  • Every IOC in your final set is confirmed present in report.txt via grep -F; none is invented.
  • asserted_but_unnamed captures the "C2 domains" claim, and hallucination-log.md records any invented domain the model produced.
  • bundle.json is valid JSON (python -c "import json;json.load(open('bundle.json'))") with indicator and attack-pattern objects.
  • The LSASS rule compiles and shows true-positive/true-negative on the test CSV.

Completion artifact

A cti-lab/ folder with ttp-table.md, bundle.json, the tested Sigma rule, and hallucination-log.md. Self-grading rubric (pass = 8/10):

  • Grounded extraction (0–3): every IOC traces verbatim to the report; every TTP has a real justifying quote = 3.
  • Hallucination catch (0–2): the unnamed-C2-domain bait is caught and logged; no invented IOC survives = 2.
  • Valid STIX (0–2): bundle parses and contains correctly typed indicator + attack-pattern objects = 2.
  • Tested rule (0–2): one technique becomes a rule that fires on known-bad and stays quiet on known-good = 2.
  • Mapping accuracy (0–1): techniques map to behavior (T1105 for certutil, not "certutil") = 1.

Stuck?

  • Model invents a domain every run: strengthen the system prompt ("NEVER output a value not present verbatim in the input"); the grounding grep is your safety net regardless.
  • STIX validator complains: the minimal bundle here is spec-shaped for the lab; if you need strict validation, pip install stix2 and construct via its classes — the grounding lesson is unchanged.
  • Sigma rule matches nothing: check CommandLine vs command_line field names and that your test row literally contains MiniDump.
  • No key: extract by hand into the schema — the grounding discipline is the graded skill, not the automation.

Client-safe variant

CTI reports frequently contain client-attributable context. Run extraction on the client-approved tenancy (Bedrock/Azure OpenAI) or a local Ollama model, and strip customer names, internal hostnames, and case IDs from the report before prompting — keep only the technical behavior and public IOCs. STIX generation and Sigma testing are fully local, so the grounded, tested output you hand a client is one where no data left an approved boundary and every indicator survived a verbatim source check.

Answer key (don't read before attempting)

  1. Named IOCs (2):185.220.101.5 and the SHA-256 hash. Both appear verbatim in report.txt.
  2. The bait: "known to rotate through several C2 domains" names no domain. Correct handling = asserted_but_unnamed; any concrete domain the model emits is a hallucination caught by grep -F failing.
  3. Technique mappings: spearphishing attachment T1566.001, cmd T1059.003, ingress transfer via certutil T1105, rundll32 T1218.011, LSASS dump T1003.001, Run-key persistence T1547.001, encoded PowerShell T1059.001.
  4. Tested rule: T1003.011 vs T1003.001 confusion is common; LSASS memory dumping is T1003.001. The rule fires on rundll32 ... comsvcs.dll ... MiniDump and not on benign rundll32.
This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in