0. Preface

This sample arrived in a folder named RustyStealer. It is written in Rust, but it is not a stealer, and saying so early matters: a responder who goes looking for dumped browser credentials will find none, and will walk straight past the hidden-desktop remote-control subsystem that is the actual point of the program.

The label is not meaningless, though — it is misfiled. A second sample from the same folder is a Rust infostealer, with a working Chrome App-Bound Encryption bypass and a Telegram bot for exfiltration. The two share a folder name, a language, and nothing else. Read this writeup as being about one binary that ended up under the wrong label, not as a claim that the label is always wrong.

Everything below is static analysis. The sample was never executed, no VM was detonated, and no request was made to its infrastructure. Decompilation was done with kuna driven from the shell, with rustabi auto set — without it, Result/Option payloads are silently dropped from the output on a Rust target.

The binary self-identifies thoroughly. It calls itself Bridge Agent, its remote-control subsystem Backstage, and it stamps its own build string into the C2 protocol:

bridge-agent-v0.4.21-backstage-beta-finance+5c0922233f932a844540b4e1adf8fb38f92a452a08a00455e21cdd42426c1519

The release channel is named backstage-beta-finance. Keep that in mind through section 3.4.

1. Sample Information

  • Original file name: d49785f0195374536fa826a9f2228da105b80fa7bff5d8bee24c7260c665a09e.exe
  • File type: executable, 64-bit (PE32+), GUI subsystem
  • File size: 6.739456 MB
  • Hash values:
    • MD5: ea7aeeebfda6f39a868a6ba491695737
    • SHA1: 01adc9b0769c2bcd81bac32d23ff9776c1a3eab4
    • SHA256: d49785f0195374536fa826a9f2228da105b80fa7bff5d8bee24c7260c665a09e
  • Compiler: Rust, linked with Microsoft Linker 14.50 (MSVC toolchain, Visual Studio 2026)
  • Compile Timestamp: Thu Sep 10 01:05:25 2026 UTC
  • Packing/Obfuscation: Not packed, not obfuscated. No symbol stripping beyond the Rust default; panic paths and src/ strings intact
  • File Entropy Details:
SectionsEntropy
.text6.34
.rdata5.86
.data3.69
.pdata6.50
.fptable0.00
.reloc5.44

Whole-file entropy is 6.41. Exactly one 16 KB window out of 411 exceeds 7.0 — a single 7.99 window at file offset 0x56c000, inside .rdata. That region is the precomputed table data belonging to ring and the ICU normalisation tables pulled in by icu_normalizer / idna, not a payload. There is no overlay and no appended blob.

The build path leaks from the panic strings: C:\Users\Admin\.cargo\registry\.... The author’s own code is a single file, src\main.rs — every other src/ path in the binary belongs to a crates.io dependency.

2. PE File Structure

  • Sections overview:
SectionsVirtual SizeRaw Size
.text52041765204480
.rdata12755801275904
.data3250426624
.pdata216660217088
.fptable256512
.reloc1352013824
  • Certificate: Unsigned. The Security Directory is zero-sized.
  • Resources: None. The Resource Directory is zero-sized — no version info, no CompanyName/ProductName, no icon, no manifest.
  • Exports: none (this is an EXE).

That combination deserves a note. A binary this large, this carefully engineered, that performs WinVerifyTrust on the packages it installs and shells out to PowerShell to inspect its own Authenticode status, ships with no Authenticode signature and no version resource of its own. Commercial management software does not look like this.

  • Suspicious imports, by what they enable:
DLLFunctionsEnables
USER32CreateDesktopW, SetThreadDesktop, OpenInputDesktop, EnumDesktopWindows, CloseDesktophidden virtual desktop
USER32SendInput, SetKeyboardState, GetKeyboardState, MapVirtualKeyW, AttachThreadInput, PostMessageWremote input injection
USER32OpenClipboard, GetClipboardData, SetClipboardData, EmptyClipboard, GetClipboardSequenceNumberclipboard read and write
GDI32 / USER32GetDC, CreateCompatibleDC, StretchBlt, SetStretchBltMode, PrintWindowscreen and window capture
KERNEL32CreateToolhelp32Snapshot, Process32FirstW/NextW, OpenProcess, TerminateProcess, TerminateJobObjectprocess enumeration and kill
KERNEL32InitializeProcThreadAttributeList, UpdateProcThreadAttributeattributed process creation (desktop binding / job objects)
CRYPT32CryptProtectData, CryptUnprotectDataDPAPI — see 3.2
WTSAPI32WTSSendMessageWmessage box into the interactive session
WINTRUSTWinVerifyTrustsignature check of installed packages

The dependency graph is equally informative. Recovered from the crates.io paths in the panic strings:

tokio, hyper / hyper-tls / reqwest, tokio-tungstenite / tungstenite (WebSocket), rusqlite (bundled SQLite), ring (crypto), schannel / tokio-native-tls (TLS via SChannel), jpeg-encoder, chrono, serde_json, base64, data-encoding, anyhow.

jpeg-encoder alongside GDI capture, and tungstenite alongside rusqlite, describe the program before a single function is decompiled: capture frames, encode as JPEG, ship over a WebSocket, queue jobs in SQLite.

3. Code and Disassembly Observation

Symbols are stripped: kuna functions reports 16,693 functions, of which only 1,081 carry names, and those are all import thunks. Rust monomorphisation accounts for most of the count. Strings are the map here — the src/ panic paths and the agent’s own dense telemetry vocabulary lead directly into user code, which occupies roughly 0x140080000–0x140220000.

3.1 What it is not: no credential theft

Because of the folder label, this was tested first and directly. Every classic Chromium/Gecko stealer artifact was searched for across ASCII and UTF-16 strings:

ArtifactHits
Login Data0
os_crypt0
encrypted_key0
v10 / v20 (ABE prefixes)0
Web Data0
credit_card0
autofill0
key4.db / cert9.db0
logins.json / signons / moz_logins0

The single Cookies match is the HTTP header name inside the http crate’s static header table.

rusqlite is present, but the only SQL the sample’s own code issues is against its own state database (section 3.3). It never opens a browser profile database.

3.2 DPAPI is used defensively

CryptUnprotectData in a stealer means browser secrets. Here it does not. The strings that surround the call sites are:

private-key self-test failed
generated key could not be parsed after DPAPI round trip
DPAPI private-key round trip mismatch
key generation failed

The agent generates an enrollment keypair, seals the private key with CryptProtectData, and verifies the round trip. This is the agent protecting its own identity material at rest — the opposite of credential theft, and the single most misleading indicator in the file if read from the import table alone.

3.3 Enrollment and the agent lifecycle

The program runs as a supervisor/runtime pair (--supervisor, --adopt-runtime, --supervisor-pid) on a tokio runtime, with eight named workers:

connection_supervisor      media_connection_supervisor
job_worker                 reconciliation_worker
startup_repair_worker      managed_apps_worker
remote_sessions_worker     runtime_heartbeat_worker

State lives in a local SQLite database with a hand-written schema:

CREATE TABLE IF NOT EXISTS agent_state(key TEXT PRIMARY KEY,value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS configuration(key TEXT PRIMARY KEY,value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS received_jobs(...);
CREATE TABLE IF NOT EXISTS completed_jobs(...);
CREATE TABLE IF NOT EXISTS pending_outbox(...);
CREATE TABLE IF NOT EXISTS managed_apps(...);
CREATE TABLE IF NOT EXISTS installed_software(...);

Jobs are persisted on receipt, acknowledged by id, and replayed after restart — pending_outbox survives reboots and network loss. This is durable, at-least-once command delivery, not a beacon loop.

Enrollment is a POST to https://<server>/api/agent/enroll. The server is not hardcoded — it is supplied at enrollment time and stored in configuration, so there is no C2 string to extract from the file. The request and response fields recover cleanly from the serde field table:

enrollment_token  installation_id  owner  country  os
agent_version     public_key       capabilities
-> key_id  server_ip  nonce  issued_at  expires_at
   ca_pem  ca_sha256  signature  device_id

The control channel is then a WebSocket over mutual TLS with a pinned CA (root-ca.crt, verified by ca_sha256), plus a separate media channel supervised independently. There is even a CA-recovery path (bridge-ca-recovery, key id recovery-2026-08) for rotating a compromised root.

The capability set the agent advertises during enrollment:

virtual_desktop_coordinates   scoped_clipboard      job_object_cleanup
resumable_transfer            two_way_recovery      job_object
managed_process_supervision   verified_identity     liveness_challenge
windows_application_trust     network_change_abort  windows_resume_abort
machine_update_install        machine_msi_install

sub_1400b0340 is the agent’s initialisation routine, and it is the one function in this binary that defeated decompilation outright — roughly 37 KB of tokio async state machine that kuna could not finish in 25 minutes at default settings, nor in 9 minutes under --mode fast. Its control flow was reconstructed instead from its 742 outgoing references, resolved against .rdata and ordered by call site. The resulting sequence:

agent.initialization.begin
  create  packages/  cache/  apps/  logs/
  open    agent.db                      -> database.open.ready
  check   enrollment.existing_identity
  load    root-ca.crt
            present -> tls.ca.local_present recovery_mode=on_certificate_failure
            absent  -> tls.ca.missing signed_recovery_required=true
  if no identity:
    enrollment.required
    enrollment.bootstrap.begin
    tls.ca.bootstrap_verified sha256=<hash>     <- CA accepted only on hash match
    enrollment.request.send  -> POST /api/agent/enroll
    enrollment.response.status=<code>
    tls.ca.binding verified=true changed=<bool>
    INSERT INTO configuration(key,value)        <- device_id, device_name
    enrollment.complete device=<id>
  spawn 8 workers                               -> agent.workers.started
  ...
  agent.stop.requested / agent.worker.unexpected_exit worker=<name>
  agent.shutdown.complete

Two details matter. First, the os and agent_version fields sent during enrollment are compiled in as the adjacent literals Windows and 0.4.21. Second, there is a second endpoint I had not seen from strings alone: /api/agent/ca-recovery, reached when an enrolled agent’s stored CA stops validating. It fetches a signed recovery document and checks it thoroughly — CA recovery nonce mismatch, CA recovery server mismatch: expected , CA recovery document has expired, CA recovery document is issued too far in the future, CA recovery signature verification failed — under key id bridge-ca-recovery-2026-08, and rate-limits the attempt (signed CA recovery is rate limited for ). Identity recovery is separately rate-limited and can be permanently blocked by the server: identity.revoked terminal_retry_state=true automatic_reenrollment_blocked=true.

The only crypto primitive linked from ring is Ed25519 (ec/curve25519/ed25519/signing.rs) plus SHA-256/512 — consistent with the agent holding an Ed25519 identity key, signing liveness challenges with it (liveness_challenge, invalid liveness challenge identity), and verifying the recovery document’s signature.

A note on a false lead, because it is the kind that ends up in a report: the binary contains exactly one hardcoded 64-hex constant, 2af157d77fb1304a74176eaee7fbc7c7e932d946bf25325e9c26c91db19e3079. It is not operator key material — it is the bundled SQLite FTS5 source id (fts5: 2025-06-28 14:00:48 …). There is no pinned CA hash and no embedded public key compiled into this build; the trust root is bootstrapped at enrollment and verified against a hash supplied with it.

None of this is what commodity malware looks like. It is genuinely well-built software, which is precisely why the next section matters.

3.4 Backstage: the hidden desktop

CreateDesktopW is referenced exactly once, inside sub_1401f6ce0, next to the string “creating private Backstage desktop / binding Backstage worker to private desktop”:

0x1401f6e20   MOV  RCX, qword ptr [RBX + 0x4b0]      ; lpszDesktop  (see below)
0x1401f6e27   MOV  qword ptr [RSP + 0x28], 0x0       ; lpsa
0x1401f6e30   MOV  dword ptr [RSP + 0x20], 0x10000000 ; dwDesiredAccess = GENERIC_ALL
0x1401f6e38   XOR  EDX, EDX                          ; lpszDevice
0x1401f6e3a   XOR  R8D, R8D                          ; pDevmode
0x1401f6e3d   XOR  R9D, R9D                          ; dwFlags
0x1401f6e40   CALL qword ptr [0x1404f86a8]           ; CreateDesktopW
0x1401f6e46   MOV  qword ptr [RBX + 0x160], RAX      ; store HDESK
0x1401f6e4d   TEST RAX, RAX
0x1401f6e50   JZ   0x1401f6fb4
0x1401f6e56   MOV  RCX, RAX
0x1401f6e59   CALL qword ptr [0x1404f8620]           ; SetThreadDesktop

A new desktop, GENERIC_ALL, bound to the worker thread. Applications are then launched on that desktop, captured from it, and driven by injected input. The victim’s visible desktop shows nothing.

The input primitive is sub_1401a0050 — a clean send_key(vk: u16, up: bool):

0x1401a006c   ADD  EAX, EAX                     ; flags = up * 2  (KEYEVENTF_KEYUP)
0x1401a006e   MOV  dword ptr [RBP + 0x8],  0x1  ; INPUT.type = INPUT_KEYBOARD
0x1401a0075   MOV  word  ptr [RBP + 0x10], CX   ; ki.wVk
0x1401a0079   MOV  word  ptr [RBP + 0x12], 0x0  ; ki.wScan
0x1401a007f   MOV  dword ptr [RBP + 0x14], EAX  ; ki.dwFlags
0x1401a009a   MOV  R8D, 0x28                    ; cbSize = sizeof(INPUT)
0x1401a00a0   CALL qword ptr [0x1404f8658]      ; SendInput(1, &input, 0x28)

Capture runs through GetDC → CreateCompatibleDC → StretchBlt / PrintWindow (“capturing desktop frame”, “opening desktop device context”), JPEG-encodes in memory, and streams over the media WebSocket.

Now the application catalogue this subsystem is built to drive:

explorer, chrome, edge, brave, opera, yandex, notepad, telegram, outlook, slack, discord, signal, exodus, ledger-live, trezor-suite, electrum, sparrow, atomic, guarda, monero-gui, bitcoin-core

with resolved paths including Ledger Live/Ledger Live.exe, Trezor Suite/Trezor Suite.exe, bin/Sparrow.exe, Wallet/monero-wallet-gui.exe, Bitcoin/bitcoin-qt.exe.

There is no benign reading of running Ledger Live and Trezor Suite on an invisible desktop while injecting keystrokes into them. Combined with the backstage-beta-finance release channel, this is the intended use, not an incidental capability.

The desktop’s name is built by format! from the literal at 0x1405084a9, length 0x10, with a session identifier clamped to 40 characters appended:

BridgeBackstage-<session id>

Worth correcting if you are writing a detection: the prefix is BridgeBackstage-, not Backstage-. A rule keyed on the shorter string still matches as a substring, but the precise literal is the longer one.

The function saves the original desktop with GetThreadDesktop(GetCurrentThreadId()) before switching and restores it on teardown (SetThreadDesktop then CloseDesktop). SwitchDesktop is never called anywhere in the binary — the agent never brings its private desktop to the screen. That is the difference between a remote-desktop tool and this: the victim’s input desktop is deliberately left untouched.

3.5 The capture pipeline

sub_140187cc0 builds a reusable capture context, and it decompiles cleanly:

v3 = GetDC(0);                              // DC of the *current* desktop
v4 = CreateCompatibleDC(v3);
// scale = min(1920.0/src_w, 1200.0/src_h, 1.0)   -- fit, never upscale
v8  = CONCAT44(out_w, 0x28);                // BITMAPINFOHEADER.biSize = 40
v25 = -(int4)out_h;                         // biHeight negative -> top-down DIB
v26 = 0x200001;                             // biPlanes = 1, biBitCount = 32
v27 = (out_w * out_h) << 2;                 // biSizeImage
v5 = CreateDIBSection(v3, &v8, 0, &v9, 0, 0);   // v9 = raw pixel pointer
v6 = SelectObject(v4, v5);
SetStretchBltMode(v4, 4);                   // HALFTONE

GetDC(0) is the whole trick. Because the calling thread was bound to the private desktop by SetThreadDesktop, the “entire screen” DC it returns is the hidden desktop’s screen, not the victim’s. The frame geometry caps are real doubles in .rdata: 1920.0 and 1200.0, with a 1.0 ceiling on the scale factor, so output is downscaled to fit 1920×1200 and never enlarged. (The neighbouring -2147483648.0 / 2147483647.0 are Rust’s saturating as i32 bounds, not resolution limits.) SetStretchBltMode(HALFTONE) is the highest-quality downscale GDI offers — a deliberate choice, since the operator has to read text in the result. CreateDIBSection hands back a direct pointer to the pixels, so no GetDIBits copy is needed.

The per-frame blit lives in sub_1401cf850, which — like the enrollment routine — was too large to decompile (killed after ~50 minutes). From disassembly:

0x1401d5030   MOV  dword ptr [RSP + 0x50], 0x40cc0020   ; SRCCOPY | CAPTUREBLT
0x1401d504e   CALL qword ptr [0x1404f8090]              ; StretchBlt

CAPTUREBLT (0x40000000) asks GDI to include layered and overlapping windows in the blit — which is what the backstage.capture.layers telemetry reports on. The source rectangle is read from the context rather than fixed, so region capture is supported (backstage.capture.geometry mode=).

There is a second, window-scoped path that does not use StretchBlt at all:

0x14010ffde   CALL qword ptr [0x1404f8080]   ; CreateDIBSection
0x140110004   CALL qword ptr [0x1404f8088]   ; SelectObject
0x140110017   MOV  R8D, 0x2                  ; nFlags = PW_RENDERFULLCONTENT
0x140110024   CALL RSI                       ; PrintWindow(hwnd, hdcMem, 2)
0x140110034   XOR  R8D, R8D                  ; nFlags = 0
0x140110037   CALL RSI                       ; PrintWindow(hwnd, hdcMem, 0)  -- fallback

PW_RENDERFULLCONTENT is the specific flag needed to capture a window whose content is composited by the GPU — Chromium-based browsers and Electron apps return a blank bitmap without it. On a desktop that is never presented to a display, that covers essentially every target in the catalogue. The capture.status values initializing and private-window name this mode.

Frames are encoded with jpeg-encoder (encoding remote JPEG frame), and the mode field carries two register-packed literals:

MOV EDX, 0xa                              ; len 10
MOV RCX, 0x6c65642d6765706a               ; "jpeg-del"
MOV word ptr [RAX + 0x8], 0x6174          ; "ta"      -> "jpeg-delta"

MOV RCX, 0x656d61726679656b               ; "keyframe"

So the media channel is not a naive frame dump — it distinguishes keyframes from deltas, which is why backstage.capture.mode exists as a reported field.

3.6 The input pipeline

Input arrives by two independent mechanisms, and the reason there are two is the hidden desktop.

The first is SendInput. sub_1401a0050 is a clean send_key(vk: u16, up: bool):

unsigned long long sub_1401a0050(unsigned short a0, uint1 a1)
{
  v26   = (uint4)a1 * 2;   // dwFlags = up ? KEYEVENTF_KEYUP : 0
  v1[0] = 1;               // INPUT.type = INPUT_KEYBOARD
  v24   = a0;              // ki.wVk
  v25   = 0;               // ki.wScan
  v27   = 0;               // ki.time
  v28   = 0;               // ki.dwExtraInfo
  if (SendInput(1, v1, 0x28) == 1)
    return 0;
  ...  // "injecting keyboard input"
}

Virtual-key codes, no scancode, no KEYEVENTF_UNICODE. The mouse counterpart sub_1401aeb30 is more interesting — it normalises coordinates against a caller-supplied rectangle:

dx = clamp(x - rect.left, 0, w-1) * 0xFFFF / (w - 1)
dy = clamp(y - rect.top,  0, h-1) * 0xFFFF / (h - 1)
// INPUT.type = 0 (INPUT_MOUSE); mi.mouseData = a1; mi.dwFlags = a0
SendInput(1, &input, 0x28)

That 0–65535 normalisation is the MOUSEEVENTF_ABSOLUTE virtual-desktop coordinate space, and it is precisely the virtual_desktop_coordinates capability the agent advertises at enrollment.

The second mechanism is PostMessageW, and sub_1401b8d70 builds the lParam by hand:

v9 = MapVirtualKeyW(vk, 4);                 // MAPVK_VK_TO_VSC_EX
// extended-key flag (0x01000000) if the scancode is extended, or vk is one of:
//   0x21 PRIOR  0x22 NEXT   0x23 END    0x24 HOME
//   0x25 LEFT   0x26 UP     0x27 RIGHT  0x28 DOWN
//   0x2D INSERT 0x2E DELETE 0x5B LWIN   0x5C RWIN
//   0xA3 RCONTROL            0xA5 RMENU
lparam  = flags | ((scancode & 0xff) << 16);
if (alt_down) lparam |= 0x20000000;         // KF_ALTDOWN  -> WM_SYSKEY*
if (repeat)   lparam |= 0x40000000;         // KF_REPEAT
if (key_up)   lparam |= 0xC0000000;         // KF_UP | KF_REPEAT

Then the focus dance, and this is where the hidden desktop bites:

v6 = GetCurrentThreadId();
if (target_tid != v6 && target_tid) {
  if (!AttachThreadInput(v6, target_tid, 1)) {
    // diagnostics, formatted into one telemetry line:
    v19 = GetThreadDesktop(v6);
    v20 = GetThreadDesktop(target_tid);
    v21 = (v19 == v20);                     // same_desktop
    v22 = GetGUIThreadInfo(target_tid, &gui) != 0;
    v23 = GetLastError();
  }
}

producing:

backstage.input.attach_failed current_thread= target_thread= target=
  current_desktop= target_desktop= same_desktop= target_queue= error=

A developer does not instrument same_desktop= on an input-attach failure unless they have spent real time fighting cross-desktop input. Focus is then taken with BringWindowToTop → SetActiveWindow → SetFocus, and modifier state is forced with GetKeyboardState / SetKeyboardState.

SetForegroundWindow is never called — it has zero references in the binary. That is the tell. SetForegroundWindow would fight the victim for the visible desktop’s foreground; AttachThreadInput plus SetActiveWindow/SetFocus confines the manipulation to the private desktop’s input queue. The same caution explains the oddly candid telemetry string “Win32 dispatch accepted; queue acceptance alone does not confirm a visual effect” — PostMessageW returns success whether or not the target window ever acts on the message, and the author knew it.

3.7 Taking browser profiles by force

To open a browser on a chosen profile, the agent must first break the profile lock held by the user’s running instance. It escalates in three documented stages:

Gracefully closing browser windows on the user desktop
Force-closing remaining browser processes
Nuclear-closing all browser processes in the endpoint Windows session

backed by tasklist.exe /FI ... /FO CSV /NH for enumeration and taskkill.exe /PID <pid> /T /F for termination, with telemetry at each step (browser.close_scan, browser.force_wave, browser.nuclear_wave, browser.profile_unlocked, browser.process_respawned).

Profiles are enumerated from Local State, /profile/info_cache, /profile/last_used, and profiles.ini, across Google/Chrome/User Data, BraveSoftware/Brave-Browser/User Data, Yandex/YandexBrowser/User Data, Opera Software/Opera Stable, Mozilla/Firefox, Microsoft/Edge/User Data — including avatar images (Google Profile Picture.png, Avatar.png), which is profile presentation for an operator picker, not credential parsing.

The distinction is worth stating plainly: the agent does not read the password database. It closes your browser, reopens your logged-in profile on a desktop you cannot see, and drives it. Session cookies never leave the machine because they never need to.

3.8 Persistence, and the disguise it just shed

Persistence has two halves, and the first one was missed on the initial pass because it lives in the install path rather than the startup path. The agent copies itself to a canonical location — C:\ProgramData\BridgeAgent, with packages\, cache\, apps\ and logs\ beneath it — and stops any previous instance before replacing it:

install.copy.begin source=            install.copy.complete destination=
install.staged path=                  install.running_from_canonical_path
activating canonical Bridge Agent     copying canonical Bridge Agent
install.old_canonical_stop.success    install.old_canonical_stop.nonzero exit=
install.old_canonical_busy error=     install.old_canonical_removed_after_stop attempt=
existing canonical Bridge Agent remained locked after stop request:

with taskkill /IM used to release a locked predecessor, and the developer-facing guard cannot replace a running canonical executable with the same filename; close the existing BridgeAgent.exe once and rerun this build. A first-run migration marker legacy-migration-v1.complete sits alongside agent.db, and instances coordinate through named objects prefixed Local\BridgeAgent-.

C:\ProgramData matters for privilege: its default ACL grants CREATOR OWNER full control over newly created subdirectories, so an unprivileged user can create and populate C:\ProgramData\BridgeAgent without elevation. Nothing in this build requires administrator rights to install or persist.

The second half is an HKCU Run key written via reg.exe:

reg ADD HKCU\Software\Microsoft\Windows\CurrentVersion\Run
    /v BridgeAgent /t REG_SZ /d "<path> --supervisor" /f

with telemetry startup.hkcu_run.ensure → startup.hkcu_run.ready / .failed exit= / .error=.

The same function then performs cleanup, and this is the most revealing string in the file. It builds %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup and deletes two things from it:

Dropbox.exe          -> startup.legacy_dropbox_removed
Bridge Agent.cmd     -> startup.folder.legacy_launcher_removed

An earlier build of this family persisted as a Startup-folder executable named Dropbox.exe. Legitimate software does not name its launcher after an unrelated commercial product. This is a masquerade the current version is migrating away from — and it is the point at which the “well-engineered management agent” reading stops being tenable.

3.9 Asking Windows whether it has been caught

The agent shells out to PowerShell with an embedded script ($env:BRIDGE_TRUST_EXE holds its own path):

$ErrorActionPreference='SilentlyContinue';
$exe=$env:BRIDGE_TRUST_EXE;
$sig=Get-AuthenticodeSignature -LiteralPath $exe;
$sigStatus=if($sig){[string]$sig.Status}else{'Unknown'};
$publisher=if($sig -and $sig.SignerCertificate){[string]$sig.SignerCertificate.Subject}else{''};
$mp=Get-MpComputerStatus;
$tamper=if($null -ne $mp -and $null -ne $mp.IsTamperProtected){...};
$source='Local';
if(Test-Path 'HKLM:\SOFTWARE\Microsoft\PolicyManager\current\device\Defender'){$source='MDM'}
elseif(Test-Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender'){$source='Group Policy'};
$onboard=(Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows Advanced Threat Protection\Status' `
          -Name OnboardingState -ErrorAction SilentlyContinue).OnboardingState;
if($onboard -eq 1 -and $source -eq 'Local'){$source='Defender for Endpoint'};
$detection='Unknown';
if(Get-Command Get-MpThreatDetection -ErrorAction SilentlyContinue){
  $matches=@(Get-MpThreatDetection | Where-Object { (($_.Resources | Out-String) -like ('*'+$exe+'*')) });
  $detection=if($matches.Count -gt 0){'Detection observed'}else{'None observed'}
};
[ordered]@{signature_status=$sigStatus;publisher=$publisher;tamper_protection=$tamper;
           management_source=$source;defender_detection=$detection}|ConvertTo-Json -Compress

Read charitably, this is compliance reporting: telling an administrator that AV is quarantining the agent. Read as what it is — an unsigned binary that persists under a shed disguise and runs wallets on a hidden desktop — the last query is the operative one. Get-MpThreatDetection filtered to its own path answers exactly one question: has Defender flagged me yet? The surrounding fields tell the operator whether the host is EDR-onboarded and whether Tamper Protection would resist an exclusion being added.

Notably, there is no VM or sandbox evasion anywhere in the sample. The lone IsDebuggerPresent import comes from Rust’s standard library panic path. Whoever built this was not worried about automated sandboxes; they were worried about Defender specifically.

4. Malware Capabilities

  • Command and Control:
    • HTTPS enrollment (POST /api/agent/enroll), then a WebSocket control channel and an independent media channel
    • Mutual TLS with a pinned CA (ca_sha256), agent private key sealed with DPAPI, plus a CA-recovery path
    • No hardcoded C2 — the server address is enrollment-supplied and stored in the local configuration table
    • Durable at-least-once job delivery via received_jobs / completed_jobs / pending_outbox; liveness challenges and agent.liveness.control_probe_ack
  • Remote Control:
    • remote.open / remote.attach / remote.detach / remote.close / remote.session
    • remote.screen / remote.frame — GDI capture of the private desktop (SRCCOPY|CAPTUREBLT) or of a single window (PrintWindow + PW_RENDERFULLCONTENT), downscaled to fit 1920×1200, JPEG-encoded as keyframe or jpeg-delta over the media channel
    • remote.control / input.* — dual path: SendInput (virtual-key; mouse normalised to the 0–65535 virtual-desktop space) and PostMessageW with hand-built lParam; focus via AttachThreadInput → BringWindowToTop → SetActiveWindow → SetFocus, modifiers forced with SetKeyboardState. SetForegroundWindow deliberately unused.
    • remote.terminal — cmd.exe and powershell.exe -NoLogo -NoProfile -NonInteractive -Command, with working_directory and timeout_seconds; terminal replay buffers
  • Defense Evasion / Masquerading:
    • Legacy persistence as Dropbox.exe in the Startup folder (removed by this build)
    • Unsigned, with no version resource, icon, or manifest
    • Self-directed Defender detection query, Tamper Protection and EDR-onboarding reconnaissance
    • No VM/sandbox checks and no anti-debug of its own
  • Persistence:
    • Self-install to C:\ProgramData\BridgeAgent (packages\, cache\, apps\, logs\), replacing and terminating any previous instance; no elevation required
    • HKCU\Software\Microsoft\Windows\CurrentVersion\Run value BridgeAgent, argument --supervisor
    • Supervisor/runtime process pair with mutual restart; startup_repair_worker; bridge-supervisor-heartbeat; named objects under Local\BridgeAgent-
  • Collection:
    • Full-desktop and per-window capture, including from the private desktop
    • Clipboard read and write (clipboard.copy, clipboard.paste, GetClipboardSequenceNumber polling) — the write primitive supports address substitution
    • Software inventory (inventory.collect, installed_software)
  • Exfiltration / Ingress:
    • file.list, file.stat, file.copy, file.move, file.rename, file.delete, file.mkdir, file.hash, file.drives
    • file.upload / file.download with chunked, resumable transfer (transfer_id, upload_path, download_path, file.upload.commit, download.verified)
  • Execution / Impact:
    • software.install — MSI and EXE, per-user and machine scope, script_execution, with WinVerifyTrust on packages
    • managed_apps process supervision with restart counts and Job Object cleanup
    • Forced browser termination up to session-wide taskkill /T /F
  • Targeting:
    • Browsers: Chrome, Edge, Brave, Opera, Yandex, Firefox (profile enumeration and forced unlock)
    • Messengers: Telegram, Slack, Signal, Discord, Outlook
    • Wallets: Exodus, Ledger Live, Trezor Suite, Electrum, Sparrow, Atomic, Guarda, Monero GUI, Bitcoin Core

What it does not do: read browser credential databases, decrypt DPAPI blobs belonging to the user, harvest cookies to disk, or exfiltrate a stealer archive. There is no grabber and no config-driven file sweep.

5. Indicators of Compromise

TypeValue
SHA256d49785f0195374536fa826a9f2228da105b80fa7bff5d8bee24c7260c665a09e
SHA101adc9b0769c2bcd81bac32d23ff9776c1a3eab4
MD5ea7aeeebfda6f39a868a6ba491695737
Build stringbridge-agent-v0.4.21-backstage-beta-finance+5c0922233f932a844540b4e1adf8fb38f92a452a08a00455e21cdd42426c1519
Product / versionBridge Agent 0.4.21, subsystem Backstage
Canonical image nameBridgeAgent.exe
Install rootC:\ProgramData\BridgeAgent (subdirs packages\, cache\, apps\, logs\)
State databaseagent.db, agent.db-wal (SQLite, WAL mode)
State filebridge-state (with .new-* temp siblings)
Named object prefixLocal\BridgeAgent-
Migration markerlegacy-migration-v1.complete
Run keyHKCU\Software\Microsoft\Windows\CurrentVersion\Run → value BridgeAgent
Legacy persistence%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\Dropbox.exe
Legacy launcher%APPDATA%\...\Startup\Bridge Agent.cmd
Desktop nameBridgeBackstage-<session id> (via CreateDesktopW)
Job objectBackstage Job Object
Heartbeat namebridge-supervisor-heartbeat
Environment variableBRIDGE_TRUST_EXE
Enrollment pathPOST https://<enrollment-supplied host>/api/agent/enroll
CA recovery path/api/agent/ca-recovery
CA artifactsroot-ca.crt, bridge-ca-recovery, key ids recovery-2026-08 / bridge-ca-recovery-2026-08
Frame modeskeyframe, jpeg-delta
Capture cap1920×1200, 32 bpp top-down DIB, SRCCOPY|CAPTUREBLT
Process arguments--supervisor, --adopt-runtime, --supervisor-pid
Build pathC:\Users\Admin\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\

A note on hunting: there is no network IoC to block. The C2 address is supplied at enrollment and stored locally, so no domain or IP exists in the file. Detection has to be host-based. The highest-fidelity behavioural signal is CreateDesktopW followed by process creation of a browser or wallet binary bound to the new desktop — that sequence has almost no legitimate occurrence on a workstation. Second best is the HKCU Run value BridgeAgent, the install root C:\ProgramData\BridgeAgent\ with its agent.db, and taskkill /T /F waves against every installed browser in quick succession. At the API level, the pairing worth alerting on is CreateDesktopW + SetThreadDesktop in a process that never calls SwitchDesktop, followed by PrintWindow with nFlags=2 against a browser window — legitimate screen-sharing software switches or shares the visible desktop; this deliberately does not.

6. Analysis Limitations

Five things could not be resolved statically, and it is worth separating properties of the sample from gaps in the analysis:

  1. Two functions would not decompile. sub_1400b0340 (agent initialisation, ~37 KB tokio state machine) timed out after 25 minutes at default settings and again after 9 minutes under --mode fast; sub_1401cf850 (per-frame capture and encode) was killed after ~50 minutes. Both were reconstructed from disassembly and their reference graphs, which is sufficient for the API sequences and constants reported above but does not give the full control flow. Anyone continuing this should carve them with --define-function sub-ranges rather than attacking them whole.
  2. The C2 address is genuinely not in the binary. This is a design property, not a missing decryption step. Recovering it requires either an enrollment token from the delivery chain or a host with a populated configuration table.
  3. The job type vocabulary is partially register-packed. Short command names (file.lis, file.cop, file.dow) appear as 8-byte inline constants rather than pooled strings, so the full list in section 4 is reconstructed from fragments plus the handler telemetry. The set is right; individual spellings may differ by a character.
  4. Whether the endpoint user is ever prompted is unresolved. The sample imports WTSSendMessageW and carries app.consent.request / .pending / .result and browser.profiles.consent.request, but no dialog title or body text exists in the file. Those consent messages travel between the agent and the operator console; whether any of them surface to the victim cannot be determined without a live server. The backstage.beta.* gate with policy=fail_closed is a launch-safety check against an existing instance, not user consent.
  5. Attribution is open. The engineering quality — mTLS enrollment, CA pinning, durable job queues, package signature verification, and unusually candid audit telemetry (“Win32 dispatch accepted; queue acceptance alone does not confirm a visual effect”) — is consistent with a commercial RMM product or a mature red-team implant, and this build is dated the day it was received. I could not tie Bridge Agent / Backstage to any published product.

That last point deserves an honest closing statement rather than a confident label. The architecture argues for legitimacy; four findings argue decisively against it: a shed Dropbox.exe disguise; hardware-wallet applications driven on a hidden desktop under a release channel named finance; a capture path built specifically around PW_RENDERFULLCONTENT to defeat the blank bitmap Chromium returns on a desktop that is never displayed; and the deliberate avoidance of SetForegroundWindow — zero references in the whole binary — so that focus never visibly moves on the victim’s own desktop. Those last two are not features a management product needs; they are only needed if the endpoint user must not notice. Where those conflict, intent is read from the capability that was deliberately built, not from the quality of the scaffolding around it.

Treat as a remote access trojan with hVNC financial-fraud tooling. The RustyStealer label should not be applied to this binary — not because the sample is benign, but because it sends responders looking for the wrong artifacts entirely. The label does belong to the other sample from this folder, which earns it; what needs retiring is the assumption that one folder holds one family.