Iāve been querying the BitTorrent DHT for node IDs beginning with eight ASCII 8 bytes, a pattern documented in 360 Netlabās Mozi research. I wanted to see which endpoints kept coming back and whether abuse.ch had already associated any of them with Mozi.
Across 73 complete crawls, the export contains 10,001 matching IPv4 addresses, with a median of 1,469 per crawl. 279 also appear in ThreatFoxās Mozi payload-delivery records. Thatās enough to warrant a closer look, although an ID prefix and an IP match wonāt tell me whatās running on a device.
Querying the network
I bootstrap into Mainline DHT, send UDP find_node queries, and follow the contacts in the replies toward targets beginning 888, 888888, or 88888888. I fill the rest of each 20-byte target with random bytes. Two other search cohorts use random prefixes as controls.
Hereās the query builder, with the send path shortened. collector_node_id identifies my crawler; target_id is where Iām searching.
def build_find_node_query(transaction, collector_node_id, target_id):
if not transaction:
raise ValueError("transaction ID must not be empty")
if len(collector_node_id) != 20 or len(target_id) != 20:
raise ValueError("collector and target IDs must be 20 bytes")
return bencode({
b"a": {b"id": collector_node_id, b"target": target_id},
b"q": b"find_node",
b"t": transaction,
b"y": b"q",
})
transaction = os.urandom(4)
prefix = b"88888888"
target_id = prefix + os.urandom(20 - len(prefix))
payload = build_find_node_query(transaction, collector_node_id, target_id)
sock.sendto(payload, endpoint)
Before accepting a reply, I check its transaction and source IP:port against an outstanding query, require y=r, and check that r.id is exactly 20 bytes. The prefix has to be in the responderās own ID. Finding it in a contact that somebody else returned wouldnāt establish that the contact answered me. I save the UTC timestamp, IP:port, full ID, query target, latency and response hash.
The archived setup runs hourly at 100 packets per second for 120 seconds, with retries and time to drain replies. The first canary was shorter. Iām sending standard DHT queries here; I donāt request a Mozi configuration or issue commands.
I couldnāt measure IPv6 properly: the collector lacked complete nodes6 coverage and recorded unreachable IPv6 sends. These results cover IPv4.
Monitoring over time
I kept 165 two-minute crawls from 13ā20 September. Below are the individual counts and a view by UTC hour. Empty cells mean I donāt have a sample for that hour.
The old collector counted 3,754 to 5,072 matches per crawl using a four-byte prefix. It lacked Octoberās transaction, endpoint and exact ID-length checks, and I later changed the duration, vantage and search strategy. I canāt read the difference between September and October as a decline in the population.
The 73-crawl October summary gives me repeat sightings and churn. I still need its per-crawl timestamps and counts to plot that series.
Repeated sightings, high churn
Of the 10,001 matching IPs, 2,089 appeared in just one crawl. The other 7,912 came back at least twice. 3,298 appeared in at least ten crawls, 1,264 in at least 24, and at least one was present in all 73.
Even with those repeat sightings, median churn between consecutive crawls was 66.8%. I calculate it from the addresses found in only one of the two crawls, divided by their union:
# previous and current contain unique matching IPs from adjacent crawls.
retained = previous & current
new = current - previous
dropped = previous - current
union = previous | current
churn = len(new | dropped) / len(union) if union else 0.0
At the median, adjacent crawls shared 33.2% of their combined IP set. I wouldnāt treat the rest as devices going offline: the path my crawler takes, packet loss, reachability and address changes all affect what comes back.
IPs and node IDs do not stay paired
The eight-byte-prefix records contain 10,001 IPs, 14,869 IP:port endpoints, and 12,602 node IDs.
I found 2,993 IPs returning multiple node IDs, with one returning 15. It happened in the other direction too: 1,837 node IDs appeared at multiple IPs, and one appeared at six. 2,999 IPs answered on multiple UDP ports.
This is why I keep the full IP:port/ID tuple and timestamp. I want to distinguish an endpoint returning from an ID turning up at a different address. I canāt tell from these records alone whether NAT, reassignment or restarts caused the changes.
None passed BEP 42
The collector checked 222,708 eight-byte-prefix observations from complete crawls against BEP 42. None passed.
BEP 42 binds the leading bits of a node ID to the nodeās external IP using CRC32C. Fixing the first eight bytes to 3838383838383838 restricts the ID independently of that calculation; it will conflict with the IP-derived prefix for most addresses.
That doesnāt settle attribution. Legacy implementations and synthetic IDs can fail BEP 42 too.
What the controls returned
Each crawl also queried two random-prefix neighborhoods. Over the export, that gives 146 control cohorts.
| Search cohorts | Logical queries | Response rate | Target-matching observations |
|---|---|---|---|
| Mozi-prefix targets | 464,942 | 83.2% | 223,705 |
| Random-prefix controls | 103,615 | 76.1% | 29 |
Only 15 four-byte control matches were recorded. The control queries received replies, but rarely returned the target prefix.
One detail to watch in that table: the Mozi searches use three-, six-, and eight-byte targets. 223,705 includes matches across those target lengths; 222,708 observations meet the eight-byte threshold. I use the controls to check whether arbitrary neighborhoods return a similar pattern, rather than to estimate infections.
Fingerprinting with abuse.ch
I also checked what abuse.ch already knew about the addresses. I use ThreatFox for Mozi-associated IOCs and MalwareBazaar for sample metadata. The enrichment contains 1,014 ThreatFox Mozi IOC records and 378 MalwareBazaar metadata records. Matching the ThreatFox addresses against the eight-byte DHT set gave 279 IPs across 290 IOC rows.
For a URL IOC, I match the literal IP in its hostname. I donāt resolve a domain today and assume it points to the same host that served an older payload. Hereās the matching logic reduced to the relevant fields:
from ipaddress import ip_address
from urllib.parse import urlsplit
matches = []
for row in threatfox_rows:
if row["ioc_type"] != "url":
continue
try:
host_ip = str(ip_address(urlsplit(row["ioc"]).hostname))
except (ValueError, TypeError):
continue
if host_ip in dht_ips:
matches.append({
"ip": host_ip, "ioc_id": row["id"],
"role": row["threat_type"],
"confidence": row["confidence_level"],
"first_seen": row["first_seen"],
"last_seen": row.get("last_seen"),
})
All 290 matching rows describe URL/payload delivery with confidence 75, rather than botnet C2. That independently associates the addresses with Mozi delivery. It doesnāt tell me that the process answering my DHT query served the payload, or that both observations concern the same machine. Iād need to line up the dates and account for reassignment, NAT and forwarding before making that claim.
Of those overlapping IPs, 46 also occur in the fixed service-scan snapshot described below.
MalwareBazaarās hash metadata supplies file format, architecture and signature information. The retained records contain 190 ARM, 126 MIPS, one ARC Compact, and 61 with unknown architecture; 375 of 378 are ELF records. These describe the malware metadata set, without identifying the architecture of a DHT responder.
A small service snapshot
I also scanned the first crawlās 1,001-address three-byte-prefix set, which included 994 eight-byte matches. I havenāt scanned the later 10,001-address union. As with the IOC join, a service at the same public IP may belong to a different machine or process.
Of the 1,001 addresses, 211 exposed a port in the top-100 TCP scan. HTTPX obtained responses from 77 IPs. The Nmap follow-up retained 45 open service rows on 38 IPs, including 14 tcpwrapped rows without an application identification.
The banners gave me a few embedded software leads: Mini web server 1.0 ZTE corp 2005. on five IPs, micro_httpd on five, Boa 0.94.13 and 0.93.15 on two IPs each, and three BusyBox Telnet identifications. Dropbear appeared as versions 0.52 and 0.53.1. Thatās a small set of fingerprints to follow up, with too little coverage to characterize the full DHT set.
Nmap also identified Microsoft FTP on TCP/23. Iād have called that Telnet if Iād labelled the service from its port number.
Intel Export
The Mozi intel export is available on GitHub.
Collection notes
Service-scan coverage and retained banners
Service-scan detail
Masscan recorded 483 open IP:port pairs on the 1,001-address snapshot. HTTPX retained 107 responses, and PTR lookups returned records for 101 IPs. Nmap covered 170 IPs with non-web open pairs.
| Identification | Selected retained fingerprints |
|---|---|
| FTP | Microsoft ftpd; FileZilla 0.9.34 beta and 0.9.41 beta; D-Link/Comtrend firmware-update FTP |
| SSH | Dropbear 0.52 and 0.53.1; OpenSSH Linux and Windows builds |
| Telnet | BusyBox telnetd; Microsoft Windows XP telnetd |
| MySQL | 5.0.51b-community-nt-log; one additional unauthorized response |
| HTTP | ZTE mini web server; micro_httpd; Boa 0.94.13 and 0.93.15; ty_httpd |
| HTTP/UPnP header | SDK 4.2.0.0 UPnP/1.0 MiniUPnPd/1.6 |
The Nmap rows comprise 18 FTP, six SSH, four Telnet, two MySQL, one time service and 14 tcpwrapped. Product and version strings are reported fingerprints; they do not verify a device model or vulnerability.