How to OSINT-Hunt a Nation-State With a Corpus You Already Have: The Vector Index, the Keyword Index, and the Six Ways the Count Lies to You
- Patrick Duggan
- 12 hours ago
- 9 min read
This started with a traffic anomaly, not a threat feed.
Somebody was walking our search API one IP address at a time — 185.82.73.162, then .164, then .165, .167, .168, .171, .175 — over about twenty hours, one query each. That is not how a human searches and it is not how a scanner searches. It is how somebody with a list in front of them checks the list.
The list turned out to be Iranian OT attack infrastructure. This post is the walk from anomaly to attribution to the parts we still do not have, using tools that are mostly free and a corpus most defenders already own without realising it.
The other end of that list was thirty-plus community water and wastewater systems in Minnesota, disrupted on 26–27 July. Small utilities, rural, the kind that run a handful of programmable logic controllers and no security team at all.
The single most useful thing in here is not the actor. It is understanding which of your indexes can be asked a question and which can only be asked for a string — and the six specific ways a search count will lie to your face while looking completely healthy.
The premise: you are not short of data, you are short of the right question
Nation-state hunting has a mystique problem. The assumption is that it requires privileged feeds, a vendor relationship, or a clearance.
It mostly requires a corpus and the discipline to interrogate it honestly. Every indicator in this post came from a public government advisory, public WHOIS, and our own index. Nothing here needed a paid source.
What it did need was knowing that our corpus is really two different kinds of thing wearing one search box, and that asking the wrong kind the wrong way returns a confident, well-formatted, completely wrong answer.
The two index types, and why the difference is the whole skill
Prose indexes are vector-embedded. Ours are blog, cisa_kev, adversaries, content, pulses and paranormal. These hold sentences, so meaning can be indexed. A conceptual query pulls records that share no keywords at all with what you typed. You can ask "memory over-read in an edge appliance" and get back the matching KEV entry, the actor who used it, and your own post about it, none of which contain that phrase. This is the part that feels like magic and it is the reason to embed prose at all.
Identity indexes are keyword and filter only. Ours are iocs, oz_decisions, block_events, tor_relays. These hold identifiers — an IP, a hash, a relay fingerprint. An identifier has no semantic neighbours. [185.82.73.171](https://analytics.dugganusa.com/stix/register?ref=ioc-click&q=185.82.73.171) does not mean anything. It either matches or it does not.
Here is why that distinction is not academic. When we ran that IP through hybrid search across four indexes, the top hit was correct — the IOCONTROL record. Hits two through five were blog posts titled things like "090 44" and "Release 173 Delete 65,000." The engine found numeric strings that looked vaguely like the query and ranked them, because that is what semantic ranking is for. On a prose index that behaviour is the feature. On an identifier lookup it is noise dressed as corroboration.
If you take one thing from this post: know which of your indexes is which, and never let a semantic result set convince you an identifier is "related" to something. For identifiers, use exact lookup and read the source field.
The walk
Step one — start from the anomaly, not the feed. The sequential-enumeration pattern in our own query logs was the entry point. Query logs are an underrated hunting surface: they tell you what other people think is interesting, which is a signal you cannot generate yourself.
Step two — enrich the identifier, exactly. One lookup on 185.82.73.168 returned: IOCONTROL, threat type APT, confidence 90, source research-import-cisa-aa26-097a, first seen 7 April 2026, referencing CISA advisory AA26-097A. That is the whole answer, and it took one call. Note what did the work — the source field, not the search ranking.
Step three — pivot on the source, never on the actor name. This is the counterintuitive one and it is covered properly below. Querying the campaign source returned the full lane: 21 indicators, every one an IP, every one attributed to CyberAv3ngers.
Step four — read the infrastructure, not the flag. Nine of the twenty-one, run through public WHOIS: UltaHost in the Netherlands and Germany three times, DediStart across Bulgaria and Cyprus, MivoCloud in Romania, a US "Private Customer" block, a RIPE-administered Dutch range, and an AFRINIC allocation in Mauritius.
Look at that list. An IRGC-linked group running OT attacks against American water utilities is doing it from European commercial hosting. Not Iranian address space. Not Russian bulletproof. Ordinary leased VPS in NATO countries, which is exactly what CISA's own advisory says — "leased third-party-hosted infrastructure."
That matters operationally: geo-blocking Iran would have stopped none of this. It also matters analytically, and it is where most people's instincts go wrong. The flag on the actor tells you about motive. The flag on the server tells you about procurement. They are different questions and conflating them produces bad blocklists and worse politics.
Step five — check the neighbours. Sequential addressing inside 185.82.73.0/24 is a strong tell for a bulk VPS purchase from one provider, not opportunistic compromise of scattered hosts. That shapes remediation: the useful conversation is with the provider, not with twenty-one separate abuse desks.
The six ways your count lies
Every one of these has bitten us, and each produced a confident wrong number that nobody would have questioned.
One — reading the wrong total field. Our search returns estimatedTotalHits. There is no totalHits. Read the wrong key and you get undefined, which in a lot of code paths quietly becomes zero. Every actor reads as a gap. Nothing errors.
Two — querying a field that is not searchable. On our iocs index, actor and description are not searchable attributes. Only value, malware_family, source and id are. So a full-text query for a threat actor's name cannot match the actor field. We proved this: 21 documents each carrying actor: "CyberAv3ngers" returned an estimated 1 hit for the actor's name and 21 for the campaign source. A campaign with five hundred correctly-attributed indicators reads as zero.
The consequence is a rule, not a caveat: always set a campaign-specific source at ingest. The source field is the only reliable attribution handle you can query later. It is what makes a campaign auditable six months on.
Three — the pagination cap. estimatedTotalHits is bounded by the index's maxTotalHits, default 1,000. A count that reads exactly 1,000 is not a count. It is a ceiling. Use facet distributions when you need a real number, and watch the facet-value cap too.
Four — semantic padding on exact lookups. Covered above. A hybrid engine will always return your requested number of hits if it can, and on an identifier query the tail is filler.
Five — a name query against an embedded index returning the entire index. We queried our 399-profile adversaries index for "Russia," then "Iran," then "China." All three returned 399. Semantic search over a small prose corpus with no filter matches everything to some degree. Three different questions, one meaningless answer, no error.
Six — mistaking ingest time for observation time. Our records carry a timestamp for when the document entered the index. A record harvested by our own scanning and a record typed in from a vendor PDF are structurally identical. We published a false lead claim on exactly this: we said we were two months ahead of a vendor, when our own timestamp showed we had ingested their research one day after they published it. We corrected it publicly.
The rule that came out of that: no lead claim ships unless the receipt is first-party and predates the comparator, or the indicator is corroborated by parties unrelated to the comparator with earlier dates. Before publishing any "we saw it first," put both timestamps side by side. If your source field names somebody else's feed, you did not see it first. You subscribed.
The layer nobody writes about: what you are licensed to do with what you pulled
Methodology posts stop at "and then query the enrichment source." That is where the interesting constraints start, and getting this wrong is a legal problem rather than an accuracy problem.
Free tiers are frequently non-commercial and non-redistributable. One of our precision sources — an independent sensor network we use specifically to catch our own false positives — is on a free research tier whose terms forbid publishing the data, creating derivative products from it, and using it to train AI systems. So it functions as an internal quality gate only. It suppresses our false positives before we publish. Its verdicts never appear in our feed, never appear in a post, and never enter a model context. You will not find its output anywhere in this article, and that is deliberate.
Contribution keys are not query keys. We hold a Spamhaus submissions key. That lets us give data back. It is not a lookup entitlement, and the query service is a separate commercial product. We spent real time debugging authentication failures before realising we were throwing a contribute credential at a consume endpoint. If you are reading a methodology post that says "check it against Spamhaus," ask which Spamhaus.
Never launder a third-party feed as your own observation. If abuse.ch, URLhaus or ThreatFox flagged something, that is corroboration and you cite it as theirs. Re-ingesting it under your own source name and later citing your own ingest date as evidence of early sight is the exact failure mode described above. Cite it; do not absorb it.
Credit the primary research. The twenty-one indicators in this post are CISA's, published in a joint advisory with the FBI, NSA, EPA, DOE, CNMF and Treasury. We did not find them. What is ours is the correlation, the infrastructure read, and the observation about who is querying them. Naming that boundary precisely is not modesty, it is the thing that makes the rest of your claims believable.
And know where your own line is. We do Tor research — public consensus data, relay clustering by ASN and operator. We do not do de-anonymization, we do not identify users, and we do not act as research cover for prosecution. Operator intelligence, not user identification. Deciding that in advance is easier than deciding it when a result is sitting in front of you.
What this produced, including what it did not
Current state on this actor, stated flat:
Solid. The advisory was updated on 22 July; we ingested on 25 July, so our network indicators reflect the current version. Thirty-plus Minnesota water and wastewater systems were disrupted on 26–27 July — four days after that update — and Tenable attributes it to this group on operational-pattern grounds. We covered it. The dual-wing model holds: an OT/ICS wing and a separate hack-and-leak wing, tracked separately.
Not solid, and worth saying plainly. Our lane for this advisory is twenty-one records, all of them IP addresses. No file hashes. No domains. The advisory describes malicious PLC project files, which means file indicators exist and we do not have them. We also could not retrieve the advisory page directly to diff our set against the full published list, so we cannot currently claim completeness — only that what we hold is correctly attributed and post-dates the update.
There is also a newer technique in the reporting — exploitation of a 2021 Rockwell authentication-bypass flaw, a shift away from the default-credential approach this group was known for. The CVE is in our KEV index. The connection between it and this actor is not yet in our coverage.
And one measurement bug, found while writing this: our adversary profile for this group reports a coverage count of zero despite years of posts about them. The enrichment job ran today and computed zero. Two comparable actors got 9 and 2. That is a broken detector, not a coverage gap — and it is a good illustration of the thing we keep relearning: the number that is wrong will not feel wrong. You have to go and look at the artifact.
The short version
Ask your prose indexes questions and your identity indexes for strings. Pivot on the source field, because the actor field probably is not searchable. Read the total-hits key you actually have. Treat a count of exactly 1,000 as a ceiling. Never confuse when you filed something with when you saw it. Read the hosting, not the flag — a state-linked actor on Dutch VPS is a procurement fact, not a Dutch problem. Know what your licences let you publish before you pull the data, not after. And credit the people whose work you are standing on.
None of that requires a vendor contract. All of it requires being willing to check whether your own numbers are honest.
Sources: CISA AA26-097A · Tenable — CyberAv3ngers FAQ · The Register — Minnesota water systems
Every indicator in this post is in the feed. Free.
1.58M+ IOCs, STIX 2.1 / TAXII, 88% novel vs ThreatFox, exploited-CVE leads ahead of CISA. No credit card — a free API key in 30 seconds, and you can audit every claim above against the live endpoints.
