The Fleet That Never Was: Eighty-Five Addresses That Cannot Exist, Counted as an Adversary
CORRECTION โ 2026-08-09, hours after first publication. Cleaning the corpus proved this letter wrong about how the ghosts got in, and I am correcting it rather than quietly amending it. Four changes, all downward for me: 1. The mechanism was forged
X-Forwarded-For, not traffic that never happened. This letter argued the requests were physically impossible because HTTP needs a completed handshake. That refutation is wrong. The sessions were real; the recorded source address was forged and written intoclient_ipat ingest. The letter explicitly considered the header-forgery hypothesis and dismissed it. The dismissal was the error. 2. The footprint was larger than reported โ 106 request rows and 105 IP records, not 85. The 85 was campaign membership, which is all a campaign-level audit can see. 3. The 2026-07-12 event carried four user-agent variants, not two โ GPTBot in two forms, plus a Chrome string and an AhrefsBot string. 4. The records were knowingly retained, not overlooked. The engineering record had already counted them at exactly 106 and left them deliberately under a compensating control, pending an explicit decision to purge. The corpus has since been purged โ see Resolution at the end. What survives unchanged: the records were fictional, the detector was right, and a confidence rating describes the reasoning rather than the data.
Letter D ended with the instrument turned outward.
We had found that our own blocklist was answering questions for an adversary โ that a 403 is a reply, that silence and refusal look identical from the inside, and that the fleet in Letter B had read our list one status code at a time. The lesson was about what our defences disclose.
This letter turns the same instrument around and points it at the census itself.
Because while auditing the campaign table for the next letter, the strongest-looking cluster in the corpus turned out to be composed entirely of addresses that cannot appear on the internet. Seventy-two of them, then thirteen more. Eighty-five in total. Every one reserved by RFC 5737 for use in documentation โ the network equivalent of the phone number 555-0100, which exists so that nobody's real telephone rings when it appears in a film.
The system had observed them, enriched them, clustered them, assigned them two autonomous system numbers, given the larger cluster a confidence rating of high, and filed them as a coordinated impersonation fleet.
The detector was not broken. That is the uncomfortable part, and it is the subject of this letter.
The shape of the thing
| Campaign | #260936 โ Fake-GPTBot Fleet AS394699 (72 IPs) |
|---|---|
| Confidence as filed | high |
| Detection method | spoofed_impostor_cluster |
| Member addresses | 72 |
| Requests | 72 โ exactly one per address |
| Window | 2026-07-12 11:06:17 โ 14:16:32 (3h 10m) |
| 404 rate | 0.000 |
| Routable members | 0 of 72 |
| Second campaign | #148054 โ Fake-GPTBot Fleet AS14061 (13 IPs), confidence medium |
| Distinct fictional addresses in campaigns | 85 |
| True footprint (corrected) | 106 requests, 105 IP records |
What the census counted
Campaign #260936 describes itself in the ordinary language of the corpus:
72 IPs in AS394699 impersonating openai/GPTBot โ provenance check failed (spoofed)
That sentence is true in every particular except the one that matters. The addresses did claim to be GPTBot. The provenance check did fail. They are not OpenAI. What the sentence cannot say is that there were no addresses.
| Range | Reserved as | Members |
|---|---|---|
192.0.2.0/24 | RFC 5737 TEST-NET-1 | 42 |
198.51.100.0/24 | RFC 5737 TEST-NET-2 | 15 |
203.0.113.0/24 | RFC 5737 TEST-NET-3 | 15 |
| routable public space | โ | 0 |
RFC 5737 assigns these blocks so that documentation, textbooks and test suites have addresses that can never collide with a real network. They are not routed and no operator announces them. No host in these ranges can be the true origin of a completed HTTP session โ which is what makes the records fictional. (As first published, this paragraph went further and claimed the requests therefore could not have happened at all. See the correction above: they happened, from a real client, wearing a forged address.)
Both campaigns were also assigned autonomous system numbers. AS394699 resolves to nothing in our enrichment corpus โ no registrant, no prefixes, no peering data, every field null. AS14061 is a real number allocated to DigitalOcean, which has no relationship to any of these addresses. One ASN is fictional outright; the other is fictional in a way that borrows a real company's name.
One ghost, up close
192.0.2.10 2026-07-12 12:13:09 GET www.shuffle-on.com /
UA: Mozilla/5.0 (compatible; GPTBot/1.2)
198.51.100.110 2026-07-12 12:56:44 GET www.shuffle-on.com /
UA: Mozilla/5.0 (compatible; GPTBot/1.2)
203.0.113.12 2026-07-12 11:06:17 GET www.shuffle-on.com /
UA: Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)
Each address made exactly one request, for /, on the same host.
The full event, recovered from the backup taken before the purge, is broader than the campaigns showed. Across 102 requests on 2026-07-12 there were four user-agent variants, not two:
| User agent | Requests |
|---|---|
Mozilla/5.0 (compatible; GPTBot/1.2) | 71 |
Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot) | 15 |
Mozilla/5.0 (Windows NT 10.0) Chrome/120 Safari | 13 |
Mozilla/5.0 (compatible; AhrefsBot/7.0) | 3 |
A verified crawler in both its documented forms, a browser, and a self-declaring crawler โ the exact quartet you would enumerate to exercise an identity classifier across its confidence tiers. Only the GPTBot rows clustered into campaigns, which is why the narrower view showed two.
Nothing here resembles crawling. A crawler that fetches your root document and never returns has not crawled you. And the 404 rate of 0.000 says the same from the other direction: every request found something, against a corpus baseline of 0.474 for burst clusters and 0.890 for credential-harvest fleets. A population that never guesses wrong is a population that was told what to ask for.
Where they actually came from
(This section is rewritten. The original attributed the records to test fixtures leaking into production at MODERATE confidence. The mechanism is now established at HIGH confidence and it is more specific.)
The estate's own engineering record already contained the answer:
106 requests with forged X-Forwarded-For across all three RFC 5737 ranges... They inflate IP counts and previously drove 85 pointless bans. Left in place deliberately; the
is_bannableguard neutralises their effect. Purge only on an explicit operator decision.
And, in a companion note tracing the causal chain from a header-forgery probe run against the landing nginx on 2026-07-31:
The probe did its job (it proved nginx pins real_ip to the trusted hop) but the forged XFF chain flowed on into Traefik ClientHost and through ingest into TEXT ip columns.
That is the whole mechanism. Traefik's ClientIP() matcher reads the TCP peer and cannot be fooled by a header โ which is precisely why Letter D recommended network matching over user-agent matching. But the access log's ClientHost honours X-Forwarded-For, and ingest consumes the log, not the matcher. Matcher and record can therefore disagree, and it is the record that reaches the corpus.
Four rows from 2026-07-31 survive as a demonstration of the mechanism running end to end:
203.0.113.66 GET 200 /robots.txt UA: lsn-xff-trust-probe/1
203.0.113.66 GET 200 /robots.txt UA: lsn-xff-verify-forged/2
203.0.113.77 GET 200 /robots.txt UA: lsn-pp-probe-host/4
203.0.113.88 GET 200 /robots.txt UA: lsn-final-verify/5
Real requests. Real responses. Fictional source addresses, forged in a header and recorded as fact.
So the sessions were genuine and locally originated; only the identity of their origin was invented. The letter's conclusion is unchanged โ these records describe hosts that do not exist โ but the reason is not impossibility. It is that the source-address field is caller-controllable and was never treated as such.
The detector was right
This is what makes it worth a letter rather than a bug report.
The spoofed-impostor detector performed exactly as designed: this client claims to be GPTBot; GPTBot has published provenance; verify the source against OpenAI's declared infrastructure; it does not match; therefore the claim is false.
Every step is correct. GPTBot genuinely does not originate from 192.0.2.0/24, and a client claiming otherwise genuinely is lying about its identity. Had the origin been real, the verdict would have been right and the high rating earned.
The detector was never asked whether the input was trustworthy. Provenance verification answers "is this claim consistent with the claimed operator's infrastructure?" โ a question about the claim. It does not answer "is this recorded origin the real origin?" โ a question about the record.
This is the same structural failure the earlier letters found in the adversary's tooling, arriving from the opposite direction. Letter B's fleet wore fifty-six identities because a user-agent string is a claim anyone can make. Letter E's ghosts were counted because a source address in our own store is also a claim โ made by a header, trusted by a log, promoted to fact by ingest.
A confidence score describes the quality of the reasoning. It says nothing about the quality of the data. Ours said high, and it was telling the truth about itself.
What the fiction did
Fictional data is harmless right up until something acts on it. Something did: on 2026-08-05 the autoban job wrote MikroTik bans for 85 unroutable hosts. The practical harm was near nil โ you cannot meaningfully block an address that cannot route โ but the mechanism ran end to end with no human in the path, and the error was caught because an operator read the ban list and recognised the ranges, not because a check fired.
Commit 2491f4afe7 โ fix(autoban): honour the operator's per-rule thresholds; skip non-routable IPs โ closed that gap, and the guard holds. Across all 85 addresses at the time of writing: 0 banned on MikroTik, 0 active CrowdSec decisions.
The guard proved itself while this dossier was being compiled. An attempt to record four ghost addresses as infrastructure returned "Added 5 infrastructure records" โ the five being two routable IPs and three ASNs. Retrying 192.0.2.10 alone returned "Added 0 infrastructure records." The platform that filed these as a high-confidence campaign in July now refuses to let an analyst enter one in August. It also refuses silently, with no error naming what was rejected โ the same species of quiet failure that let the original records sit unexamined.
What was known, and for how long
(Rewritten. The original argued nobody had gone back for the records. That was unfair.)
The synthetic traffic had been identified, counted precisely at 106 requests, characterised by user-agent, and deliberately retained โ with a compensating control (is_bannable) and an explicit standing instruction that purging required an operator decision.
That is a reasoned decision under a known risk, not an oversight, and this letter's first edition mischaracterised it.
The general principle still holds and is worth keeping: when bad data drives a bad action, the action is conspicuous and gets fixed, while the record is invisible and does not. Any correction of this kind needs a second question โ what does the system now believe, and does it still believe it? Here the answer was known. It simply had not yet been acted on, because acting on it was correctly reserved for a human.
How much of the census was fiction
| Campaigns enumerated | 252 |
|---|---|
| Composed entirely of non-routable members | 2 |
| Partially contaminated | 0 |
| Fictional addresses in campaigns | 85 (3.3% of 2,576 campaign IPs) |
| Request rows (corrected) | 106 |
| IP records (corrected) | 105 |
The boundary was clean: no campaign mixed fictional addresses with genuine ones, so no other campaign's membership, geography or ASN distribution needed re-deriving.
But the corrected figures carry their own lesson. The audit ran at campaign level, and campaigns are a derived artefact โ so it measured only contamination that happened to cluster. Twenty IP records never clustered and were invisible to it. Audit the base records, not the things built from them.
Steelmanning the boring explanation
(Retained as first published, with its verdict reversed where it was wrong.)
Could these be real packets with forged source addresses at the IP layer? No โ and this remains correct. Source spoofing appears in UDP reflection attacks, which are one-way. HTTP needs a completed handshake, and a host forging 192.0.2.10 at the IP layer never receives the SYN-ACK.
Could a misconfigured proxy have rewritten the client address? This was the right answer and the first edition rejected it. The original text conceded it "explains the addresses but not the shape" and dismissed it on that basis. The shape reasoning was sound but the conclusion was not: forgery at the header layer, not the IP layer, is exactly what happened, and the shape is explained independently by the traffic being a local classifier exercise. Both were true at once. I weighted one piece of evidence โ shape โ heavily enough to discard a mechanism I had not tested, and the estate's own records already contained the test.
A legitimate crawler on a misconfigured network? No. Nothing puts a production crawler on TEST-NET-1, and OpenAI's real crawler is observable in the same corpus doing something entirely different.
A rendering artefact โ real IPs anonymised for display? No. Other campaigns return ordinary routable addresses in the same responses, and the reserved addresses appear consistently across different endpoints.
What the real thing looks like
Of 319 addresses that have claimed GPTBot, those surviving provenance verification look like this:
| Address | Requests | ASN | Confidence |
|---|---|---|---|
74.7.227.159 | 2,059 | AS8075 Microsoft | verified |
74.7.227.23 | 1,170 | AS8075 Microsoft | verified |
74.7.227.55 | 305 | AS8075 Microsoft | verified |
74.7.242.46 | 292 | AS8075 Microsoft | verified |
74.7.242.19 | 222 | AS8075 Microsoft | verified |
OpenAI runs its crawler on Azure, which is why real GPTBot arrives from AS8075. One genuine address made 2,059 requests across 2,052 distinct paths over sixteen days.
| Ghost fleet | Real GPTBot | |
|---|---|---|
| Requests per address | 1.0 | up to 2,059 |
| Distinct paths | 1 | 2,052 |
| Recorded source | reserved documentation ranges | AS8075 Microsoft / Azure |
| Provenance check | failed | passed |
| Duration | 3 hours, once | 16 days, continuous |
The identity claim was byte-for-byte identical in both cases. Everything separating them is behaviour and origin โ the thesis this series has circled since Letter B. You cannot tell who someone is by asking them. You can only tell by watching what they do and verifying where they came from.
What confidence is for
A rating of HIGH means the reasoning chain is sound, the evidence is multi-sourced, and alternatives have been tested and found wanting. It is a statement about the argument.
It is not a statement that the underlying observations are real. Campaign #260936 is the purest demonstration โ an impeccable argument, correctly executed, rated high, about eighty-five hosts that never existed.
And this letter's own correction is the second demonstration, one layer up. I rated a mechanism at HIGH because the reasoning was clean, without checking whether the estate's engineering record already contained a tested answer. It did. The failure mode I was documenting is the one I then committed: confident reasoning over unverified inputs.
Which suggests the check that belongs upstream of all the others: before asking what a record means, ask whether the record is a record. Is this address routable? Is the field it came from caller-controllable? Could this observation have originated where it claims? These are cheap questions, and they are the only defence against reasoning beautifully about nothing.
Resolution
On 2026-08-09 the operator gave the explicit authorisation the standing instruction required, and the corpus was purged.
All affected rows were backed up to /home/lsn/backups/web-threats-ghost-purge/ as verified CSV, then deleted in a single transaction:
| Table | Deleted |
|---|---|
campaign_members | 85 |
campaigns | 2 |
requests | 106 |
ip_intel | 105 |
Verification after the purge: 0 RFC 5737 rows remain in any of those tables; totals moved exactly as predicted (campaigns 260 โ 258, members 3,572 โ 3,487, requests 279,322 โ 279,216, ip_intel 26,561 โ 26,456); 0 orphaned campaign members; 0 non-routable members remain anywhere in campaign_members across every reserved range tested.
CrowdSec's 45 active decisions on 192.0.2.0/24 were deliberately preserved. They are CIDR-level bogon blocking imported via cscli under scenario threat-intel-feed โ legitimate defence that never targeted the individual addresses, and a good idea independent of this incident.
One item stays open, and it is the durable one: move the routability check upstream to ingest, so a single test protects every consumer instead of autoban and the dossier infrastructure path each carrying their own copy โ and mark synthetic data as synthetic at creation, so nothing downstream has to infer its nature from the shape of an address.
Confidence: HIGH โ that the records were fictional and describe hosts that do not exist. All members fall in RFC 5737 reserved ranges; one request per address; identical path; 0.000 404 rate; a three-hour containment window.
Confidence: HIGH (revised upward, mechanism corrected) โ that they entered via forged X-Forwarded-For written into client_ip at ingest. Documented in the estate's engineering record, with the 2026-07-31 probe rows demonstrating the path end to end. The first edition of this letter said MODERATE and named the wrong mechanism.
Confidence: HIGH โ that contamination was bounded: 2 of 252 campaigns, 0 partially contaminated, 106 requests and 105 IP records in total.
Verification. The records described here have been deleted; the backup at /home/lsn/backups/web-threats-ghost-purge/ is the archival copy. Reproduce the reasoning against any live corpus by testing client_ip in the requests table against 192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24, and by comparing ClientIP() matcher behaviour with access-log ClientHost under a forged X-Forwarded-For.