Skip to content
Go back

Field Notes

Mozi like DHT Identities: Persistence, Churn and IOC Overlap

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.

September DHT monitoring: timestamped counts and a daily UTC-hour heatmap

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.

Number of eight-byte-prefix IPs seen in at least one, two, ten, or 24 crawls

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.

Distinct IPs, UDP endpoints and node IDs across the 73 complete crawls

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 cohortsLogical queriesResponse rateTarget-matching observations
Mozi-prefix targets464,94283.2%223,705
Random-prefix controls103,61576.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.

IdentificationSelected retained fingerprints
FTPMicrosoft ftpd; FileZilla 0.9.34 beta and 0.9.41 beta; D-Link/Comtrend firmware-update FTP
SSHDropbear 0.52 and 0.53.1; OpenSSH Linux and Windows builds
TelnetBusyBox telnetd; Microsoft Windows XP telnetd
MySQL5.0.51b-community-nt-log; one additional unauthorized response
HTTPZTE mini web server; micro_httpd; Boa 0.94.13 and 0.93.15; ty_httpd
HTTP/UPnP headerSDK 4.2.0.0 UPnP/1.0 MiniUPnPd/1.6

Nmap service rows in the fixed October scan snapshot

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.