
Cling's STUN Disguise Is the Detection Signal
Cling's STUN Disguise Is the Detection Signal
Two research teams published on the same botnet four days apart, under two different names. Nozomi Networks Labs called theirs Cling on 1 October; Fortinet's FortiGuard Labs called theirs ClingSTUN on 5 October, a back-connect proxy backdoor that turns compromised devices into remotely controlled proxy nodes. Neither examined the other's sample.
The naming collision is the least interesting part. The useful finding is that this botnet's camouflage is built out of a protocol violation, and that violation is cheap to detect.
One botnet, two reports, strong overlap
The teams worked from different architectures — Nozomi analysed a MIPS sample, Fortinet an x86-64 one — and disagree in places. But they converge on artefacts, and those artefacts are not generic:
- Both name the same persistence triple: copies at
/root/.clingand/usr/local/bin/.cling, appended to/etc/inittab,/etc/init.d/rcSand/etc/rc.d/rc.boot. - Nozomi's list of 13 hardcoded STUN servers is a strict subset of Fortinet's 24. Every address matches.
- Their self-propagation lists agree on six of seven CVEs: Nozomi lists the Eir D1000 router bug CVE-2016-10372, Fortinet the KGUARD DVR bug CVE-2026-87827.
That bind-mount trick shows up twice: in prose in Fortinet's analysis, and as the literal $s4 = "mount --bind /tmp /proc/%d" in Nozomi's YARA rule. Searching IoCs or YARA collections? Use both names.
The front door keeps moving
Nozomi saw a spike in attempts against CVE-2021-35394, the critical RCE in the Realtek Jungle SDK diagnostic component usually compiled as UDPServer, from around 5 September 2026. The exploit is crude: UDP datagrams beginning orf; followed by shell commands, then BusyBox wget, chmod, and run with a replication tag like realtek.selfrep.
That CVE has sat in CISA's Known Exploited Vulnerabilities catalog since 10 December 2021, with a 24 December deadline. Nearly five years later it is still the door being knocked on. SDK 2.x up to 3.4.14B is affected, and it ships in routers and access points abandoned by firmware updates a decade ago.
The part that should worry you more: Fortinet's first sighting did not come through Realtek at all. It came through CVE-2022-36553, a command injection in Hytec Inter HWL-2511-SS routers, from 124[.]163[.]212[.]119 — and that CVE is not in KEV. A second phase abused EnGenius (CVE-2025-34035) and D-Link UPnP (CVE-2024-23625), and a third hit a third host. Their entry points span Linear, D-Link, Realtek, TP-Link, Sunhillo, Ivanti, Tenda, Hytec, AVTECH, EnGenius, Lantronix and MeiG.
One caution when diffing these reports: the most recent download host is printed as 118.45.196[.]225 by Nozomi and 118.145.196[.]225 by Fortinet. Those are different addresses — do not silently merge them.
Patching the Realtek CVE is worth doing — it is the same exposure problem as the RouterOS pre-auth RCE covered here recently. But the entry point is a moving target on top of that problem. The durable fix is inventory plus exposure reduction, not a queue of CVEs.
The disguise, in four steps
STUN is defined in RFC 8489. It lets a host behind NAT learn its own public IP and mapped port, and shows up everywhere — WebRTC, ICE, TURN, SIP, Teams, Zoom. That ubiquity is why Nozomi calls the abuse of it effective.
Nozomi's sample does this:
- Every ~5 seconds, send a STUN
Binding Requestto 13 hardcoded servers. The transaction ID is all zeros instead of the random value the spec requires. - Each server answers with a
Binding Success Responsecarrying the public IP and the port it observed. - The bot sends a custom UDP datagram to every one of those servers containing its infection tag (its own
argv[1], orunknown) plus every port it just learned. Conforming servers drop it — none of the 13 responded. - The bot waits for UDP packets on the mapped ports. Operator commands arrive with the command encoded in the 12-byte STUN transaction ID field.
Fortinet's x86-64 variant sends standard 20-byte binding requests but fans out to 24 endpoints, 13 in its third evolution. They report no separate coordination server, and say explicitly that how the operator obtains the mapping and delivers control traffic "remains unverified". Two teams, two architectures, one mechanism left half-open — plan for both detection paths.
Why the disguise gives it away
- The all-zero transaction ID violates the spec.
- A short non-STUN datagram arriving at a STUN port is not NAT traversal.
- No legitimate ICE session re-negotiates against 13 different STUN servers every five seconds.
Nozomi turned that into an attribution method: send controlled Binding Requests to all 13 and look for misbehaviour. A conforming server echoes your transaction ID back. One — 145.249.115[.]184 — replied all-zero regardless of what was asked.
Then they confirmed it. A client pretended to be an infected host, sent registration messages to each server, but reported disjoint sets of ports to each. If that server belonged to the operator, it would have to contact a port nobody else was told about. Several hours later, one of the ports advertised only to 145.249.115[.]184 received commands, the first instructing the bot to use that host as a loader.
It is a reusable technique: misbehaviour is a fingerprint, and a controlled probe can confirms a hypothesis without touching production hosts.
#!/usr/bin/env python3
"""cling_stun_hunt.py - flag Cling/ClingSTUN STUN-C2 anomalies in a pcap.
Pure stdlib (Ethernet + IPv4 + UDP). Read-only.
Usage:
python3 cling_stun_hunt.py capture.pcap [--fanout 8] [--window 60]
Exit codes: 0 = nothing flagged, 1 = findings, 2 = bad input.
"""
import argparse
import struct
import sys
from collections import defaultdict
STUN_MAGIC = 0x2112A442
BINDING_REQUEST = 0x0001
BINDING_SUCCESS = 0x0101
STUN_PORTS = {3478, 19302, 5349}
# Union of the endpoints printed by Nozomi Networks (2026-10-01, 13 servers)
# and Fortinet FortiGuard Labs (2026-10-05, 24 endpoints). Nozomi's 13 are a
# strict subset of Fortinet's 24, which is the strongest public hint the two
# reports describe the same cluster.
CLING_STUN_SERVERS = {
"5.39.72.109", "20.14.234.56", "64.131.63.217", "66.51.128.1",
"74.125.250.129", "77.72.169.210", "77.72.169.211", "77.72.169.212",
"77.72.169.213", "81.187.30.115", "82.113.193.63", "83.211.9.232",
"85.17.88.164", "85.93.219.114", "139.162.62.29", "145.249.115.184",
"154.73.34.8", "185.125.180.70", "207.38.82.134", "212.53.40.43",
"212.227.67.33", "212.227.67.34", "216.93.246.18", "217.0.0.249",
}
def ipv4(raw):
return ".".join(str(b) for b in raw)
def udp_packets(blob):
"""Yield (ts, src, dst, sport, dport, payload) for Ethernet IPv4/UDP."""
magic, = struct.unpack("<I", blob[:4])
endian, nano = ("<", False) if magic == 0xA1B2C3D4 else (">", True)
off = 24
while off + 16 <= len(blob):
sec, frac, caplen, _wire = struct.unpack(endian + "IIII", blob[off:off + 16])
off += 16
pkt = blob[off:off + caplen]
off += caplen
if len(pkt) < 42 or struct.unpack("!H", pkt[12:14])[0] != 0x0800:
continue
ihl = (pkt[14] & 0x0F) * 4
if pkt[14] >> 4 != 4 or pkt[23] != 17:
continue
src, dst = ipv4(pkt[26:30]), ipv4(pkt[30:34])
u = 14 + ihl
sport, dport, ulen = struct.unpack("!HHH", pkt[u:u + 6])
ts = sec + frac / (1e9 if nano else 1e6)
yield ts, src, dst, sport, dport, pkt[u + 8:u + 8 + max(0, ulen - 8)]
def stun_shape(p):
"""Return (message_type, txn_id) if p looks like STUN per RFC 8489."""
if len(p) < 20 or p[0] & 0xC0 or struct.unpack("!I", p[4:8])[0] != STUN_MAGIC:
return None
return struct.unpack("!H", p[0:2])[0], p[8:20]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pcap")
ap.add_argument("--fanout", type=int, default=8,
help="distinct listed STUN servers per host before flagging")
ap.add_argument("--window", type=float, default=60.0,
help="seconds; Nozomi measured Cling beaconing every ~5s")
args = ap.parse_args()
try:
blob = open(args.pcap, "rb").read()
except OSError as exc:
print("error: %s" % exc, file=sys.stderr)
return 2
findings = []
seen_txn = {} # (src, dst, sport, dport) -> txn id last requested
contacted = defaultdict(set)
decoded = 0
for ts, src, dst, sport, dport, payload in udp_packets(blob):
decoded += 1
shape = stun_shape(payload)
if shape is None:
# Cling step 3: a custom, non-STUN datagram sent to a real STUN
# endpoint. Registration messages are short; anything longer is
# still worth a look.
if dport in STUN_PORTS or dst in CLING_STUN_SERVERS:
findings.append((ts, "NONSTUN_TO_STUN", src,
"non-STUN %dB to %s:%d" % (len(payload), dst, dport)))
continue
mtype, txn = shape
if mtype == BINDING_REQUEST:
seen_txn[(src, dst, sport, dport)] = txn
if txn == b"\x00" * 12:
findings.append((ts, "ZERO_TXN_REQUEST", src,
"Binding Request to %s:%d with all-zero transaction ID"
% (dst, dport)))
if dst in CLING_STUN_SERVERS:
contacted[src].add(dst)
elif mtype == BINDING_SUCCESS:
# Nozomi fingerprint: a conforming server echoes the request's
# transaction ID. One of the 13 replied all-zero regardless.
want = seen_txn.get((dst, src, dport, sport))
if want and want != txn:
findings.append((ts, "TXN_ECHO_MISMATCH", src,
"response from %s echoed %s, expected %s"
% (dst, txn.hex() or "<all-zero>",
want.hex())))
for host, servers in sorted(contacted.items()):
if len(servers) >= args.fanout:
findings.append((None, "STUN_FANOUT", host,
"%d listed STUN servers within %.0fs"
% (len(servers), args.window)))
if not findings:
print("no Cling/ClingSTUN STUN anomalies in %s (%d UDP packets)"
% (args.pcap, decoded))
return 0
findings.sort(key=lambda f: (f[0] is None, f[0] or 0))
for ts, kind, src, detail in findings:
stamp = " " if ts is None else "%8.3f" % ts
print("%s %-18s %-15s %s" % (stamp, kind, src, detail))
print("\n%d finding(s) in %d decoded UDP packet(s)" % (len(findings), decoded))
return 1
if __name__ == "__main__":
sys.exit(main())
Test it against a synthetic capture first: one fixture covers all four rules, plus a clean VoIP host that must yield zero findings.
# clean baseline: correct transaction IDs, echoing server, no fan-out
# infected host: 13 all-zero-txn requests, non-STUN registration datagram
# probe: random txn request to 145.249.115.184, all-zero reply
python3 cling_stun_hunt.py fixture.pcap ; echo "exit=$?"
# -> 13x ZERO_TXN_REQUEST, 1x NONSTUN_TO_STUN, 1x TXN_ECHO_MISMATCH,
# 1x STUN_FANOUT, and the clean host contributes nothing. exit=1
One caveat both vendors underline: do not blanket-block these STUN services. Most are legitimate public infrastructure your own conference calls depend on; Fortinet explicitly advises against treating third-party STUN as attacker-controlled. Alert on the protocol anomalies; treat the destination list as context, not a verdict.
The operator borrows your trust, not just your allowlist
The part that should keep you up: the packets carrying operator commands appeared to originate from 74.125.250[.]129 — the address stun.l.google.com resolves to. Nozomi's reasoning: no legitimate way to make a STUN server relay a specific transaction ID, UDP is unidirectional, and the IP TTL differs consistently between genuine STUN responses and command-bearing ones. Their most likely explanation: the operator sits in an ASN that does not perform source address validation properly, letting them spoof the source. That is exactly the gap RFC 2827 — Network Ingress Filtering, BCP 38 — was written to close.
The attack is not on your firewall, it is on your judgement. That host just spent five seconds legitimately talking to that exact IP, so the packet behind it arrives pre-vetted by context. "More traffic from Google STUN, this device already uses STUN" is the reasoning that lets a command through.
It is the same borrowed-credibility trick as a placeholder domain sitting in 1,700 repositories, which is why reputation-based triage fails on both. The Carbonato Docker botnet reached the same outcome differently: hosts turned into proxy nodes anyway.
The eight commands the transaction ID can encode are mundane: download and system(3) a payload, scan and exploit IPv4 space, stop the scanner, start or stop a TCP tunnel or proxy relay, and flood a target for a set duration. Nozomi observed self-propagation plus floods against a South Korean ISP endpoint, a University of Chicago cluster on port 53, and two Minecraft servers on 25565. A device sold as a webcam is now a proxy or a DDoS node — that is the whole loss.
Check the device, not just the wire
If a host trips any of those rules, the network evidence only tells you it is compromised. These artefacts confirm it cheaply and survive reboot:
#!/usr/bin/env bash
# cling_triage.sh - read-only hunt for Cling/ClingSTUN artefacts on a device.
# Usage: sudo ./cling_triage.sh [rootdir] (default rootdir = /)
set -u
ROOT="${1:-/}"
R() { printf '%s' "$ROOT$1"; }
hits=0
hit() { printf 'HIT %-14s %s\n' "$1" "$2"; hits=$((hits + 1)); }
echo "== 1. malware copies =="
for p in /root/.cling /usr/local/bin/.cling; do
[ -e "$(R "$p")" ] && hit "copy" "$p"
done
echo "== 2. replaced wget =="
for d in /bin /usr/bin /usr/local/bin /sbin /usr/sbin; do
for s in wget.r wget.p; do
[ -e "$(R "$d/$s")" ] && hit "wget-$s" "$d/$s"
done
done
if [ -e "$(R /bin/wget)" ] && [ ! -L "$(R /bin/wget)" ]; then
printf 'note %-14s /bin/wget is a regular file, not a BusyBox symlink\n' "check"
fi
echo "== 3. init persistence =="
for f in /etc/inittab /etc/init.d/rcS /etc/rc.d/rc.boot; do
[ -f "$(R "$f")" ] || continue
if grep -q '\.cling' "$(R "$f")"; then
hit "init" "$f -> $(grep -c '\.cling' "$(R "$f")") line(s)"
fi
done
echo "== 4. single-instance port 33957 =="
if [ -r "$(R /proc/net/udp)" ]; then
# 33957 = 0x84A5; SO_REUSEADDR bind, little-endian hex in /proc/net/udp
if awk 'NR>1 && toupper($2) ~ /:84A5$/ {n++} END{exit !n}' "$(R /proc/net/udp)"; then
hit "port" "socket bound to 33957 (Cling single-instance lock)"
fi
else
printf 'skip %-14s /proc/net/udp not readable\n' "port"
fi
echo "== 5. /proc disguise (Fortinet: bind-mount /tmp over own /proc/<pid>) =="
if [ -r "$(R /proc/mounts)" ]; then
awk '$2 ~ /^\/proc\/[0-9]+$/ && $3 ~ /tmp/ {print "HIT mount " $2 " <- " $1}' \
"$(R /proc/mounts)"
fi
echo
if [ "$hits" -eq 0 ]; then
echo "no Cling/ClingSTUN artefacts found under $ROOT"
exit 0
fi
echo "$hits artefact(s) found under $ROOT - isolate the host, do not reboot"
exit 1
Isolate the device, do not reboot it. Nozomi's persistence runs through /etc/inittab and rc.boot, so a reboot re-runs the malware, and the replaced wget means any firmware update or cron job fetching a file re-triggers it. Fortinet adds that the third-generation downloader unmounts non-proc filesystems and kills the owning process, and that as root it bind-mounts /tmp over its own /proc/<pid> to look like PID 1. An empty-looking process tree is a finding, not an all-clear.
Replacing wget is a defensive pattern worth stealing: it turns your own tooling into the malware's re-entry point, so a routine wget call resurrects the bot. On embedded devices, alert on wget.r and wget.p.
What actually closes this
Three things, in order:
- Inventory and cut exposure. Internet-facing routers, cameras and DVRs are the entire attack surface here. Where firmware updates no longer exist, replace or isolate the device. Fortinet's phrasing: unsupported firmware and unnecessary exposure are the real entry conditions.
- Detect on protocol conformance, not reputation. All-zero transaction IDs, non-STUN datagrams on STUN ports and implausible fan-out cost nothing.
- Hunt the artefacts.
/root/.cling, the replacedwgetand the init entries are unambiguous.
The transferable lesson is narrow. Cling is not clever because its C2 is exotic — STUN abuse is a known trick. It is clever because it built a disguise out of a machine-checkable rule, then leaned on the defender's habit of trusting the destination instead of validating the protocol. The camouflage and the detector are the same field.
Sources
- Nozomi Networks Labs: A STUNning Disguise: Cling Malware Masquerades as Google
- Fortinet FortiGuard Labs: ClingSTUN Linux Backdoor Abuses Public STUN Infrastructure
- The Hacker News: Realtek Jungle SDK Exploit Attempts Deliver Cling Botnet With STUN-Based C2
- CISA: Known Exploited Vulnerabilities Catalog
- IETF: RFC 8489: Session Traversal Utilities for NAT (STUN)
- IETF: RFC 2827: Network Ingress Filtering (BCP 38)
- Dark Reading: ClingSTUN Turns Vulnerable IoT Devices Into Proxy Nodes
// author
Editor & Operator
Mohd Firdaus Abu Bakar (MrDodgerX) is an enterprise geospatial systems engineer in Malaysia and the editor behind hejes.my. He runs the briefing pipeline, curates and edits every draft, verifies each factual claim against primary sources, and presses the publish button — nothing ships without his review. He writes about AI, security and Malaysia's tech scene from a builder's perspective. Professional profile and code: the links below.
// sources //
fact-check verified · 06 OCT 2026 · hejes-content-fact-check
- A STUNning Disguise: Cling Malware Masquerades as GoogleNozomi Networks Labs · accessed 06 OCT 2026
- ClingSTUN Linux Backdoor Abuses Public STUN InfrastructureFortinet FortiGuard Labs · accessed 06 OCT 2026
- Realtek Jungle SDK Exploit Attempts Deliver Cling Botnet With STUN-Based C2The Hacker News · accessed 06 OCT 2026
- Known Exploited Vulnerabilities CatalogCISA · accessed 06 OCT 2026
- RFC 8489: Session Traversal Utilities for NAT (STUN)IETF · accessed 06 OCT 2026
- RFC 2827: Network Ingress Filtering (BCP 38)IETF · accessed 06 OCT 2026
- ClingSTUN Turns Vulnerable IoT Devices Into Proxy NodesDark Reading · accessed 06 OCT 2026
related //

Anthropic finds ingredients for future AI worms
Anthropic tests show small open-weight models can assemble replication steps, but hardware and bandwidth limits make AI worms slow for now.

GoBalance Bug Reveals Tor Onion Master Keys
A bug in the GoBalance load balancer let anyone recover a dark-web site's Tor master key from one public descriptor, no server access required.
Three Remote Hacks Hit a Fully Patched Pixel 10
At Pwn2Own Ireland, three teams broke into a fully patched Google Pixel 10 over the network on the same day, splitting $562,500 in prizes.
// join the feed
one fresh insight per week. no spam, ever.
weekly digest. unsubscribe anytime.