ExfilGuardian
Backend

Agent & Installer

Windows service agent, DeviceIoControl loop, SQLite WAL queue, and deployment

Overview

The old CLI/TUI has been replaced by two deployment-focused crates:

  • exfil-agent (agent/): A Windows service that reads packets from the kernel driver via DeviceIoControl, queues them in SQLite WAL, and streams them to the server via gRPC
  • exfil-installer (installer/): A standalone binary that writes registry configuration and creates Windows services via sc.exe

Both crates compile only on Windows. On other platforms, they print an error message and exit.

Agent (exfil-agent)

Dual-Mode Execution

The agent supports two execution modes:

pub fn run() -> Result<()> {
    // Try to start as a Windows Service (SCM dispatcher)
    let res = service_dispatcher::start("ExfilAgent", ffi_service_main);
    if res.is_err() {
        // If not launched by SCM, fall back to console/debug mode
        let rt = tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .build()?;
        return rt.block_on(run_agent());
    }
    Ok(())
}
  • Service mode: When started by the Service Control Manager (e.g., sc start ExfilAgent), the agent registers with SCM, reports ServiceState::Running, and handles stop signals
  • Console mode: When launched directly from a command prompt, the agent runs interactively for development and debugging

Startup Sequence

  1. Read registry configuration: Reads HKLM\SOFTWARE\ExfilGuardian\TargetUrl to get the gRPC server address
  2. Connect to kernel driver: Tries multiple device paths to open a handle to \\.\ExfilGuardian:
let paths = [
    c"\\\\.\\ExfilGuardian",
    c"\\\\?\\ExfilGuardian",
    c"\\\\.\\Global\\ExfilGuardian",
    c"\\\\?\\Global\\ExfilGuardian",
    c"\\\\.\\DosDevices\\ExfilGuardian",
    c"\\\\?\\DosDevices\\Global\\ExfilGuardian",
];

The agent retries up to 10 times with 1-second intervals in case the driver has not finished initializing.

  1. Initialize SQLite WAL queue: Opens (or creates) a local SQLite database at exfil_queue.db with WAL journal mode for concurrent read/write
  2. Start gRPC streaming task: Spawns a Tokio task that connects to the server and streams queued packets
  3. Enter IOCTL loop: Blocks on DeviceIoControl(IOCTL_EXFIL_GET_PACKET), waiting for the kernel to deliver intercepted packets

Kernel Communication

The agent mirrors the driver's ExfilPacket struct:

#[repr(C)]
#[derive(Clone, Copy)]
pub struct ExfilPacketKernel {
    pub protocol: u8,
    pub padding: [u8; 3],
    pub local_ip: u32,
    pub remote_ip: u32,
    pub local_port: u16,
    pub remote_port: u16,
    pub payload_len: u32,
    pub payload: [u8; 4096],
}

Each DeviceIoControl call blocks until the driver completes an IRP with a packet. The returned ExfilPacketKernel is converted to a Protobuf NetworkPacket and inserted into the SQLite queue.

SQLite WAL Queue

The agent uses SQLite in WAL (Write-Ahead Logging) mode as a persistent packet queue. This ensures no data loss if the gRPC connection drops:

CREATE TABLE IF NOT EXISTS packets (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    data BLOB NOT NULL
);
  • Producer thread: The IOCTL loop serializes each NetworkPacket to Protobuf bytes and inserts them into the packets table
  • Consumer task: A Tokio task reads up to 50 packets at a time (ordered by id ASC), sends them through the gRPC stream, and deletes them on successful transmission
  • Wake mechanism: An mpsc::channel notifies the consumer when new packets are available, avoiding unnecessary polling

gRPC Client

The agent connects to the server using tonic:

let endpoint = tonic::transport::Channel::from_shared(target_url)?;
let channel = endpoint.connect().await?;
let mut client = ExfilServiceClient::new(channel);

client.stream_packets(ReceiverStream::new(rx_proxy)).await?;

If the connection drops, the agent:

  1. Keeps queuing packets locally in SQLite
  2. Logs the disconnection
  3. Retries connection every 5 seconds
  4. On reconnect, drains the SQLite queue in order

Dependencies

CratePurpose
tokioAsync runtime (multi-thread)
tonicgRPC client
prostProtobuf serialization
rusqliteSQLite WAL queue
windows-sysWin32 API (CreateFileA, DeviceIoControl, GetLastError)
windows-serviceSCM integration (define_windows_service!, service_dispatcher)
winregWindows Registry access
rustlsTLS (ring crypto provider)

Installer (exfil-installer)

The installer is a standalone binary for manual deployment (as opposed to the automated enrollment via the REST API).

Usage

exfil-installer.exe <GRPC_SERVER_URL>

Example:

exfil-installer.exe http://192.168.1.50:50051

What It Does

  1. Registry configuration: Creates HKLM\SOFTWARE\ExfilGuardian and sets the TargetUrl value:
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
let (key, _) = hklm.create_subkey("SOFTWARE\\ExfilGuardian")?;
key.set_value("TargetUrl", &target_url)?;
  1. Kernel driver service: Stops and removes any existing ExfilGuardian service, then creates a new one:
sc create ExfilGuardian binPath= "C:\path\to\exfil_guardian.sys" type= kernel start= demand
  1. Agent service: Stops and removes any existing ExfilAgent service, then creates a new one:
sc create ExfilAgent binPath= "C:\path\to\exfil-agent.exe" start= auto
  1. Start services: Starts the kernel driver first, then the agent:
sc start ExfilGuardian
sc start ExfilAgent

Installer vs. Enrollment

FeatureInstaller (exfil-installer)Enrollment (/api/v1/enroll)
Triggered byManual CLI executionOne-liner PowerShell from server
Certificate managementNone (manual)Auto-generates per-agent mTLS cert
Binary distributionExpects binaries in current directoryDownloads from server /static
Driver installationsc.exe createpnputil.exe /add-driver (INF/CAT)
Registry protectionNo driver cert setupImports driver cert to trusted stores
Use caseDevelopment / testingProduction deployment at scale

Logging

The agent writes log output to a file at the same path as the executable with a .log extension. This is necessary because Windows services cannot write to stdout:

macro_rules! service_log {
    ($($arg:tt)*) => {{
        let mut exe_path = std::env::current_exe()?;
        exe_path.set_extension("log");
        let mut f = OpenOptions::new().create(true).append(true).open(exe_path)?;
        writeln!(f, "[{}] {}", unix_timestamp, format_args!($($arg)*))?;
    }};
}

Log entries are timestamped with Unix seconds.

On this page