top of page

We Asked Text Whether Two IP Addresses Were Related. It Kept Saying Yes.

Writer: Patrick Duggan
Patrick Duggan
4 hours ago
8 min read

Ask our threat-correlation endpoint about 9.9.9.9 and it used to hand back twenty rows with every appearance of authority.


9.9.9.9 is Quad9. It is a free public DNS resolver run by a Swiss non-profit, recommended by security teams, and we hold precisely nothing on it. The rows came back anyway: a Mirai botnet domain called b.9-9-8.com, a GobRAT address at 47.86.9.9, and eighteen more. The response carried a strength score and a cross-index match count, and both of those numbers read identically to what you got for a live command-and-control server we had been tracking for months.


Nothing in the reply said which was which. A defender reading it had no way to tell a public resolver from a threat.



What it was actually doing


Meilisearch, like most full-text engines, tokenizes on punctuation. An IP address is four numbers separated by dots, so the query 9.9.9.9 becomes the tokens nine, nine, nine, nine — and then it cheerfully matches any record anywhere in the corpus containing a nine in any octet position.


We measured three more:


Query 117.55.202.115, which we hold nothing on, and you got back 217.211.117.55 and 166.117.55.188. Those share the digit runs one-one-seven and five-five. They share nothing else. They are not neighbors, not co-hosted, not related.


Query 100.23.75.198 and you got 111.7.100.23 — a match on the tokens one-hundred and twenty-three.


Query 1.1.1.1, which is Cloudflare, and you got 43.1.1.1 and 2.1.1.1.


And the row count was a lie of a second kind. The default twenty was not twenty matches. It was the page size. Asking for fifty returned fifty. Anyone who wrote "this indicator correlates to twenty other records in our corpus" was quoting a pagination default and calling it evidence. We know, because we did exactly that in a post about a NadMesh command-and-control server. That figure was page-size noise and we have gone back and marked it as such.



The endpoint was never the bug


This is the part worth keeping, and it took us two days to see.


The obvious reading is "the correlation endpoint is broken, go patch the correlation endpoint." That reading is wrong, and patching there would have produced a slightly less wrong endpoint that was still guessing.


The actual problem sat one level up: our indexes shared no canonical key. Indicators of compromise lived in one index, edge blocks in another, published feed rows in a third. Nothing joined them. When there is no key to join on, text is the only thing left, and text is a catastrophically bad proxy for "is this the same infrastructure."


So the fix was not a better query. It was to build the key that should have existed from the beginning.



The join key


One document per normalized indicator, carrying the fields that make a real join possible.


The /24 that an address sits in, as a literal stored value, so "same subnet" becomes a filter instead of an exercise in comparing the first three octets by eye. The registrable domain name, so "same site" stops being substring matching. The autonomous system number, promoted to the top level — it had been buried inside a nested metadata object where it could not be filtered on at all, despite being the single best join across edge blocks, decoy hits and feed rows. Which of our collection lanes saw each indicator, so corroboration becomes countable. And a flag from our edge shield recording whether the thing ever actually sent something, or only knocked.


It is deliberately not a data warehouse. The roadmap has a Snowflake phase with a real monthly bill attached. This is one scheduled job and one small index, and it answers the question we actually had. Ask the cheapest question first; if it turns out not to be enough, at least we will have learned precisely what the expensive thing needs to do.


Correlation now answers two separate questions with two different mechanisms, neither of them text. Do we hold this is an exact lookup, returning a plain true or false plus the name of whichever lane answered. What is adjacent to it is a filter on the join key.





Measured on production, the day it shipped


Quad9 now returns held false, zero neighbors in its /24, zero correlations, strength none. So do 117.55.202.115, 100.23.75.198, and Google's 8.8.8.8.


An address we genuinely hold, 101.36.104.239, returns held true, names the two lanes that hold it, and reports exactly one neighbor — a real second address in the same /24, caught by our own decoys.


A domain we hold returns sixty-one neighbors sharing its registrable name. Those are real siblings on attacker-controlled infrastructure, not coincidence.



What a join key made visible


The most interesting thing it surfaced was not a new indicator. It was a shape.


We hold 134 Flax Typhoon indicators attributed to Integrity Tech — the Beijing contractor that the US Treasury's Office of Foreign Assets Control sanctioned on January 3, 2025, under Executive Order 13694, for providing the infrastructure Flax Typhoon used against US targets. The State Department described it as a large government contractor with ties to the Ministry of State Security. In the old index those were 134 unrelated strings. Grouped by subnet they resolve into eight contiguous /24 networks running from 120.36.248.0 through 120.36.255.0 — which is not eight things. It is one 120.36.248.0/21 allocation. A defender can cover that with a single rule instead of 134 lines.


Here is the honest limit, and we are putting it in bold because it is exactly the kind of thing that gets quietly dropped: every one of those 134 records carries the same ingest timestamp from a single vendor import. The graph revealed the shape of the block. It did not detect it. Ingest time is not detection time, a distinction that has burned us publicly before, and a /21 we can draw is not a /21 we found.


Two things it surfaced that are genuinely ours:


Infrastructure density is now countable. One autonomous system in our data holds 42 addresses across just 3 subnets. Another holds 169 across 34. Compare that to Google, with 517 addresses scattered across 514 different subnets. The ratio is the signal — a few subnets holding many indicators is an operator renting a block; one address per subnet across hundreds is cloud background noise. That comparison was unaskable last week.


Cross-lane corroboration is thin, and now we know the number. Exactly 5 indicators out of 15,154 have been seen by more than one of our collection lanes. Twenty-two subnets have been touched by two lanes — typically our own decoys landing in the same block as a campaign we imported from research. Those twenty-two are the most valuable rows in the index, because independent confirmation we assembled ourselves is the only honest basis for saying we were early on something.


Five out of 15,154 is not a flattering number. It is also the first time we have been able to compute it at all, and a number you can see is worth more than a number you assumed.



Two bugs we caught before they shipped


The job that builds the graph had been run once, by hand. Putting it on a schedule exposed two defects, and both are the kind that report success while doing nothing or doing harm.


It could not have run at all. It reached the search engine over SSH, using the Azure command-line tool to fetch a credential. Neither of those binaries exists inside the container where the scheduler lives. A cron entry pointing at it would have logged a successful spawn every six hours and produced nothing, forever. We already have one job in that exact state and it took sixty days to notice.


It would have corrupted our own first-seen dates. The write is an add-or-replace, and each run only looks at a recent window. On a six-hourly schedule it would have rewritten each indicator's first-seen date to the most recent sighting, every time. Measured on a real two-day pass: 41 of 183 records would have had an earlier date destroyed. That field sits underneath every claim we make about when we saw something, and the corruption would have been invisible, automated, and permanent. It now merges — earliest first-seen wins, latest last-seen wins, collection lanes union together.



And then we found the defect in our own fix


Our adjacency-by-domain-name logic is wrong on shared platforms, and we found it by auditing the thing we had just shipped rather than by waiting for a customer to.


We hold 2,843 indicators that live on pages.dev — Cloudflare's free static hosting. Grouped by registrable name they look like one enormous family. They are not a family. They are 2,843 unrelated attackers who each clicked the same free-hosting signup button. The same applies to 176 indicators on github.com and a handful on azurewebsites.net.


Measured across twenty-seven known shared-hosting suffixes: 3,041 of the 3,910 graph entries that carry a domain name sit on one — 77.8%. Query one of them and the endpoint will confidently report thousands of neighbors, and every one of those neighbors is meaningless.


This is the identical mistake we have criticized federal advisories for making when they list GitHub and Cloudflare addresses as indicators of compromise. We verified our fix against a real attacker-controlled domain, where it works beautifully, and never checked the boring case. That is the failure mode we keep writing about: trusting our own green light.


It does not fabricate the held answer — that remains an exact lookup and is unaffected. It inflates the neighbor count on platform domains. The fix is a suffix list that marks those results as an unreliable basis so a count can never be read as kinship. It is in progress, and we are publishing the defect now rather than after, because a caveat that arrives after someone has quoted the number is not a caveat.



If you use our feed


The correlation endpoint lives at the search-correlate path on our analytics API and requires a key, which is free. Read two fields: held, which is an exact true or false, plus a list naming which lane holds it; and neighbors, which carries the basis used, the key, and a real count. If the count comes back as two hundred with a truncated flag, that means the scan hit its bound, not that there are exactly two hundred.


Three things that have not changed. A network block we carry that happens to contain an address does not mean we hold the address — that is coverage, not detection, and the response says so with a separate field. Our edge telemetry is a different question answered by a different endpoint. And if the graph itself is unreachable, the response reports strength unknown rather than zero neighbors, because an instrument that cannot answer must never be allowed to look like an instrument answering no.


Any correlation figure we published before October 26, 2026 is suspect. If you built on one, re-query it. We have gone back through our own rules and documentation and dated the old behavior rather than quietly deleting it.



The accounting


This post makes 24 checkable factual claims. Twenty-three were verified against production or against the index directly before publishing; the twenty-fourth, the Integrity Tech designation, was checked against Treasury's own press release rather than taken from secondhand reporting.


Two of those numbers were wrong in draft and got corrected before publication rather than after. The per-subnet counts in the diagram were drafted from a partial sample, and four of the eight were invented to fill the gaps — so we went back and counted all eight; they sum to 134, which matches the total independently. The platform-domain share was estimated at "about 77%" from three suffixes and then measured properly across twenty-seven: 3,041 of 3,910, or 77.8%.


We cap ourselves at 95% on anything. Something in here is wrong, because something always is. When we find it we will say so at this same address, the way we have done twenty-three times before.


The uncomfortable part is not that correlation was broken. It is that it was broken confidently, for as long as it existed, and it took building a guard that deliberately asks whether a public resolver and a live threat look the same before anything noticed. That guard runs on every check now. It had been failing for days before we went and looked at what it was telling us.


Green is a claim. The artifact is the evidence.




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.



Was this useful? Thirty seconds, no cookies, no tracking, no third parties, your address hashed and never stored. If the box below does not load, the same question lives at https://analytics.dugganusa.com/nps.html?post=correlate-join-key-indicator-graph-october-2026



Comments

Rated 0 out of 5 stars.
No ratings yet

Add a rating
bottom of page