home / malware / runkey-beacon-loader
Masqueraded .NET loader with Run-key persistence and a fixed-interval beacon
2026-11-04 · self-authored specimen · Windows 11 23H2, isolated range
At a glance
- Question
- What does a minimal, user-land loader look like in host and network telemetry — and which of those signals survive a rebuild that changes every hash?
- Specimen
wm-loader, a 38 KB unsigned .NET 8 assembly written for this study. Copies itself, persists without administrator rights, beacons once, then idles. Its command handler accepts three inert verbs and can run nothing else.- Platform
- Windows 11 23H2 (22631), x64, virtualised guest with a snapshot taken before the first run.
- Tooling
- dnSpy for static analysis, Sysmon 15.15 for host telemetry, Zeek 7.0 on the range sensor, CyberChef to decode the config blob, Sigma for the rule.
- Environment
- Default-deny VLAN with no route out; a stub command server on
10.20.30.40. No real credentials, no real data, reverted to snapshot after each run. See the isolated research range. - Status
- COMPLETE — two runs, one Windows build, one sensor.
- Artifacts
- None. The specimen is withheld under the sanitization policy; what is published is the telemetry, the analysis and the rule.
Objective and scope
Loaders are the part of an intrusion that a SOC analyst meets first: something small, unsigned and unremarkable runs once on a workstation, arranges to run again, and then talks to the internet. This study builds the smallest specimen that does those three things and asks what a defender can see.
In scope: the persistence mechanism, the masquerading choice, the beacon's timing, and the TLS metadata left behind. Out of scope: evasion against EDR, privilege escalation, lateral movement, and any kind of code injection. The specimen has no capability in those directions, so any conclusion about them would be about a different piece of software. Deliberately not tested: behaviour when the C2 is unreachable, and behaviour with a non-zero jitter setting.
Laboratory environment
written before the runThree hosts, one flat monitored VLAN with no gateway. The guest cannot reach anything except the sensor's span port and the stub server, and the stub server can reach nothing outside the range.
The blind spots matter as much as the coverage: nothing sees the loader's memory after the config is decrypted, PowerShell script-block logging is irrelevant because the specimen is .NET rather than a script, and no EDR product was in the path, so none of the conclusions below should be read as a claim about EDR detection.
| Host | Role | Telemetry |
|---|---|---|
lab-win11 | Analyst-controlled guest, local administrator for the run | Sysmon 15.15: process create (1), file create (11), registry set (13), network connect (3), DNS (22). Windows Security 4688 with command-line auditing. No Defender real-time telemetry: protection is disabled in the range by design. |
lab-sensor | Zeek 7.0 on the VLAN span port | conn, http, ssl, dns. No packet capture beyond headers, so no payload reconstruction inside TLS. |
lab-c2 | Stub command server, 10.20.30.40:8443 | Its own request log, used only to confirm the beacon arrived. No second stage exists on it. |
Methodology
- The specimen was written and built outside the range, then transferred in over a read-only share so no build tooling exists on the guest. The source was not analysed until after the first run, so the host telemetry was interpreted without knowing the intended order of operations.
- Snapshot revert, then a single execution from
C:\Users\lab\Desktop\. The guest was left idle for 30 minutes with no other user activity, so any beacon in the window belongs to the specimen. - Host events were exported as XML and network logs collected from the sensor. The run was repeated from the same snapshot: the second run reproduced the same sequence at the same interval, which is why the timing claim below is labelled corroborated rather than observed once.
- Static analysis followed: strings, the base64 blob, and the decompiled
Mainin dnSpy. Where static analysis is the only source for a claim, the evidence block says so.
Evidence and observations
one record per claimTimestamps are UTC on 2026-11-03. Records are reproduced as captured and sanitised: paths and addresses are the range's own, and no hash is published because the specimen is withheld.
EV-001The specimen runs once from the desktop, unsigned, with no command-line argumentsFACT
Sysmon 1 2026-11-03 09:41:07.214 Image C:\Users\lab\Desktop\wm-loader.exe CommandLine "C:\Users\lab\Desktop\wm-loader.exe" ParentImage C:\Windows\explorer.exe User LAB\lab Company - Signed false Integrity Medium
Interpretation. A double-click from Explorer, not a service or a scheduled task. Nothing in this record distinguishes the specimen from any unsigned utility a user might run, which is the point: the first event of the chain carries almost no signal on its own.
EV-002It writes a copy of itself into AppData under a Windows Update service nameFACT
Sysmon 11 2026-11-03 09:41:07.902 Image C:\Users\lab\Desktop\wm-loader.exe TargetFilename C:\Users\lab\AppData\Roaming\WinUpdate\wuauserv.exe CreationUtcTime 2026-11-03 09:41:07.887 Contents copy of the running image
Interpretation. wuauserv is the Windows Update service, and the real one never exists as an image: it runs inside svchost.exe from System32. An executable with that name under %APPDATA% is therefore anomalous by construction, but the record proves only that the file was created. It does not prove the copy is later executed — EV-003 and EV-004 are needed for that.
EV-003A logon-run value is created that points at the copy, with no administrator rightsFACT
Sysmon 13 2026-11-03 09:41:07.918 Image C:\Users\lab\Desktop\wm-loader.exe EventType SetValue TargetObject HKCU\Software\Microsoft\Windows\CurrentVersion\Run\WinUpdate Details "C:\Users\lab\AppData\Roaming\WinUpdate\wuauserv.exe" User LAB\lab
Interpretation. HKCU is writable by its owner, so persistence needs no elevation and no service creation. The value name WinUpdate and the target path agree with EV-002, which is what makes the pair corroborate persistence rather than leave it as two unrelated writes.
EV-004The guest opens one HTTPS connection to the stub server and posts a 176-byte bodyCORROBORATED
Sysmon 3 2026-11-03 09:41:09.006 Image C:\Users\lab\AppData\Roaming\WinUpdate\wuauserv.exe Destination 10.20.30.40:8443 Protocol tcp Initiated true zeek http.log ts 2026-11-03T09:41:09.118 method POST host 10.20.30.40 uri /api/v2/beacon user_agent Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 request_body_len 176 status_code 200 resp_mime_types application/json
Interpretation. Two independent records agree that the copy, not the desktop binary, made the connection, which is what ties the file write to the execution. The user agent is ordinary Chrome; taken alone it is worthless as a signal, and it is included here to show why the rule below is built on the registry write instead.
EV-005TLS is a self-signed certificate with no SNI, and the client fingerprint is stable across both runsFACT
zeek ssl.log server_name - subject CN=c2.lab.internal issuer CN=c2.lab.internal validation_status self signed certificate ja3 6f0d8e1a9c3b4f27d05a8e6b1c9347d2 ja3s 2d1f7c0b8e4a9635cf17b0d2e6a85413 version TLSv12 cipher TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
Interpretation. Self-signed is expected on a lab stub and says nothing about the specimen's own validation: static analysis (EV-007) shows the client accepts any certificate, so this handshake would have completed against a spoofed server too. The JA3 value identifies the .NET HTTP stack rather than the specimen, so it is a weak, version-dependent pivot, not a detection.
EV-006Twenty beacons in thirty minutes: a fixed 90-second interval with no jitterCORROBORATED
$ zeek-cut ts uri < http.log | grep -c '/api/v2/beacon' 20 gaps between consecutive requests (seconds) 90.02 89.97 90.11 89.94 90.03 90.06 89.99 90.08 ...
Interpretation. The interval is exact to within one scheduler tick, which is unusual: most commodity implants jitter. That precision makes a periodicity rule viable against this specimen, and it is also a warning — a rebuild with jitter set to anything above zero would defeat it, which is why the rule below does not depend on timing.
EV-007Static analysis: the config blob decrypts to a URL, a sleep value and three permitted verbsFACT
dnSpy, Main():
DecodeConfig() base64 -> AES-256-CBC, key = SHA256("WM-Lab-2026"), IV embedded
Persist() File.Copy(self) then Registry.CurrentUser Run\WinUpdate
Loop() POST config.url, sleep config.sleep, dispatch cmd
decoded config (inert text):
{"url":"https://10.20.30.40:8443/api/v2/beacon",
"sleep":90,"jitter":0,"max_retry":3,
"commands":["noop","sleep","exit"]}
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
No process creation, no download, no shell, no injection API in the assembly.Interpretation. The three-verb allow-list bounds what this specimen can do: it can idle, change its own sleep interval or exit. That is a property of this build, not of loaders generally, and the findings table says so rather than generalising from it. Certificate validation is disabled, so no network control that depends on a trusted certificate would have stopped the beacon.
Findings
| Finding | Evidence | Confidence |
|---|---|---|
User-level persistence via HKCU\...\Run needs no elevation, and the write happens within a second of first execution. | EV-002, EV-003 | CORROBORATED |
The persisted copy is named after a Windows Update component and lives outside System32, where that name cannot legitimately exist. | EV-002 | INFERENCE |
| The beacon interval is fixed at 90 seconds with zero jitter in the default build. | EV-004, EV-006 | CORROBORATED |
| TLS certificate validation is disabled and the certificate is self-signed, so certificate trust is not a control here. | EV-005, EV-007 | FACT |
| This build cannot spawn a process, download a file or inject into another process, so it says nothing about what a real loader does next; the claim stops at this specimen. | EV-007 | FACT |
| Command and control traffic is HTTP over TLS to a hard-coded IP with an ordinary browser user agent; there is no domain to block. | EV-004, EV-007 | FACT |
Detection
The durable signal in this chain is the registry write: it survives a rebuild that changes the binary, the path, the URL and the interval. The rule targets the shape rather than the name, because a rebuild would rename the value and the copy.
title: Logon Run Key Points At A User-Writable Directory
id: 0b1c4f2a-7d3e-4a91-8c55-6e2f9d1a4b70
status: experimental
description: A logon-run value whose executable lives under AppData or ProgramData, the usual per-user persistence path for a loader.
references:
- https://attack.mitre.org/techniques/T1547/001/
author: WMouton
date: 2026/11/04
tags:
- attack.persistence
- attack.t1547.001
logsource:
category: registry_set
product: windows
detection:
selection_key:
TargetObject|endswith:
- '\Software\Microsoft\Windows\CurrentVersion\Run'
- '\Software\Microsoft\Windows\CurrentVersion\Run\*'
- '\Software\Microsoft\Windows\CurrentVersion\RunOnce\*'
selection_path:
Details|contains:
- '\AppData\Roaming\'
- '\AppData\Local\'
- '\ProgramData\'
condition: selection_key and selection_path
falsepositives:
- Per-user installers and updaters that register themselves at logon (Teams, Slack, Zoom, Spotify)
- Sign-in helpers and peripheral utilities installed per user
level: mediumThe second, cheaper hunt is on the masquerade: a process whose image name is wuauserv.exe but whose path is not C:\Windows\System32. The same query works for any service name that never exists as an image, because those services all run inside svchost.exe. In Sysmon 1 terms, Image|endswith: '\wuauserv.exe' with a path under a user profile or ProgramData is worth an alert in its own right, and it needs no threat intelligence or hash list.
The table below is where those signals stop, which is the part a rule review should read first. If this rule graduates to the published detection set, it belongs there rather than duplicated here; this page is the reasoning behind it, not a second copy of the rule library.
| Signal | Data source | What it cannot see |
|---|---|---|
| Run-key value pointing into a user-writable directory | Sysmon 13, or the registry auditing equivalent in EDR | Persistence through scheduled tasks, services, WMI subscriptions or startup folders; a loader that installs no persistence at all |
Service-named image running outside System32 | Sysmon 1 / Security 4688 | A copy given an unremarkable, non-system name; every one of those is a FALSE negative that a rename produces |
| Fixed-interval beacon to one host | Zeek conn or a proxy log, over a long window | Jitter, sleeping hours and low-and-slow intervals; anything under the volume threshold of the query |
Mitigation
what removes the behaviour, and what only raises the costRemoving the behaviour. Application control (WDAC or AppLocker) that denies execution from %APPDATA%, %LOCALAPPDATA% and ProgramData for standard users stops the persisted copy from starting at all, which is a stronger position than alerting after it has. The rule above is the fallback for hosts where that policy is not yet in place.
Raising the cost. Egress filtering that denies direct-to-IP HTTPS from workstations gives a hard-coded address nowhere to go. Under that control this specimen would still run and persist; it would stop beaconing, and the persistence would be left for the host-side rule to find rather than for the network to see.
Validation and reproducibility
Everything below was run against the published records and the range logs, and re-run after the second execution. The counts are the check: if a rebuild changes the interval or the persistence method, they stop matching.
# sensor: beacon count and spacing over the 30-minute window
$ zeek-cut ts uri < http.log | grep -c '/api/v2/beacon'
20
$ zeek-cut ts < http.log | grep -B1 '/api/v2/beacon' | awk 'p{print $1-p} {p=$1}' | sort -u | head -3
89.94
90.02
90.11
# guest: the persistence write is present exactly once
PS> Get-WinEvent -LogName 'Microsoft-Windows-Sysmon/Operational' |
Where-Object { $_.Id -eq 13 -and $_.Message -match 'WinUpdate' } |
Measure-Object | Select-Object -ExpandProperty Count
1
# guest: the persisted copy exists and is unsigned
PS> Get-AuthenticodeSignature "$env:APPDATA\WinUpdate\wuauserv.exe" |
Select-Object -ExpandProperty Status
NotSignedThe drift check is the count: a rebuild with jitter above zero keeps roughly the same number of connections but loses the constant spacing, and a build that switched to a scheduled task produces one registry event fewer and one task-creation event more. Both show up in the same three commands, which is what makes them usable on a specimen nobody can download.
Limitations and open questions
- OPEN The specimen supports a jitter setting and has never been run with it above zero. Whether a jittered rebuild defeats the periodic query is untested, and the rule above is written not to depend on the answer.
- DISPROVEN The first hypothesis was DNS tunnelling, because the IP was hard-coded and that usually implies name resolution somewhere. Zeek
dns.logrecords four queries in the window, allA/AAAAfor the range resolver and the update host, noTXT, noNULL. The hypothesis was wrong and is kept here so the conclusion is not read as "no covert channel was tested for". - BENIGN A per-user Teams install on the same guest writes an
HKCU\...\Runvalue pointing at%LOCALAPPDATA%\Microsoft\Teams\current\Teams.exeand triggers the rule above. It is the first false positive and it cannot be resolved by the registry record alone: the distinction is that an installer wrote it, which is a different parent process and a different time of day. - OPEN No memory capture was taken while the config was decrypted, so the claim about the plaintext config rests on decompilation (EV-007) and not on a live record. A RAM acquisition would settle where the clear-text key lives at rest.
- Two runs, one Windows build, one sensor, no EDR in the path. Behaviour that depends on the build, the OS version or a security product is outside what this evidence supports.
References
- MITRE ATT&CK T1547.001 Registry Run Keys / Startup Folder.
- MITRE ATT&CK T1036.005 Masquerading: Match Legitimate Name or Location.
- MITRE ATT&CK T1071.001 Application Layer Protocol: Web Protocols.
- Sysmon event reference, and the Zeek SSL analyser fields used above.
- Sigma specification for the rule format.
- On this site: the isolated research range this ran in, the sanitization policy that keeps the specimen offline, and the detection set.
Tags
#malware#windows#dotnet#persistence#detection