Your Machines Are Still Calling Domains Nobody Owns
Popcorn Time’s abandoned updater pinged unregistered domains from millions of PCs. Here’s how to find the same ghost in your own fleet before someone buys it.
Somewhere on a laptop that hasn’t streamed a pirated movie since 2016, a small C++ program wakes up as SYSTEM. It builds a GET request, stamps a hardware ID on it, and sends it over plain HTTP to a domain that expired years ago. Nobody answers. It tries again tomorrow.
Multiply that by a few million machines and you have a botnet with no operator. All it’s waiting for is the first person willing to pay a registrar about ten dollars.
Eye Security’s research team turned out to be that person, and they did the decent thing with it. Their writeup, Operation IXIM, presented at hack.lu on October 22 and picked up across the security press in the last week, is the cleanest public case study I’ve seen of a bug class most defenders never hunt for: residual trust in dead infrastructure. Your software estate almost certainly has some. The good news is that finding it takes an afternoon and tools you already own.
What Eye Actually Found
The story starts boring, which is how the good ones always start. During a threat hunt in late 2025, Eye flagged an Updater.exe on a client laptop that was reaching for a lapsed domain plus a few others VirusTotal already considered malicious. It belonged to Time4Popcorn, one of the Popcorn Time forks. None of the clients who had it were using it.
So Eye registered the domain and listened. Roughly 50 requests per second came in. Over six months they counted more than 3.8 million hardware IDs, which they later revised downward because the IDs were unstable: an estimated 2.5 to 3.0 million Windows hosts and 250,000 to 350,000 Macs.
The updater itself is a museum of things you shouldn’t ship:
- On Windows, a C++ binary built on Boost 1.55 fetched a JSON manifest, downloaded an executable and ran it as
NT AUTHORITY\SYSTEM. No code signing. Plain HTTP. - On macOS, a launch daemon called
PT.updddid the same thing as root, with obfuscated strings to make it harder to read. - The Windows hardware ID was an MD5 over the volume serial, the volume GUID and the MachineGuid, except a reversed error check meant MachineGuid effectively dropped out. Cloned corporate images produced identical IDs.
A request in their logs looked like this:
GET /?app_id=T4PSEC&hid=...&ver=6.1.0.0&os=WIN060200Whoever answered that request owned the box. Eye answered with an update whose only job was to disable the updater: stop the Windows service, unload the macOS daemon. They piloted it small, served it once per previously seen ID at first, and had legal sign off. More than a million systems took it by December 2025. In late March 2026 they handed their 15 domains to the Shadowserver Foundation, and the bug now carries CVE-2026–30612.
The detail I keep coming back to: their disabling update got flagged by 11 VirusTotal engines, and they had to rework it until only one complained. The malicious version of this story would have been quieter.
The software was abandoned. The trust never got the memo.
Why This Bug Class Lives Everywhere
Every program that phones home carries a hardcoded hostname, and that hostname is a promise the vendor has to keep renewing every year forever. Companies get acquired. Side projects lose their maintainer. A dev forgets to auto-renew a domain on a personal card that expired. The binary keeps calling.
Updaters are the worst case because they execute what they receive. Telemetry endpoints, license checks, plugin indexes and remote config URLs are a step down, but any of them can still hand an attacker a beacon of who runs what, or a parser to feed malformed input. Eye found the main Popcorn Time app was its own problem: a 2014-era Chromium build with the sandbox off, pulling JavaScript over HTTP from abandoned domains. They got calc.exe to pop through it.
Your EDR is unlikely to flag any of this. The process is signed by nobody, sure, but it has run quietly for years, its behavior baseline is “calls a domain once a day,” and the domain was benign right up until the moment ownership changed. Detection built on reputation is detection built on the past.
So you hunt it the other way around: start from what your machines are asking for, and check whether anyone is still on the other end.
Hunt One: Listen for NXDOMAIN
An unregistered domain announces itself in DNS. Resolvers return NXDOMAIN, and most networks log every one of those and throw the logs away unread.
If you run Zeek on a span port, dns.log already has what you need. Pull the queries that failed with NXDOMAIN, count them, and sort:
$ cat dns.*.log | zeek-cut query rcode_name \
| awk '$2 == "NXDOMAIN" {print $1}' \
| sort | uniq -c | sort -rn | head -50A home lab version of this is cheap. A Raspberry Pi 5 [AFFILIATE: Raspberry Pi 5 8GB] running Zeek behind a switch with port mirroring, such as the Netgear GS308E [AFFILIATE: Netgear GS308E], sees every DNS query on the segment without touching the endpoints.
Pi-hole users can skip the capture entirely. Pi-hole v6 keeps query history in its FTL database, and the reply_type column records how upstream answered:
$ sudo pihole-FTL sqlite3 /etc/pihole/pihole-FTL.db \
"SELECT domain, COUNT(*) AS hits FROM queries
WHERE reply_type = 2 -- 2 = NXDOMAIN in FTL's schema
GROUP BY domain ORDER BY hits DESC LIMIT 50;"Expect noise. Chrome fires random-label probes at startup to detect DNS hijacking, search suffixes get appended to bare hostnames, and typos happen. What you’re looking for is the shape the Popcorn updater had: the same registrable domain, from the same set of hosts, on a schedule, for weeks. Periodicity is the tell. Humans don’t mistype the same domain at 03:00 every night.
Get Aeon Flex, Elriel Assoc. 2133 [NEON MAXIMA]’s stories in your inbox
Join Medium for free to get updates from this writer.
Strip results down to the registrable domain before you go further. update.forgotten-vendor.net and cdn.forgotten-vendor.net are one finding, and the base domain is the thing someone can buy.
Hunt Two: Read the Binaries That Run Unattended
Network logs only show you software that has run recently. The scarier stuff sits dormant until a scheduled task wakes it. So go to the endpoints and inventory everything that starts without a human asking.
On Windows, list services with their binary paths and check whether each one is signed:
Get-CimInstance Win32_Service |
Where-Object { $_.PathName } |
ForEach-Object {
# strip quotes and arguments to isolate the exe path
$exe = ($_.PathName -replace '^"([^"]+)".*', '$1') -replace '^(\S+\.exe).*', '$1'
$sig = Get-AuthenticodeSignature -FilePath $exe -ErrorAction SilentlyContinue
[pscustomobject]@{
Service = $_.Name
Start = $_.StartMode
Status = $sig.Status
Path = $exe
}
} |
Where-Object { $_.Status -ne 'Valid' } |
Format-Table -AutoSizeAnything unsigned that runs as LocalSystem and has “update” anywhere in its name or path goes on the short list. Repeat for scheduled tasks with Get-ScheduledTask and their Actions.Execute paths.
On macOS, launch daemons are the equivalent. List them, read where they point, and check the signature:
$ ls /Library/LaunchDaemons /Library/LaunchAgents ~/Library/LaunchAgents
$ plutil -p /Library/LaunchDaemons/PT.updd.plist # read ProgramArguments
$ codesign -dv --verbose=2 /path/from/ProgramArguments
$ spctl --assess --type execute -v /path/from/ProgramArgumentsThen pull the hostnames out of each suspect binary. Plain strings handles most of them:
$ strings -n 6 Updater.exe \
| grep -Eio '([a-z0-9-]+\.)+[a-z]{2,24}' \
| grep -viE '\.(dll|exe|so|dylib|json|xml|txt)$' \
| sort -uWhen the strings are obfuscated, as they were in PT.updd, Mandiant's FLOSS recovers stack strings and decoded strings that strings misses. Run floss against the binary and grep its output the same way.
You end up with a list of domains your machines are prepared to trust. Most will be alive and owned by the people you expect. The point of the exercise is the handful that aren’t.
Hunt Three: Ask the Registry Who Owns Them Now
WHOIS output is a mess of free text. RDAP is the structured replacement, and the rdap.org bootstrap service redirects each query to the right registry. A domain the registry has never heard of usually comes back 404.
#!/usr/bin/env python3
"""deadcheck.py: flag domains that are unregistered or recently re-registered."""
import sys
from datetime import datetime, timezone
import requests
STALE_AFTER_DAYS = 365 # re-registered within a year of today is suspicious
def check(domain: str) -> str:
r = requests.get(f"https://rdap.org/domain/{domain}", timeout=15)
if r.status_code == 404:
return "UNREGISTERED (or TLD has no RDAP: confirm with whois)"
r.raise_for_status()
events = {e["eventAction"]: e["eventDate"] for e in r.json().get("events", [])}
reg = events.get("registration")
if not reg:
return "registered, no registration date published"
born = datetime.fromisoformat(reg.replace("Z", "+00:00"))
age = (datetime.now(timezone.utc) - born).days
flag = " <-- NEW OWNER?" if age < STALE_AFTER_DAYS else ""
return f"registered {born.date()} ({age} days){flag}"
for line in sys.stdin:
d = line.strip().lower()
if d:
print(f"{d:40} {check(d)}")
$ sort -u domains.txt | python3 deadcheck.pyThere are two outcomes you care about. An unregistered domain is the Popcorn Time scenario waiting for its first buyer. A registration date newer than the software that calls it is worse, because someone already bought it, and you need to know who and why before you assume they’re a researcher like Eye.
Two gotchas. Some country-code TLDs don’t run RDAP at all, so a 404 on those is a cue to run whois before you conclude anything. And a domain that resolves to a parking page is still owned by somebody. Treat parked domains as live, hostile until proven otherwise, and block them at the resolver while you sort out the software.
Remediation is mostly unglamorous. Uninstall the abandoned thing. If you can’t, sinkhole the domain internally with a resolver override, disable the update service, and file it as a known risk. If it’s software your own team shipped years ago, buy the domain back yourself. Ten dollars a year is the cheapest incident response you will ever do.
What a Trustworthy Updater Looks Like
The Popcorn updater failed in the most expensive way possible: it trusted the network path instead of the payload. HTTPS alone wouldn’t have saved it, because whoever owns the domain can get a valid certificate in about ninety seconds. Domain ownership is transferable. Keys are what you keep.
The fix is old and boring. Bake a public key into the client, sign every manifest and binary with the private half, and refuse anything that doesn’t verify, no matter which server delivered it. Then a lapsed domain degrades into a denial of service instead of remote code execution.
I built the same rule into ghost_net, my experimental gossip network. Each node generates an Ed25519 keypair on first start, its ID is derived from the SHA-256 of the public key, and every peer verifies the ID, the signature and the key-to-author match before it stores or relays anything. Peers prove key ownership during the handshake by signing a random nonce, and nodes that keep sending invalid transmissions get dropped. It’s a different problem from software updates, but the trust model is the one the updater lacked: the message carries its own proof, and the transport is just plumbing. Spin up two nodes and watch them handshake:
$ git clone https://github.com/numbpill3d/ghost_net.git
$ cd ghost_net && npm install && npm start
$ PORT=3001 DATA_DIR=_void2 BOOTSTRAP_NODES=ws://localhost:3000/peer npm startnpm test runs the multi-node tests if you want to see rejection paths without a browser. Fair warning from the README: transmissions are public and unencrypted beyond whatever TLS you add, and peers can see each other's IPs. Signing proves who sent a message. It hides nothing.
Make the Hunt Recurring
A one-time sweep finds today’s ghosts. Domains lapse on their own schedule, so the hunt is only worth much if it runs again next month without you remembering to do it.
The three hunts chain cleanly: NXDOMAIN counts and binary strings feed a domain list, the domain list feeds deadcheck.py, and anything flagged lands somewhere a human reads. I keep findings like this in a notes vault with a template per hunt, so the history of "this domain was fine in June, re-registered in October" survives longer than my memory. If you want that structure prebuilt, the Obsidian + Claude Daily Ops System ships recon and security research templates plus Python scripts for the intel pipeline side of it.
The scheduling half is a good job for a persistent agent: run the sweep, diff it against last week, write a short brief only when something changed. That’s close to the daily brief agent built step by step in the OpenClaw + Claude Code persistent agent playbook , and swapping its sources for your DNS logs and RDAP output is an evening of work. Keep the agent read-only. Something that hunts for trust problems shouldn’t be the next one.
If Popcorn Time is still installed anywhere you’re responsible for, start there. Eye’s advice is simply to uninstall it, and they publish 20 IOC domains plus hashes for Updater.exe and PT.updd that are worth dropping into your blocklists today. Then pull a week of NXDOMAINs and read the top fifty. Something in there has been calling a dead number for years, patiently, the way abandoned machines do.
![Aeon Flex, Elriel Assoc. 2133 [NEON MAXIMA]](https://webproxy.poorya-velaei-d67.workers.dev/https://miro.medium.com/v2/resize:fill:128:128/1*ewo3-1c0qY57bCDWa3tdUA.png)







