~/f4n6 $ grep -r "Salad on the Side: Unpacking LunarCrypt and the SalatStealer RAT" ./investigations/ --include="*.md"
malware

Salad on the Side: Unpacking LunarCrypt and the SalatStealer RAT

Jeff Davies 12 Sep 2026 10 min read

A Go crypter, a RAT with SQLite in its WebAssembly belly, and an encrypted C2 configuration that eventually introduced itself through an error message. The salad came with several layers of dressing.

The archive password was infected. The executable inside was 5.27 MB of Go. Behind it sat another Go binary, this time a 32-bit RAT with browser and wallet theft routines, remote-control machinery, and some intriguing references to the TON blockchain.

The wrapper called itself LunarCrypt. The payload's module path was simply salat. Both had been stripped, but enough Go metadata survived to give us a guided tour.

The best discovery came from memory. After working through the wrapper and unpacking the payload, we let the implant decode its configuration inside the isolated lab. It then obligingly repeated its configured C2 hostname every time a connection attempt failed:

sa1atik[.]cn

Encryption had hidden the endpoint on disk. Error handling supplied the name tag.

The rig, and what the evidence means

The analysis ran through our malbox tooling. Dynamic execution took place in the isolated VMware guest for710-win10, configured with host-only networking and no route to the internet. The guest was reverted to a snapshot before and after each run. Static analysis, unpacking, Ghidra work, and inspection of Go runtime structures took place on the lab host.

Throughout this post, observed means recorded during the guest runs or recovered from process memory. Static means reconstructed from the binary, its embedded content, or decompiled code. Inferred means a plausible explanation that still needs a trace or a more complete reconstruction.

That distinction matters here: a function named GetAppBoundKey is a useful lead; demonstrating successful decryption of a particular browser's protected data is a separate result. Likewise, recovering a configured hostname does not establish that the server was live. No successful external C2 connection occurred in these runs.

Stage 0: the archive

The sample arrived in an AES-encrypted ZIP containing one executable. Its filename matched the stage-1 SHA-256 recorded in the case:

84596497251e3847ce4389cb389a56fe87e08944474ffd4019ec315d306f449e.exe

Format:     PE32+ GUI executable, x86-64
Size:       5,268,480 bytes
Sections:   8
Imphash:    c2d457ad8ac36fc9f18d45bffcd450c2

The password, infected, belongs to the sample-sharing convention. It tells us how this copy reached the lab, rather than how a victim would have received it.

The section layout gave us the first useful clue. Beside approximately 0.7 MB of .text and 0.86 MB of .rdata, the binary carried roughly 3.65 MB of .data, with a recorded entropy of 7.989 bits per byte. That large, high-entropy region was consistent with a packed or encrypted payload. Following the code established which.

Stage 1: LunarCrypt keeps a diary

The wrapper's Go build information survived:

path    command-line-arguments
build   -ldflags="-s -w -H=windowsgui"
build   CGO_ENABLED=0
build   GOARCH=amd64
build   GOOS=windows
go1.22.0

So did this source path:

C:/Users/Administrator/Desktop/LunarCrypt/.go_cache/stub_1788423005216099200.go

LunarCrypt had removed its conventional symbols and debug information while leaving its name and a generated-stub path in the binary. The recovered Go function names were equally conversational:

main.getPassword      main.decryptAESGCM
main.deriveKey        main.decryptString
main.pbkdf2SHA256     main.decompress
main.executeFile     main.melt
main.main

Static reconstruction: Ghidra reduced the main path to a short sequence:

  1. Recover an embedded password by XORing its bytes with 0xf1.
  2. Derive a 32-byte key using PBKDF2-HMAC-SHA256, an embedded salt, and 100,000 iterations.
  3. Decrypt the embedded payload blob with AES-GCM.
  4. Decompress the result, write it to a temporary executable, and launch it.
  5. Invoke the cleanup path associated with main.melt.

The binary also contains the temporary-file pattern payload_*.exe. Together with the reconstructed write-and-launch path, that gave us a concrete artifact to collect during detonation.

Observed: the wrapper, PID 6724, ran for approximately three seconds. It wrote:

C:\Users\REM\AppData\Local\Temp\payload_4002092102.exe

It launched that file as PID 6804 and exited with code 0. The case notes also record the wrapper's subsequent disappearance. The collector recovered the dropped executable before the snapshot was reverted:

File:       payload_4002092102.exe
Format:     PE32 GUI executable, Intel 80386
Packing:    UPX
Size:       3,594,752 bytes
Sections:   3
SHA-256:    16afb5021e5a7eafb309798a4ec28ddec925f0577d2b3b5c7cf18db66167b704

The handoff changes architecture: an x64 Go wrapper launches an x86 Go payload. The different builds fit a modular wrapper-and-payload arrangement. They do not establish separate authors or a commercial crypter service.

Stage 2: UPX opens the salad bar

UPX unpacked the payload cleanly: 3.59 MB became approximately 12.57 MB, and another batch of Go metadata became readable.

path    salat
mod     salat    (devel)
go1.24.0

The dependency list included github.com/andygrunwald/vdf, github.com/capnspacehook/taskmaster, github.com/gorilla/websocket, and github.com/tetratelabs/wazero: useful leads for Steam configuration parsing, Windows scheduled tasks, WebSocket transport, and WebAssembly execution respectively.

Source filenames included salat/main.go, salat/init.go, salat/funcs.go, salat/sets.go, salat/task.go, and salat/tsc.go, alongside an internal salat/screenshot package.

For this case, we use SalatStealer for the payload identified by that salat module. Its recovered code surface extends well beyond credential collection into remote-access functionality.

The menu: 301 recovered main-package symbols

This is a static capability map. Names, strings, and linked interfaces identify areas of interest; the isolated runs did not exercise every feature.

Area Evidence in the unpacked payload
Chromium data getChromeLogins, getChromeCookies, getChromeAutofils, getChromeToken, GetChromiumMasterKeys; Login Data and Local State strings
App-Bound Encryption targeting GetAppBoundKey, decryptAPPB, StartAPPB, and COM interface names including IElevator, IElevatorBrave, and IElevatorEdge
Firefox and Gecko data getGeckoLogins, getGeckoCookies, key4.db, logins.json, and NSS-related cryptographic routines
Windows data protection DPAPI, CryptUnprotectData, and DATA_BLOB references
Cryptocurrency wallets Strings naming MetaMask, Exodus, Electrum, Phantom, Solflare, Keplr, Ronin, Rabby, Coinomi, Guarda, and numerous others
Messaging applications Telegram-related tdata, found tg process, and found tg:// url strings; a separate getDiscord symbol
Steam getSteams, parseVdf, decodeSteam, and Steam/users
Keylogging startKeylogger, stopKeylogger, keyPressCallback, runKeylogger, virtual-key names, and idle-state helpers
Screen and webcam capture salat/screenshot.CaptureRect, sendScreen, screenStream, getWebcams, getScreen, and mjpeg
Remote commands and files executeCommand, shellCommand, downloadFile, unzip, zipFiles, newTask, and doTask
Privilege and token handling Elevate, getSystemToken, impersonateSystem, enablePrivilege, and DuplicateUserTokenFromSessionID
Handle and process manipulation openHndl, duplicateHandle, readFileFromHandle, suspendProcessThreads, unlockProcs, and Restart Manager structures
Scheduled-task support taskmaster bindings and scheduler-related names including Actions, Triggers, and RunLevel
C2 and proxying initConnection, wsSess, c2Server, changeEndpoint, getEp, periodicFlush, proxySocks, and p2pSocks
Cleanup and duplicate checks selfDelete, Suicide, and checkDupe

Two areas deserve particular care. The App-Bound Encryption names support an intended browser-key recovery path, but we did not demonstrate a successful bypass against a specified browser version. The handle and Restart Manager references suggest techniques for accessing files held by other processes; their exact sequence needs a call trace before we can describe how a lock is released or a database is read.

SQLite, served inside WebAssembly

The unusual dependency was wazero, a Go WebAssembly runtime. Beside it sat an embedded WASM module of approximately 1.3 MB, extracted at file offset 0x69c1a0 in the unpacked image.

Static extraction: its exports included sqlite3_step, sqlite3_column_text, and sqlite3_bind_*. The module was SQLite.

That arrangement has a legitimate precedent: earlier versions of ncruces/go-sqlite3 embedded a WASM build of SQLite and ran it through wazero, providing SQLite access without cgo. The combination in this sample is consistent with that design; the listed dependencies alone do not establish the exact driver or version. Versioned upstream documentation

For a stealer interested in browser databases such as Login Data, Cookies, and Web Data, a bundled SQLite engine is a practical ingredient. This design can supply it without a separate native sqlite3.dll.

The sample gives us evidence of packaging, rather than a measurement of detection resistance. Still, finding an entire database engine inside the RAT's WebAssembly belly was a pleasant diversion from the usual string soup.

The housekeeping script has opinions

Static decoding: an embedded base64 blob, approximately 5.4 KB in its encoded form, yielded a PowerShell script with four main jobs:

  1. Request Microsoft Defender exclusions for Program Files, %AppData%, and %LocalAppData% using Add-MpPreference -ExclusionPath.
  2. Invoke reagentc /disable.
  3. Download 7z.dll, 7z.exe, MSTSCLib.dll, AxMSTSCLib.dll, and ffmpeg.exe into %TEMP% from a raw GitLab repository path.
  4. Set EnableLUA to 0.

The configured download base was:

hxxps://gitlab[.]com/webrat1/importantfiles/-/raw/main/

The filenames suggest archive handling, RDP client components, and media processing. The exact relationship between those helpers and individual RAT features remains to be traced. GitLab supplies the delivery location in the embedded script; successful downloads were not demonstrated in the disconnected guest.

The recovery and UAC changes also need precise wording. reagentc /disable disables the active Windows Recovery Environment image associated with the running installation. Setting EnableLUA to 0 requests a system-wide UAC policy change, whose activation requires a restart. These commands express intent; the decoded script alone cannot establish that either change succeeded. REAgentC reference, UAC registry mapping, UAC restart requirement

Quite a lot of housekeeping for something that was never invited in.

The encrypted configuration: one layer down

The configured C2 was not recovered as a plaintext endpoint during the initial static inspection. The resolver strings were easier to find:

hxxps://cloudflare-dns[.]com/dns-query?name=
hxxps://dns[.]google/resolve?name=
hxxps://1[.]1[.]1[.]1/dns-query?name=

Runtime URL construction also included &type=A.

Static reconstruction: two 516-byte blobs at analysis VAs 0x00c966a0 and 0x00c968a4 began with the dword 0xa5a5a7a5. A routine at 0x008dfb70 was reconstructed as applying this transform to successive body dwords:

out[i] = bswap32(rol32(w[i], 4*i) ^ (4*i) ^ 0x9e3779b9)

The rotation is over 32 bits. The first dword separately gives a candidate output length:

0xa5a5a7a5 ^ 0xa5a5a5a5 = 0x200 = 512 bytes

Those addresses refer to this analysed image and should not be treated as portable signatures.

The recorded reimplementation did not produce a readable endpoint. Further decoding was a plausible explanation, with main.dec, main.getBestMethod, and CIPHER ERROR providing additional leads. It remained an incomplete config reconstruction: non-readable output alone cannot distinguish another encryption layer from a mistake in the reconstructed transform or its inputs.

At that point, we had a more direct route. The implant needed the answer too.

Letting the error message do the talking

Observed: we ran the packed x86 payload under the x86 CDB debugger inside the guest, allowed it approximately 60 seconds to initialise and attempt its network bootstrap, then interrupted execution and searched process memory.

This was user-mode process debugging. CDB supports launching or attaching to a process directly, which is the workflow used here. Microsoft's CDB documentation

The search targeted the readable part of the DoH URL construction:

0:000> s -a 0x01020000 L?0x7efd0000 "dns-query?name="
01c1e0bb  dns-query?name=d
01cbc250  dns-query?name=g
...

Memory inspection exposed resolver URLs, apparent bootstrap probes involving dns[.]google, google[.]com, and 1[.]1[.]1[.]1, and repeated connection errors. The useful one read as follows, with the URL and hostname defanged for publication:

Get "hxxps://sa1atik[.]cn/sa1at/": dial tcp: lookup sa1atik[.]cn: no such host

There were dozens of copies. Every retry left another breadcrumb.

The recovered endpoint list associated with main.getEp contained four copies of sa1atik[.]cn:443, using the path /sa1at/. For this build, endpoint rotation led back to the same hostname.

The recovered configuration was therefore:

hxxps://sa1atik[.]cn/sa1at/
Port: 443

The binary includes a WebSocket client dependency, but the lab run did not complete a TLS connection or WebSocket handshake. What we recovered was the destination the implant tried to use.

It had spent considerable effort concealing that destination on disk, then printed it in a complaint about the lab's networking. We appreciate an actionable error message.

TON: an interesting lead, with some assembly required

The TON-related evidence consisted of several pieces:

  • Static: references to ton[.]access[.]orbs[.]network, the function main.decodeFromTonAddress, and an embedded base64 sequence beginning te6cckEBAQEAEgAA.
  • Observed in memory: lookup-failure text naming ton[.]access[.]orbs[.]network.
  • Inferred: a TON-assisted configuration or endpoint-discovery path, potentially used as a fallback when the ordinary endpoint is unavailable.

The base64 prefix decodes to bytes beginning b5 ee 9c 72, the TON Bag of Cells serialization magic. A BoC can represent many kinds of cell data; identifying its container does not establish that it holds executable contract code or an operator-controlled record. TON serialization documentation

Address formats are another easy place to overread the evidence. A raw basechain address is 0: followed by a 256-bit account identifier represented as 64 hexadecimal characters. The user-friendly representation encodes a 36-byte structure into 48 base64 or base64url characters. A routine reportedly accepting 0: plus 64 base64url characters needs its own parser semantics checked before we call that a standard raw TON address. TON address formats

The draft analysis also identified an opaque string that decodes to 32 bytes. Length alone cannot identify it as an Ed25519 public key, a wallet identity, or a contract address.

The next useful result would connect a specific TON request and response to a decoded endpoint, then show that endpoint reaching the connection routine. Until then, a blockchain fallback is a working hypothesis. An operator updating a smart contract or TON DNS record to redirect the fleet remains unproven in this case.

There is enough here for another evening in the debugger. The blockchain can wait its turn.

Hunting the parts that leave footprints

The wrapper complicates static inspection, but the chain still supplies several useful points of correlation:

  • Process and file activity: a short-lived x64 GUI executable writes and launches an x86 payload_<digits>.exe beneath %TEMP%. That sequence was observed in this run; the filename pattern alone is weak evidence.
  • Recovered Go metadata: the LunarCrypt build path and stub_*.go pattern belong to stage 1; the salat module and source paths belong to the unpacked payload. Match combinations with file context.
  • Script telemetry: the embedded script's combination of Defender exclusion requests, reagentc /disable, an EnableLUA change, and downloads from the specific raw GitLab path provides a behavioural hunting lead. Script-block and process telemetry could establish whether those operations actually ran.
  • Endpoint indicators: correlate sa1atik[.]cn and /sa1at/ with the responsible process. The URL path requires endpoint-level or decrypted HTTP visibility; ordinary encrypted network traffic does not expose it.
  • Memory artifacts: the configured hostname, repeated lookup failures, and resolver URL fragments were useful after runtime initialisation. Searching for strings assembled during failed connections can recover information absent from the initial on-disk view.

The public resolver and TON gateway names belong in contextual hunting, not standalone malicious-domain lists. The import hashes below are also supporting pivots: packers and shared toolchains can produce collisions across unrelated samples.

One extra string deserves an honourable mention: dQw4w9WgXcQ, recorded near the config parser. Even here, someone found room for a rickroll.

Indicators of compromise

These SHA-256 values identify the three executable artifacts recorded in the case. A separate archive hash was not supplied.

Executable SHA-256 hashes

LunarCrypt wrapper — x64
84596497251e3847ce4389cb389a56fe87e08944474ffd4019ec315d306f449e

SalatStealer payload — UPX-packed x86
16afb5021e5a7eafb309798a4ec28ddec925f0577d2b3b5c7cf18db66167b704

SalatStealer payload — unpacked
d6a102b8ca947e5ca97a90843c295b714130156792ad56a7493d759719e7c641

Import hashes

Wrapper:           c2d457ad8ac36fc9f18d45bffcd450c2
Packed payload:    6ed4f5f04d62b18d96b26d6db7c18840
Unpacked payload:  1aae8bf580c846f39c71c05898e57e88

Configured network locations, defanged

# C2 recovered from process memory
sa1atik[.]cn
hxxps://sa1atik[.]cn/sa1at/

# Helper-download base in the decoded script
hxxps://gitlab[.]com/webrat1/importantfiles/-/raw/main/

# Requested helper filenames
7z.dll
7z.exe
MSTSCLib.dll
AxMSTSCLib.dll
ffmpeg.exe

# Legitimate public services referenced by the sample
cloudflare-dns[.]com/dns-query
dns[.]google/resolve
1[.]1[.]1[.]1/dns-query
ton[.]access[.]orbs[.]network

Hash of the day

LunarCrypt brought the encrypted wrapper. Salat brought browser-theft routines, remote-access machinery, and a database engine tucked into WebAssembly. The runtime supplied the final ingredient: a readable endpoint in an otherwise unhelpful connection failure.

The useful lesson is in that progression. The wrapper disclosed the launch path; the unpacked Go metadata mapped the code; the running process exposed the configuration it needed to use. Each layer gave us a different kind of evidence.

The archive said infected. The module said salat. Eventually, the error message told us where the salad wanted to go.

Analysis date: 12 September 2026. Case reference: malware:84596497251e. Executable artifacts remain in the lab; this post distributes hashes only. Network indicators are defanged throughout.

Working on a difficult security investigation or building similar tooling? I’d be interested to hear from you.
AI Platform Investigation hello@f4n6.co.uk
Post this to LinkedIn
Formatting is converted automatically — headings, bullets, a link back & hashtags. Paste straight in.
J
Jeff Davies
// read next

Related