ES EN
Index

XorDDoS · Chapter 2

Cracking XorDDoS open with Ghidra

In Chapter 1 the C2 stayed encrypted inside the binary. Here I open it with Ghidra and it comes out whole — but on the way, everything else this critter carries turns up too: how it disguises itself as a system process, how it kills the competition using the very trail that gives it away, and how it lies about its own address when its command centre tells it to.

In Chapter 1 a loose thread was left dangling: the bot carries its C2 hidden inside the binary, encrypted with an XOR table, and the quick methods didn't crack it. Today I open it with Ghidra — and it comes out whole.

Recovering a hidden config to pull out IOCs and be able to defend yourself has nothing to do with rebuilding the weapon: the DDoS side stays untouched. What I'm after is who the critter calls home to. But opening it up looking for that, everything else it carries turned up — and it turned out to be far more interesting than a list of domains.

01The binary on the table

I loaded the sample into Ghidra (headless, no GUI; I opened the GUI afterwards just for the screenshot further down) and let it auto-analyze. The clue for finding the decryptor came from a plain strings run over the binary: amid the junk, the string BB2FA36AAA9541F0 and a hex table 0123456789ABCDEF showed up repeatedly. All you have to do is ask Ghidra who references that data to land on the routine that uses it: FUN_00015d00 — short, with a loop and XOR operations. That's our man.

02The decryption routine

The short version, for anyone who'd rather not wade into the mud: it's a repeating 16-byte XOR key, and the key was in plain sight the whole time — the config's own zero padding was ratting it out in the strings. With that, decrypting is a one-liner. The gutting, for whoever wants it:

The routine in Ghidra, byte by bytedisassembly

Here's the heart of FUN_00015d00 as it looks in the decompiler (trimmed to the essentials):

ghidra · FUN_00015d00 (decompiled)
do {
    *pbVar4      = *pbVar4      ^ (&DAT_00011070)[uVar5     & 0xf];
    pbVar4[1]    = pbVar4[1]    ^ (&DAT_00011070)[uVar5 + 1 & 0xf];
    pbVar4[2]    = pbVar4[2]    ^ (&DAT_00011070)[uVar5 + 2 & 0xf];
    pbVar4[3]    = pbVar4[3]    ^ (&DAT_00011070)[uVar5 + 3 & 0xf];
    uVar5  = uVar5  + 4;
    pbVar4 = pbVar4 + 4;
} while (param_2 != uVar5);   // param_2 = length
Ghidra showing XorDDoS's decryption routine: x86 assembly on the left, reconstructed C code on the right
The routine, in Ghidra. On the left, the x86 assembly exactly as it sits in the binary; on the right, that same code translated into C. There are the ^ *pbVar3 of the XOR loop, the DAT_00011070 pointing at the key, and the constants 0x4136334146324242 and 0x3046313435394141 — which, read as ASCII bytes, spell out letter by letter BB2FA36AAA9541F0. The key was in plain sight: plain ASCII text, only the disassembly shows each 8-byte immediate backwards, in little-endian order.

You can read it at a glance: XOR each byte with a key indexed by position & 0xf — a repeating 16-byte key, stored in DAT_00011070.

The "aha" that had me thrown offThe key turned out to be the ASCII characters of "BB2FA36AAA9541F0" (bytes 42 42 32 46 41 33 36 41…), not the decoded hex bytes. And that's why the padding gave it away: the config carries zero padding, and zero XOR key = key. That's why BB2FA36AAA9541F0 showed up repeated in the strings — it was the padding ratting out the key.

03The config laid bare

I replicate the algorithm (read the key, XOR with index pos % 16) and apply it to the config data. Out it comes, clean:

decrypted config
# C2 servers (with failover), port 1529
telemetry-pipe.sh:1529 | api-metadata-v6.is:1529 | sys-kernel-update.to:1529

# config / update URL
https://api-metadata-v6.is/config.rar

Three C2 domains on port 1529, separated by | as a fallback list, plus a URL to fetch its config. That was the secret the encryption was guarding.

04The domains' disguise

Look at the names — they're no accident. They're picked to look like legitimate infrastructure:

  • telemetry-pipe.sh — sounds like a "telemetry pipe", and the .sh TLD (Saint Helena) makes it look like a shell script name. A double disguise.
  • api-metadata-v6.is — looks like a perfectly ordinary internal API endpoint.
  • sys-kernel-update.to — reeks of "system updates", exactly the kind of thing an admin won't look at twice.

It's hiding in plain sight: in a connection log, these names don't raise an eyebrow. And the small-country TLDs (.sh, .is, .to) are chosen for availability and less scrutiny. It's the same instinct — dressing up as the system — that we'll watch it apply to itself when it runs on a machine; we'll come back to it.

05Is the infrastructure still alive?

Passive OSINT (DNS resolution + third-party databases; I never touch the servers): of the three domains, two are dormant — they don't resolve — and the third, sys-kernel-update.to, points to 141.98.11.51: HostBaltic (AS209605, Lithuania), an abuse-tolerant host. Two dormant and one live is normal for a C2 list with fallbacks: they don't all light up at once. Shodan sees that IP as a Windows server; the actual C2 port, 1529, doesn't show up because it's uncommon and not routinely scanned.

A pattern that says a lotEach piece lives on a different offshore host: the loader came in from one provider, the binary was served from another, and the C2 lives on a third. They spread the infrastructure across three abuse providers so that taking one down doesn't bring it all down. Sloppy in execution, but with a certain notion of resilience.

One detail from the binary fits right here: it carries two of Google's DNS servers written inside it, 8.8.8.8 and 8.8.4.4. In a critter whose C2 goes by domain, having its own resolvers isn't decoration — it's making sure it can resolve even if the machine's DNS fails or is being watched.

And there's a move today's resolution doesn't show, with a practical consequence: that IP wasn't always that one. The name changes number within HostBaltic, without leaving the same ASN. There's the lesson for the catalogue: publish the IP on its own and your indicator expires, leaving whoever copies it blocking an empty address. What holds up is the pair domain + ASN. Changing number within the same provider costs the operator nothing; changing provider doesn't.

06I switched it on, in a cage

Everything above comes from reading the binary. But reading tells you what a program knows how to do; switching it on tells you what it does. So I did — in an isolated virtual machine, its network with no way out to anywhere, reverted to a clean snapshot afterwards. On the bait you never do that (it has a public IP, and letting a worm loose would infect third parties); in a sealed cage, you can. The difference is the cage.

First thing: it copies itself. One copy goes to /usr/lib/libudev.so — a system library path, a system library name — identical to the sample byte for byte: the same SHA-256 from the specimen card in Chapter 1. Another goes to /usr/bin with a random ten-letter name, and that one no longer has the hash I published. It's the same gibberish-name trick from the first chapter, one notch up: back then it rotated the name on disk, here it rotates the whole file. The hash I gave identifies the file that was uploaded to me; a minute into running on a real machine, what's executing there already has a different one.

And then I looked at the process list, and I couldn't find it. There were crond, /usr/sbin/gdm3, rpc.statd, automount, rpc.idmapd and /sbin/audispd. Six of the dullest system daemons going, the kind your eye slides straight past. All six were it.

A process carries two names: the one the kernel notes down when it starts it, and the one the program writes into its own argv[0] — the one that shows in the list. And now that I have the binary open, I know how it sets the fake one: it relaunches itself passing the name to impersonate, and on that second start it wipes the command line and writes over it.

The disguise mechanism, in the decompilerdisassembly
ghidra · the disguise, in three steps
name = argv[1];                   // stores the name to impersonate
memset(argv[0], 0, ...);          // wipes its own name
memset(argv[1], 0, ...);          // wipes the one it was handed
memset(argv[2], 0, ...);          // wipes the third
                                  // and writes the fake one over argv[0]

That's why, if you look at the process memory, you find leftovers like /usr/bin/xqaghzmjom [kthreadd] 19882: the critter calling itself, with the fake name in the middle and, behind it, the number of the process it wants to look like.

In the cage I saw it wearing six names, but the code carries eighteen, and twelve are of a kind the six didn't include — kernel threads, those names in square brackets:

the eighteen, inside the binary
kernel threads    [kthreadd]  [rcu_gp]  [rcu_par_gp]  [cryptd]  [mld]
                   [scsi_eh_2]  [cpuhp/0]  [mm_percpu_wq]  [ttm_swap]

daemons           crond   automount   rpc.statd   rpc.idmapd   pcscd
                   hald-runner   (sd-pam)   /usr/sbin/gdm3   /sbin/audispd

And that distinction has a consequence. Comparing the name a process claims with the file its /proc/<pid>/exe points at doesn't work as a general rule: a critter that changes both names slips underneath it, and there's one in this very log. But against these twelve it does work, and there's no arguing: a real kernel thread has no executable. If something claims to be [kthreadd] and its /proc/<pid>/exe resolves to a file in /usr/bin, there's no reading of that which comes out innocent. Against a fake crond it's useless; against a fake square bracket, it's final.

07It looks for itself where it looks for its rivals

The same binary has a routine for clearing the competition out of the way: it walks the machine's processes one by one, resolves each one's /proc/<pid>/exe, compares it against what it's hunting, and kills whatever matches. That's how it keeps the machine to itself — the same war between criminals that turns up in half this log, here read in the code, not inferred.

Twenty lines further up, in the same function, there's another call to /proc/<pid>/exe. But this one isn't about a rival: it's about itself.

The two calls, in the same functiondisassembly
ghidra · bot_main
// on itself — to find out where its own binary is
sprintf(path, "/proc/%d/exe", getpid());
readlink(path, ...);

// … twenty lines below, on the others — to hunt them down
sprintf(path, "/proc/%d/exe", other_pid);
readlink(path, ...);
kill(other_pid, 9);

And it's not that it fancies asking after itself: it has no choice. It has just wiped its own argv[0] to put the disguise on (we saw it in the previous section), and with that it destroyed the normal way a program has of knowing where its file is. So to find itself it has to go to the same place it hunts the others from — and the same place where, if you're paying attention, it gets hunted.

It depends on the very trail that betrays it. That's not a figure of speech: it's two calls, in the same function, twenty lines apart. It hides by wiping the one thing that said who it was, and then has to go and ask at the counter where anyone can overhear.

08And it lies about where it comes from

So far, a critter that hides. But it does something more, and it's what separates it from any old flooder: it forges its source address. The bait recorded it doing so — a short burst, a couple of seconds, some sixty connections whose return address wasn't its own. Neighbouring addresses, and the distance from the real one wasn't noise:

the gap between the fake source and the real one, in arrival order
−1     +1        −3     +3        −7     +7        −15    +15
−31    +31       −63    +63       −127   +127      −255   +255
−511   +511      −1023  +1023     −2047  +2047     …
                                            … doubling each step, out to the end of the range

One less, one more. Three less, three more. Each step double the last, and always symmetrical. A random generator doesn't spit that out: someone is counting. So I went to the binary to see who.

It's there — and it isn't a suspicion from the traffic: it's code, and it shows up in two places in the program. But the interesting part is how it's put together:

  • It's optional, and the critter doesn't decide it. There's a flag in its config; if it isn't set, it uses its real address. The command centre fills it in.
  • It's bounded. The operator doesn't tell it "lie": it hands it a range, and the critter moves inside it.
  • There's no randomness. The fake address comes out of arithmetic, not a generator — which is why the burst is regular steps and not noise.

That fits what the bait recorded right down to the order: the critter talks to its command centre first using its real address, and then starts lying. It wasn't probing anything on its own initiative: it got the order and carried it out.

The header and the arithmetic, byte by bytedisassembly

To forge the source you have to ask the kernel to let you write the packet header yourself. That's two gestures, and both are in the binary: a raw socket and the IP_HDRINCL option. With that on, the system no longer fills in your address: you do.

ghidra · the packet, built by hand
// raw socket + "the header is included"
fd = socket(AF_INET, SOCK_RAW, IPPROTO_RAW);
setsockopt(fd, IPPROTO_IP, IP_HDRINCL, "1", 2);   // ← it passes the TEXT "1", not an integer

// and writes the IP header byte by byte:
h[0]      = 0x45;            // version 4, header length 5
h[2..3]   = htons(72);       // total length
h[8]      = 128;             // TTL
h[9]      = 17;              // protocol = UDP
h[12..15] = fake_source;     // ← here goes the lie
h[16..19] = destination;

That "1" is a botch that works by the skin of its teeth: it hands over two bytes of text where the kernel expects an integer. The kernel reads something other than zero and turns the option on anyway.

And the arithmetic behind the fake source, which is what makes the burst regular:

ghidra · where the address comes from
if (cfg.spoof == 1) {                       // ← the C2's switch
    size   = cfg.range_end - cfg.range_base + 1;
    source = cfg.range_base + (X % size);    // X = value from a table, by counter
} else {
    source = my_real_ip;
}

Base and end are two fields the operator sends. X comes from a table indexed by a counter — deterministic, no rand(). The mechanism explains why the burst is steps that double; reproducing the exact pattern would take the table and the range the C2 sent that time.

And what serves you most if you're defending isn't the mould, it's the signature on the wire: a short burst — tens of packets in two seconds, not thousands — from addresses neighbouring the real one, doubling the distance as they go; and above all, addresses from your own space arriving from outside, which is exactly what a properly placed ingress filter should always drop. The capability is the critter's; the detection is yours.

A point of methodTwo things worth keeping apart: the behaviour — the burst — I saw in a capture from the bait; the capability — that it's written into the program — I read in the binary I have open. They're from the same family; I can't guarantee they're the same file down to the byte. I'm saying XorDDoS carries this inside and that it matches what was recorded, not that I disassembled the exact packet that went out over the wire.

09What a cage doesn't tell you

One detail that made me think, and it goes without ornament because it's the honest lesson of the chapter. When I switched the critter on in the cage, I saw the disguise, I saw the copies, I saw the persistence. Of the source forgery I saw nothing. And not because it hid:

  • I watched the processes, not the network. I set the observation up to answer "what does it install, and under what name?", and to that it answered in full. The other question I never asked.
  • And even if I had, I'd have seen nothing. The cage has no way out — that's what makes it a cage. So the critter never got to talk to its command centre, never received the spoofing flag switched on, and never ran that branch of the code. The capability was dormant by design.

A detonation only tells you what you built it to tell you, and on a network with no way out there are whole branches of the program that never get walked. The lie about the source wasn't shown by the cage; it was shown by the code, and confirmed by the bait once the critter had a real command centre on the other end. Neither route, on its own, would have told the whole thing.

And the tools don't eitherI ran this binary through an automated capability classifier and it didn't see the raw socket. In a static, stripped binary, a tool failing to find something is no proof that it isn't there.

10Indicators (IOCs)

TypeValue
C2 (port 1529)telemetry-pipe.sh · api-metadata-v6.is · sys-kernel-update.to
Active C2141.98.11.51 (HostBaltic, AS209605, LT)
Durable indicatorsys-kernel-update.to + AS209605 — the IP moves around inside the ASN; the domain and the provider don't
Config URLhttps://api-metadata-v6.is/config.rar
Config encryptionXOR · 16-byte ASCII key "BB2FA36AAA9541F0" · idx = pos & 0xf
Embedded resolvers8.8.8.8 · 8.8.4.4
Library copy/usr/lib/libudev.so — same SHA-256 as the sample
/usr/bin copyrandom ten-letter name — SHA-256 different from the sample's
Impersonated processes (18)[kthreadd] · [rcu_gp] · [rcu_par_gp] · [cryptd] · [mld] · [scsi_eh_2] · [cpuhp/0] · [mm_percpu_wq] · [ttm_swap] · crond · automount · rpc.statd · rpc.idmapd · pcscd · hald-runner · (sd-pam) · /usr/sbin/gdm3 · /sbin/audispd
The check that does worka process with a name in square brackets whose /proc/<pid>/exe resolves to a file — a genuine kernel thread has no executable. Square brackets only
Spoofing capabilitysource IP forgery — optional, switched on by the C2, bounded to a range set by the operator and computed without randomness
How it looks on the wireshort burst (tens of packets in two seconds) from addresses neighbouring the real one in symmetrical doubling steps · and addresses from your own space arriving from outside (an ingress filter should drop those)

And that ties off the loose thread from Chapter 1 — the C2 was there, encrypted, and now it's on the table — and with it the whole critter: how it hides, how it hunts, and how it lies. Told without cuts: the capability, how it works and how it's detected. What doesn't get handed out isn't the analysis, it's the weapon — the binary and the exploit for the entry vector; that stays out. The rest is here.

To be continued — the bait is still lit. There's one thing left for me to read in this critter: when it's told to forge, it's given a range. The range decides who gets the blame. That isn't in the binary — the C2 tells it over the wire, and that conversation I still can't read. 🍯

Comments