TL;DR: PhaaS domains churn faster than IOC lists can ship. We built a self-hosted MCP server that runs an OSINT pipeline from a source report through to scored candidates, a long-term RAG entry, and human-gated takedown notifications — using local LLMs so no report text or screenshots leave the network. Across nine real campaigns it has produced 2,482 scored candidates from a single source report, correctly refuted a shared-hosting false positive via IP pivot, and had four drafted abuse notifications rejected by the human gate as false positives. Zero verified true positives have reached the confirmed tier through the pipeline's own scoring yet. The durable output is not the IOC list — it is the detection logic that finds the next wave.
We spend a lot of time chasing phishing domains. The problem is that phishing-as-a-service (PhaaS) operators spin through domains faster than we can block them — a TYCOON2FA infrastructure rotation in March 2026 cycled dozens of freshly-registered or compromised domains in a matter of days, and by the time an IOC list ships, half the entries are 404 and the operator has moved to the next batch. The durable asset in this game is not the domain list. It is the detection logic — the queries, markers, pivot strategies, and false-positive indicators that find the next wave of infrastructure regardless of what the domains are this week.
This is a build log for a tool we wrote to capture and accumulate that detection logic. It is an MCP server called phishing-tracker that runs an OSINT pipeline from a published source report through to IOC outputs, a long-term RAG entry, an analyst report, and drafted takedown notifications. It is self-hosted, uses local LLMs, treats LLM-generated JSON as adversarial input, enforces a human gate on every takedown, and has been run against nine real campaigns. We are going to walk through what it is, how it works, and what the real campaigns taught us — including the false positives and the bugs, because a build log that only documents successes is marketing.
The nine campaigns at a glance
| # | Campaign | Source | Headline stat | Outcome |
|---|---|---|---|---|
| 1 | aitm-evilginx-iocs-2026 |
our own prior AiTM/evilginx writeup (local file; drew on Deepwatch's IOC list) | 358 markers, 696 queries, 2,482 scored candidates, 415 clusters, 1,630 domains, 1,287 IPs | second-order pivots expanded a 50-marker report 7×; IP pivot refuted the nestict.com FP |
| 2 | evilginx_aitm_deepwatch |
Deepwatch Labs blog | 67 markers, 410 candidates, 30 screenshots, 20 vision analyses | full pipeline incl. vision; 15 highs were all security-research-article FPs |
| 3 | bluekit_phishing |
BleepingComputer | 263 candidates, 1 reached confirmed tier, 4 notifications drafted | all 4 rejected by human gate as FPs (legit storefront, news article, two IC3 PSA URLs) |
| 4 | f4n6-solutions-blog |
our own earlier Cavern Manticore writeup | 8 candidates, 3 high-confidence, 3 notifications drafted | pending review at time of writing; details anonymised in this post |
| 5 | mirage2fa |
Fortra blog | 52 markers extracted, 2-year infra window across 31+ domains | stage-coupling bug dropped 51 of 52 markers from the RAG entry; full marker set survived in state |
| 6 | octopi365_phishing |
Huntress (Kali365) | 43 kit markers, 0 scored candidates | query generation produced no kit-specific URLScan queries; operator-side endpoints not public-facing |
| 7 | artoken-eviltokens-m365 |
BleepingComputer (ARToken/EvilTokens) | 8 markers, 0 candidates | queries + vision stages failed |
| 8 | jadepuffer-ai-agent |
BleepingComputer (JadePuffer ransomware) | 0 markers, 0 candidates | not a PhaaS kit; queries + vision failed |
| 9 | chottogram-mvam-investigation |
direct URL | 1 marker, 0 candidates | source returned only "302 Found"; vision failed |
The four campaigns we walk through in detail below are #1 (the scale run), #3 (the confirmed-tier FP), #5 (the bug), and #4 (the pending takedown).
The problem with chasing domains
The arithmetic is brutal. A modern PhaaS kit like Evilginx, TYCOON2FA, Mirage2FA, Bluekit, or Kali365 operates at the scale of dozens to hundreds of domains per campaign, with infrastructure rotations every few weeks. A threat intel blog post ships, an IOC list gets cut, the list goes into a SIEM, and by the time the detections fire the operator has already rotated. We have watched this loop play out enough times to conclude that the IOC list is the wrong output.
The right output is the query that finds the IOCs. When TYCOON2FA rotated infrastructure in March 2026, the domains changed but the kit wording (TYCOON2FA, phishlets, the Yg11u campaign token on abducke.com), the URL patterns (/Fe2yiyoKvg3YTfV!/$EMAIL_ADDRESS), the PTR signatures (16259-X.ip-pptr.tech on GCS-AS proxy nodes), and the ASN clustering (M247 Europe SRL for IPv6 login egress; DeltaHost-Kyiv for Mirage2FA hosting, tied to a Fortra-reported registrant name that may itself be fabricated) all stayed stable. If you capture those signatures as executable queries, you find the next rotation on URLScan before anyone writes a blog about it.
That is the philosophical center of the tool. We will come back to it when we talk about the RAG entry.
What we built
phishing-tracker is a Model Context Protocol (MCP) server built on FastMCP, running over stdio inside an opencode session. It exposes roughly 24 tools to the analyst: run_pipeline to kick off a campaign, poll_pipeline to watch it run, list_candidates and get_candidate to review scored hits, get_markers and add_marker/remove_marker to refine the extracted marker set, get_iocs and get_rag_entry to pull outputs, resolve_contacts and the *_notification family to draft and gate takedown requests, and run_ip_pivot/run_urlscan_query/run_searxng_query for interactive pivots outside the pipeline.
The choice of MCP over a CLI or a web UI is deliberate. The analyst is an LLM-assisted workflow — the model reads the source report, decides what to pivot on, interprets the vision output, and drafts the notification. Wrapping the tools as MCP means the model calls them natively as structured functions instead of us shell-wrangling curl commands and parsing ad-hoc output. The state is a single JSON document per campaign, mutated atomically under file locking, so parallel tool calls (the analyst adding a marker while the pipeline polls its own status) cannot corrupt each other.
The pipeline itself is a bash orchestrator (pipeline.sh) that generates a bespoke run_pipeline.py into each campaign's output directory and runs ten stages sequentially. The generated-runner pattern is a small but meaningful choice: every campaign is self-documenting and re-runnable in isolation. You can python3 output/EvilTokens/run_pipeline.py six months later and reproduce the run.
Two LLMs are wired in, both local self-hosted vLLM endpoints running OpenAI-compatible APIs: a Qwen3.5-MoE text extraction model handles marker extraction in stages 02 and 04b, and a Qwen vision model handles screenshot analysis in stage 07. No report text and no screenshots leave the analyst's network. The config has separate EXTRACTION_LLM_* and VISION_LLM_* blocks, and the JSON parser has explicit fallbacks for the reasoning_content/reasoning fields that vLLM emits from reasoning-capable models — a detail that matters more than you would think, because reasoning models like to emit example arrays in their chain of thought before the real answer.
The architecture, end to end:
Source Report → LLM Extraction → Query Generation → Passive Pivots
↓
Candidate Scoring
↓
Screenshots → Vision Analysis → Clustering
↓
IOC CSV/JSON · RAG Entry · HTML Report · Notifications
The pipeline at a glance
Ten stages, each a function in lib/stages.py, each mutating a shared PipelineState that persists to state.json after every stage:
| # | Stage | What it does |
|---|---|---|
| 01 | 01_ingest |
Fetches the source report (URL or local file), strips scripts/styles/HTML, decodes entities, extracts <title>. |
| 02 | 02_extract |
Sends the report text (truncated to 50K chars) to the extraction LLM with a strict prompt; parses the JSON array defensively. |
| 03 | 03_queries |
Maps each marker to source-specific query syntax (URLScan, SearXNG, crt.sh, Wayback, IP pivots); suppresses generic terms. |
| 04 | 04_pivots |
Executes all queries with per-source rate limiting; every action gated by check_action(). |
| 04b | 04b_multisource |
Mines SearXNG results for secondary articles about the same kit, re-extracts markers, re-runs 03 and 04 incrementally. |
| 05 | 05_score |
Scores every result against marker sets; builds evidence lists; dedups by URL, merging evidence and incrementing scan count. |
| 06 | 06_screenshots |
Downloads PNGs from urlscan.io/screenshots/{uuid}.png for high/probable/confirmed candidates, capped at 30. |
| 07 | 07_vision |
Base64-encodes each screenshot, POSTs to the vision LLM, stores the description, and re-scores candidates when markers appear in the description. |
| 08 | 08_cluster |
Groups candidates by shared IP, shared ASN (>2 members), Cloudflare Worker pattern, and shared server header. |
| 09 | 09_output |
Generates iocs.json, iocs.csv, rag_entry.json, and a self-contained dark-themed analyst_report.html with embedded base64 screenshots. |
| 10 | 10_notify |
Filters to confirmed and high confidence, builds evidence packs, resolves contacts, drafts notifications. Nothing is sent. |
A few of these stages deserve a closer look because the implementation details are where the real engineering happened.
Adversarial JSON parsing from reasoning-capable LLMs
Stage 02 sends the report text to the extraction LLM with a prompt that constrains output to a JSON array of {type, value, confidence, reason} objects against a closed enum of marker types. The parser does not trust the model. It strips markdown fences. It regex-finds every [{...}] array in the response and tries them last-to-first — explicitly because reasoning-capable models sometimes emit an example array in their chain of thought before producing the real output, and the real array is the last one. If no array parses, it falls back to line-by-line JSON-object extraction. Unknown marker types default to kit_wording rather than rejecting the entry.
This sounds paranoid until you run it against a model that emits a 200-token reasoning preamble before the answer. We hit that on the first campaign and the parser saved the run. If you are consuming structured output from any reasoning-capable LLM, treat the output as adversarial input. Strip fences, scan for all arrays, try them in reverse, fall back to line parsing. It is a small amount of code and it is the difference between a pipeline that works and one that silently produces zero markers.
Multi-source ingestion as a second-order pivot
Stage 04b is the stage we did not expect to matter as much as it did. After the first round of pivots, the pipeline mines the SearXNG results for secondary articles about the same kit — titles containing kit-wording markers, filtered to exclude social media and dictionary domains (reddit, x.com, wiktionary, merriam-webster) and generic version strings (v3.3.0 matches Bootstrap docs as easily as Evilginx). It fetches up to eight secondary sources, re-extracts markers from each, merges only new (type, value) combinations, and then re-runs query generation and pivots incrementally with the expanded set.
In practice, this is how the aitm-evilginx-iocs-2026 campaign went from 50 markers in the original OSINT report to 358 markers in the final run. The original Deepwatch blog post pointed at three Evilginx infrastructure IPs. The second-order pivot pulled in follow-up articles from eSentire, CrowdStrike, LevelBlue SpiderLabs, Microsoft, Trend Micro, and Abnormal Security, each contributing additional IOCs: the M247 IPv6 login egress pool, the ProxyLine commercial proxy infrastructure, the Dadsec-to-Tycoon2FA lineage with shared PHP payload filenames (res444.php, cllascio.php, .000.php), and the post-takedown ASN rotation (HOST TELECOM, Clouvider, GREEN FLOID, Shock Hosting). One source report became seven, and the marker set grew by a factor of seven.
Vision as a feedback loop, not a display
Stage 07 is not "show the analyst a screenshot." It is a scoring loop. The vision LLM receives each screenshot with the prompt "What do you see in this image? Describe all text, UI elements, buttons, form fields, and branding. List everything top to bottom." The description is stored on the candidate, and then for each marker whose value appears in the description, the candidate gets a vision_confirmed evidence tag worth +3 points and is re-scored. A probable candidate (score 3) with a vision confirmation becomes a high candidate (score 6). A high candidate with two vision confirmations becomes confirmed.
This matters because the takedown stage only drafts notifications for confirmed and high candidates. Vision promotion directly controls which candidates reach the human-gate. It also, as we will see in the Bluekit campaign, is where the most expensive false positives originate — the vision model matches article text about a kit, not phishing UI elements, and promotes a legitimate news article into the confirmed tier.
Marker extraction
The extraction prompt constrains the LLM to a closed enum of 17 marker types:
url_pattern, path_pattern, js_filename, html_title, favicon_hash,
form_field, api_endpoint, kit_wording, redirect_behaviour,
lure_brand, domain, ip, cidr, worker_pattern, page_title,
server_header, subdomain_pattern
The prompt explicitly instructs: "Kit-specific markers only, not generic web terms." This is enforced a second time at query generation, where a hardcoded suppression set filters out generic brand names (outlook, adobe, docusign, microsoft, google, office, login, sign in, onedrive, sharepoint, teams, voicemail) and version strings (e1, e2, e3, v?\d+(\.\d+){0,2}). Without this suppression, a page.title:"outlook" URLScan query returns millions of irrelevant results and the pipeline drowns. The suppression is built into query generation, not bolted on as a post-filter, because the cost is in the query itself — you do not want to issue the query at all.
The analyst can refine the marker set after extraction via add_marker and remove_marker. This is not a cosmetic feature. On the evilginx_aitm_deepwatch campaign, the LLM extracted the kit wording Tycoon 2FA, which is correct, but that wording then matched legitimate security research articles about the Tycoon2FA takedown (Trend Micro, Microsoft, Reddit r/netsec) and promoted them into the high-confidence tier. The fix is to either remove the marker or add a more specific one — and the pipeline explicitly supports both.
Finding bad-guy infrastructure: the pivot methodology
Stage 03 maps each marker type to source-specific query syntax. The notable mappings:
| Marker type | URLScan query | Other sources |
|---|---|---|
domain |
domain:{val} |
bare value to CT + Wayback |
ip |
ip:{val} |
added to ip_pivots list (highest yield) |
cidr |
base IP extracted, ip:{base} |
full CIDR to SearXNG |
page_title / html_title |
page.title:"{val}" |
suppressed if generic |
worker_pattern / subdomain_pattern |
strips * and leading ., domain:{cleaned} |
— |
kit_wording |
page.title:"…" AND page.body:"…" |
suppressed if length <4 or generic |
path_pattern / api_endpoint |
strips {param} placeholders, page.url:"…" |
— |
js_filename / form_field |
page.body:"…" |
— |
server_header |
page.server:{val} |
— |
favicon_hash |
page.favicon:{val} |
— |
The IP pivot is identified in the code comments as "the highest-yield pivot," and our campaign data confirms it. The pivot is simple: query URLScan with ip:{ip} to find every domain ever scanned on that IP. The discriminative power is in what it reveals about the nature of the IP.
The nestict.com false positive
The Deepwatch blog post on Evilginx infrastructure listed 91.121.237.230 as an Evilginx server. We ran the IP pivot against it and got 12 historical URLScan scans. All 12 belonged to nestict.com — a legitimate Kenyan IT company. Subdomains: iclood.nestict.com (a Nextcloud instance named "I-CLOUD"), elib.nestict.com, billing.nestict.com, mail.bim.nestict.com, accounts.nestict.com, tickets.nestict.com, bizmart.nestict.com. The Evilginx instance Deepwatch identified was almost certainly one tenant on a shared OVH server, not the hosting company itself. The IP has since been decommissioned as nestict.com moved to Contabo.
Without the IP pivot, 91.121.237.230 would have gone into an IOC list as "Evilginx operator." With the pivot, it correctly downgraded to "shared-hosting false positive." That is the difference between blocking a legitimate small business and not.
The GCS-AS proxy farm confirmation
The same pivot, run against 185.143.223.30 and 185.143.223.34 (TYCOON2FA egress IPs), told the opposite story. The PTR records resolved to 16259-7.ip-pptr.tech and 16259-5.ip-pptr.tech — the ip-pptr.tech domain is the proxy infrastructure domain for Global Connectivity Solutions LLP, and the sequential numbering (16259-2, -5, -7) indicates a pool of proxy nodes. All scans showed bare IP access with empty page titles — proxy-only, no web content. This is a commercial proxy farm, not shared hosting. The pivot confirmed the IOC instead of refuting it.
The same pivot pattern, run against the Mirage2FA infrastructure IP 139.28.36.38, revealed five phishing domains sharing identical JS loader path patterns (/nsk/xls/n1s2k.js, /wsk/xls/w1s2k.js, /dsk/xls/, /euk/xls/) — all on DeltaHost-Kyiv under a registrant name Fortra reported in their writeup (which may itself be fabricated, as registrant fields routinely are). The IP pivot did not just find domains; it found the operator's hosting cluster.
Candidate scoring
Every URLScan and IP-pivot result is scored against the marker sets. The scoring builds an evidence list, sums weighted points, and maps to one of four confidence tiers:
| Tier | Min score | Criteria |
|---|---|---|
| candidate | 1 | One weak pattern matched |
| probable | 3 | Multiple URL/path/title/screenshot markers matched |
| high | 5 | Template + infrastructure + lure content + repeat scan history |
| confirmed | 8 | Passive evidence + vision confirmation + kit-specific artefacts |
Evidence weights:
| Evidence tag | Points |
|---|---|
domain_match, screenshot_match, vision_confirmed |
3 |
title_match, path_match, ip_match, kit_wording, form_field_match, subdomain_pattern, lure_brand, multi_scan |
2 |
pattern_match, server_match, repeat_scan |
1 |
Candidates seen on URLScan more than three times get a multi_scan boost (+2). Vision confirmation adds +3. The analyst can manually override any candidate's tier via rescore_candidate, which flags it manually_scored: true so the override is auditable.
The Bluekit campaign: one confirmed-tier candidate, four rejections
The bluekit_phishing campaign, sourced from a BleepingComputer article in June 2026, is our only campaign to date where a candidate reached the confirmed tier through the pipeline's own scoring. The candidate was bluekitmedical.com — vision-confirmed, with evidence types title_match, domain_match, pattern_match, kit_wording, and vision_confirmed. Page title: "Surgical Instruments & Day Surgery Kits | BlueKit Medical."
We want to be clear about what that means and what it does not. The pipeline's scoring produced a high-confidence hit. It did not produce a verified true positive. The human gate then identified it as a false positive. That is the story — both subsystems worked as designed, and the gate is the one that mattered.
The pipeline drafted four abuse notifications. Under two hours later (timestamps preserved in the notification queue: drafted 21:01, rejected 22:56), a human review pass rejected all four.
notif_a865bc00→bluekitmedical.com— rejection reason: "FALSE POSITIVE — bluekitmedical.com is a legitimate UK surgical instruments e-commerce store (Shopify). Name collision only, not the phishing kit."notif_79d37177→www.bleepingcomputer.com— rejection reason: "FALSE POSITIVE — bleepingcomputer.com is a news outlet covering the Bluekit kit. Not a phishing deployment."notif_d71d617c→www.ic3.gov— rejection reason: "FALSE POSITIVE — ic3.gov is a government site. Not a phishing deployment."notif_1e6d3e02→www.ic3.gov(same PSA, differentutm_source) — same false positive pattern.
The vision model had vision_confirmed the legitimate BlueKit Medical storefront, the BleepingComputer news article, and the FBI IC3 PSA because it matched article text containing kit wording, not phishing UI elements. The kit wording phishing-as-a-service appeared in the IC3 PSA title; Bluekit and browser-in-the-middle appeared in the BleepingComputer article; bluekit appeared in the legitimate storefront's domain and title. The scoring did exactly what it was told to do. The failure was in the vision model's inability to distinguish "a page about phishing" from "a phishing page."
This is the integrity-of-the-pipeline story. The scoring subsystem produced a confirmed-tier candidate; the human gate caught that it was a false positive; no notification was sent. The rejection reasons are preserved in notification_queue.json with timestamps. The lesson, baked back into the RAG entry's false_positive_indicators, is that kit-wording markers are the noisiest signal and vision confirmation against article text is a known failure mode. We are working on tightening the vision prompt to ask explicitly whether the page is a functional phishing form rather than a page about phishing.
The RAG entry: detection logic that outlives stale domains
The rag_entry.json is the deliberately designated long-term-valuable output. The README is explicit: it stores "detection logic (queries, patterns, pivots) that remains useful even when specific domains go stale." It contains a detection_logic block with 13 keys:
urlscan_queries,searxng_queries,ct_queries,wayback_queries,ip_pivots— the executable queriestitle_markers,path_patterns,subdomain_patterns,lure_titles,kit_wording— categorised marker valuesinfrastructure_signatures—[{type: ip, value, asn}]for high/confirmed candidates onlypivot_strategies— four hardcoded strategy descriptions (IP pivot → domain rotation; title pivot → gate pages; pattern pivot → worker tenants; cluster analysis)false_positive_indicators— three hardcoded FP warnings:- Legitimate identity-verification services (Sumsub, Vouched, Incode, Persona) use similar page titles
- Cloudflare's own "Verify you are human" challenge is the legitimate version kits mimic
- Old legitimate domains may be compromised but not part of this specific kit — check URL patterns
Plus analyst_actions (preserve screenshots, check tenant logs, check M365 sign-ins after first known access, block at network layer, monitor URLScan with saved queries, check for domain rotation on backend IPs), candidate_infrastructure, clusters, confidence_summary, and pipeline_metadata.
The point is reframing the deliverable. When the specific domains go down, the queries and patterns still find the next wave. You are not maintaining an IOC list; you are accumulating detection logic.
The Mirage2FA campaign: 52 markers and a stage-coupling bug
The mirage2fa campaign, sourced from a Fortra blog post in June 2026, is the run where this philosophy was tested and where we found a real bug. Stage 02 extracted 52 rich markers from the Fortra article, including:
- The credential exfiltration endpoint
letsgetitnow2.storewith paths/api/xwps.php,/eor/xwps.php,/tsk/xwps.php,/cmr/xwps.php,/ulr/xwps.php— hosted on Namecheap, root redirecting to google.com as a decoy - The DeltaHost-Kyiv infrastructure cluster under a registrant name Fortra reported (which may be fabricated — registrant fields routinely are), with five phishing domains on
139.28.36.38sharing identical JS loader path patterns (/nsk/xls/n1s2k.js,/wsk/xls/w1s2k.js,/dsk/xls/,/euk/xls/) - The Telegram C2 bot
@LinXcoded_botreferenced in JS code from February 2025 through January 2026 - A two-year activity window (June 2024 – June 2026) across 31+ domains and 14 IPs spanning HostPapa (US), DeltaHost-Kyiv (UA), Namecheap (US), DigitalOcean (US), and CloudHost (Indonesia)
- The primary attacker domain
cheacker.storeand its second-stage subdomainuser.cheacker.store - The CAPTCHA imagery host
miniapp.bereetro.it.comand the relatedascertain.it.comcluster (including an ADP-branded phishing subdomainadp.ascertain.it.comreturning title "Verify")
That is a rich, durable detection signature set. The bug: the Fortra source page set a JavaScript/cookie check, so source_report.text_content was just the string "Fortra Enable JavaScript and cookies to continue". Stage 02 in the original run extracted the 52 markers from the full HTML raw_content (which contained the article body even though the JS-rendered text was blocked). But when the RAG and IOC output stages ran, they operated on the truncated text_content rather than the rich raw_content, and re-extracted only a single marker: kit_wording: "Enable JavaScript and cookies to continue". The rag_entry.json is valid JSON, but it contains one marker instead of 52.
The 52 markers survived in state.json. The RAG entry did not. That is a stage-coupling bug — the output stages should source from extracted_markers in state, not re-extract from text_content. It is fixed in the working tree but it is the kind of bug that only surfaces when you run the pipeline against a source that gates on JavaScript, and it is worth flagging because it is the sort of thing that silently degrades output quality without failing loudly. The lesson: if a stage depends on the output of an earlier stage, source it from the persisted state, not from a re-derivation of the input.
The takedown workflow
Stage 10 drafts notifications for confirmed and high-confidence candidates only. The contact resolution (lib/contact_resolver.py) works through a strict seven-tier priority order:
| Tier | Source | Confidence |
|---|---|---|
| 1 | security.txt (RFC 9116) — /.well-known/security.txt then /security.txt over HTTPS then HTTP |
high |
| 2 | Website contact routes — guessed security@, abuse@, postmaster@, webmaster@ |
low |
| 3 | RDAP domain — TLD→RDAP bootstrap table (com→Verisign, io→identitydigital, uk→nominet, bw→nic.bw, …), falls back to rdap.org |
medium |
| 4 | Hosting/ASN abuse — RDAP IP via rdap.org/ip/{ip}, parses network name, CIDR, country, ASN, abuse emails |
medium |
| 5 | CDN/platform abuse — pattern-matches domain+server header against cloudflare, workers.dev, pages.dev, fastly, amazonaws |
high |
| 6 | Brand/impersonated organisation — lure_brands markers against Microsoft (reportphish@microsoft.com), DocuSign, Adobe, Google (Safe Browsing), Dropbox, DHL, FedEx |
medium |
| 7 | National reporting — NCSC UK, Action Fraud UK, IC3 US, Fraud Action UK | low |
The first contact becomes the notification's primary_recipient. The notification lifecycle is drafted → approved → sent → followed_up, and there is no code path that sends email. send_notification only records that the analyst manually sent it, along with sent_at, sent_to, and sent_method=manual_email. Every abuse-submitter helper module's docstring states it "does NOT auto-submit." Stage 10 logs, emphatically: "NOTIFICATIONS ARE DRAFTED BUT NOT SENT — human approval required."
The recheck stage (lib/recheck.py) runs after notifications are sent. It does an HTTP HEAD first, classifies 404 as removed, 301/302/403/451 as blocked (451 = "Unavailable For Legal Reasons" is sometimes used for takedowns), and 5xx/unreachable as unreachable. On 200 it GETs the body, extracts the title, and checks for the expected title and matched markers — still_active if found, changed if the content differs. There is also a passive recheck_via_urlscan variant that queries URLScan for recent scans without touching the target directly.
A pending-takedown campaign (details anonymised pending human gate)
We have one campaign with a populated notification queue still at pending_review at time of writing. The notifications target a two-domain C2 cluster on one European hosting provider and a separate domain on another. Three high-confidence notifications were drafted, all on passive evidence (domain matches and repeated scans across months) rather than vision_confirmed — the vision descriptions were all "404 Not Found" or default web-server pages, because the C2 endpoints were returning non-functional pages at screenshot time.
We are deliberately not naming the domains, IPs, ASNs, or registrar details here. Two reasons. First, the human gate has not run yet — publishing specifics of pending takedowns tips the operator before any abuse report is sent. Second, if the gate later rejects any of these as false positives (and the lack of vision confirmation is a genuine caveat), we would have publicly labelled live infrastructure as high-confidence C2 in a post that cannot be un-published. We will write this up as a follow-up once the gate has run and the notifications are either sent or rejected. The point of including the campaign here is to show the pipeline drafting real notifications against real infrastructure on passive evidence alone — and to show us exercising the discipline of not publishing pending-takedown specifics.
Design choices worth flagging
A few engineering decisions are worth calling out for anyone building similar tooling.
Atomic state with file locking for MCP concurrency. save_json writes to a temp file, fsyncs, then os.replaces over the target, all while holding an flock on a .lock sibling file. This is deliberate because parallel MCP tool calls — the analyst calling add_marker while poll_pipeline reads status, or two tools mutating state simultaneously — cannot corrupt state.json. If you are building an MCP server with shared mutable state, do this from day one. We did not, and we lost a campaign state to a race on the third run.
The ALLOWED/AVOIDED action enforcement is a safety gate, not documentation. common.py:check_action hardcodes two lists. ALLOWED: urlscan_api, searxng_query, ct_logs, wayback_check, passive_dns, http_head_public, http_get_public_landing, screenshot_fetch_urlscan, vision_model_analysis. AVOIDED: submit_forms, enter_credentials, bypass_bot_protection, brute_force_discovery, intrusive_scanning, authenticate_to_services. Every external action in stages 04, 04b, 06, and recheck calls check_action() first. The pipeline cannot, by construction, submit a form or enter credentials, even if a future stage were added that tried to. This is the right way to scope an autonomous OSINT tool — the safety boundary is in the code, not in a README.
The generated-runner pattern. pipeline.sh does not just call Python. It writes a bespoke run_pipeline.py into each campaign's output directory with the campaign parameters string-substituted in. Every campaign is self-documenting and re-runnable in isolation. Six months from now, when someone asks "what queries did we run against the TYCOON2FA campaign?", the answer is python3 output/aitm-evilginx-iocs-2026/run_pipeline.py --dry-run away.
Plaintext API key caveat. The URLSCAN_API_KEY is stored in plaintext in config/pipeline.conf. This is a local self-hosted config file, not a published secret, but it is worth flagging as a deployment-hardening consideration. If you deploy this in a shared environment, put the key in an environment variable or a secrets manager and source it into the config. We will likely move to env-var injection in a future commit.
Honest bugs and false positives. The mirage2fa stage-coupling bug and the Bluekit vision false positives are in this post on purpose — as we said at the top, successes-only build logs are marketing. The tool drafted four abuse notifications against legitimate websites and a government domain, and the human gate caught all four. The tool extracted 52 markers and then threw 51 of them away downstream because of a stage-coupling bug. These are the findings that make the tool better, and they are the findings that would be invisible if we only wrote about the 2,482-candidate run.
A concrete artefact: the eSentire KQL hunting query
One of the second-order pivots in the aitm-evilginx-iocs-2026 campaign pulled an actionable KQL hunting query from eSentire's April 2026 post-takedown writeup on TYCOON2FA (reproduced below with attribution to eSentire; see their original post for full context). It hunts for AiTM-style sign-ins where the user agent is axios (the HTTP library used by the kit's token-refresh automation) against OfficeHome, then expands the risk event types:
let aadFunc = (tableName: string) {
table(tableName)
| where ResultType == 0 and AppDisplayName == ('OfficeHome')
| where UserAgent contains 'axios'
| limit 100
| extend risks = todynamic(RiskEventTypes_V2)
| extend risk = iif(isnull(risks) or array_length(risks) == 0, dynamic([null]), risks)
| mv-expand risk
| summarize first_time = min(TimeGenerated), last_time = max(TimeGenerated),
IPAddresses = make_list(IPAddress), risks = make_set(risk)
by UserPrincipalName,AppDisplayName,UserAgent
};
let aadSignin = aadFunc("SigninLogs");
let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
union aadSignin, aadNonInt
This is the shape of the durable output. The domains from that campaign are mostly stale. The KQL query is not. It will find the next kit that uses axios for token refresh against OfficeHome, regardless of what domains it rotates through. That is the whole point.
What's next
The templates/ and tests/ directories in the repository are currently empty. The road ahead, in rough priority order:
- A test suite. The adversarial JSON parser, the marker suppression set, the contact resolution tiering, and the evidence scoring all deserve unit tests. The mirage2fa stage-coupling bug would have been caught by a test that asserts the RAG entry's marker count equals
state.extracted_markerslength. - More notification templates. The current set is generic email, Cloudflare abuse form, and NCSC. We want templates for Hetzner, OVH, DigitalOcean, Namecheap, and the major registrars — all of which surfaced in the real campaigns and several of which had no resolvable abuse contact.
- Fixing the stage-coupling bug so output stages source from
state.extracted_markersand never re-derive fromtext_content. - Tightening the vision prompt to ask explicitly whether the page is a functional phishing form rather than a page about phishing, to cut the article-text false positives that bit us on Bluekit and Deepwatch.
- A false-positive feedback loop: when an analyst rejects a notification, the rejection reason should feed back into the RAG entry's
false_positive_indicatorsautomatically rather than relying on the three hardcoded warnings.
The tool is not finished. Across nine campaigns it has produced one candidate that reached the confirmed tier through its own scoring — which the human gate then correctly identified as a false positive. Zero verified true positives have reached confirmed tier via the pipeline alone. It has drafted and rejected four false positives (all caught by the human gate), extracted 358 markers from a single source report via second-order pivots, and used the IP pivot to correctly refute a shared-hosting IOC that would otherwise have been propagated as an "Evilginx operator." Three further high-confidence notifications are pending human review against a C2 cluster we have deliberately not named in this post. The durable outputs — the RAG entries, the queries, the pivot strategies, the false-positive indicators — are what we are actually accumulating, and they are what we will use to find the next wave of infrastructure when the current domains go stale. The goal of this post is to make the design choices and the honest failures visible enough that someone else building PhaaS infrastructure tracking can borrow what works and avoid what does not.