Creating a Protected Process Light in Rust for Sanctum EDR

Ever wish your code had its very own bouncer? Welcome to Protected Process Light—where only the VIPs (and properly signed DLLs) get past the velvet rope, leaving malware forever outside!


Intro

To see this used in my project, check it out on GitHub!

Before getting into the technical detail of implementing Protected Process Light (PPL) in Rust, lets quickly examine why we need this.

Since Windows 8.1, Microsoft implemented a new (extension of) security mechanism in the kernel which is designed to better protect against malware and other bad activity. This extension allows antimalware developers / vendors to join the Protected Process party, which means only legitimate, trusted signed code can be loaded into the process, which defends against injection attacks.

Once we opt into the PPL, we can only use a subset of libraries, including Windows DLL’s, so we want to use this carefully. I would imagine adding this to a Tauri application for example would fail as it will depend on a lot of different system libraries, which may or may not be correctly signed. I’ll talk about an example I came across with this later.

Why do we want to use PPL? Well, aside from being protected from code injection and memory reads, it gives us access to the exclusive Event Tracing for Windows: Threat Intelligence provider, which will give us significantly more telemetry than what we are ordinarily able to hook into via the driver.

I drew heavily on a blog post on tofile.dev to get this whole PPL service running, so big thanks to that blog post, the author does a good job of talking through the process. He writes that in C, so hopefully this will be a nice Rust compliment to that.

Here is a preview of the results of doing this, you can see our service and a child process both running as PPL, in the context of SYSTEM.

Windows Protected Process Light

Windows Protected Process Light

Early Launch Antimalware

In order for us to get a Protected Process Light to interact with the Threat Intelligence provider, we first need our driver to be an ELAM (Early Launch Antimalware) driver which is essentially a flag we can specify on a code signing certificate. In the real world, these are issued by Microsoft. We are obviously developing for fun and learning purposes, so we can’t do that. What we can do however, is generate our own self signed certificate with ELAM specified.

Generating the certificate

So, the first thing we want to do, is generate our ELAM certificate. I have a PowerShell script that will do this for us:

$ErrorActionPreference = "Stop"

# certificate parameters
$CertSubject = "CN=Sanctum ELAM Cert"
$CertStore = "Cert:\CurrentUser\My"
$PfxPath = ".\sanctum.pfx"
$CerPath = ".\sanctum.cer"
$CertPassword = "password" # todo change this for prod

Write-Host "[i] Creating a new self-signed ELAM certificate..."

# https://github.com/microsoft/Windows-driver-samples/tree/main/security/elam
$Cert = New-SelfSignedCertificate -Subject $CertSubject `
    -CertStoreLocation $CertStore `
    -HashAlgorithm SHA256 `
    -TextExtension @("2.5.29.37={text}1.3.6.1.4.1.311.61.4.1,1.3.6.1.5.5.7.3.3")

Write-Host "[+] Certificate created: $($Cert.Thumbprint)"

# password to secure string
$PasswordSecure = ConvertTo-SecureString -String $CertPassword -Force -AsPlainText

# export the cert to a PFX file
Write-Host "[+] Exporting certificate to PFX file: $PfxPath"
Export-PfxCertificate -Cert $Cert -FilePath $PfxPath -Password $PasswordSecure

Generating the resource file

With the certificated generated, we now need to pull out a hash from the certificate, and store this in a resource which will be linked into the driver. Note: up until this point we have been using the standard Windows driver building process in Rust (via cargo make) but we now need to diverge from this.

Instead of writing a resource file to disk to link, we can do this as part of our build.rs script, essentially this will be linked into the driver:

// in build.rs
let elam_rc_content = r#"MicrosoftElamCertificateInfo  MSElamCertInfoID
{
    1,                        
    L"903E531C8BEF7C2D631BA6927206B073238F0F4489512DDDE267F2DC2FD51DCC\0", // CHANGE THIS TO YOUR HASH
    0x800C,                   
    L"\0"                     
}"#;

On the line I have commented CHANGE THIS TO YOUR HASH you need to change this to be a hash from your certificate. To get this, you can sign your driver (I have bat script for this, and it must be run from the Developer Command Prompt, and change the paths below to reflect your environment):

@echo off
setlocal

:: paths
set DRIVER_PATH=target\debug\sanctum_package\sanctum.sys
set PFX_FILE=sanctum.pfx
set PFX_PASSWORD=password

:: remove WDK test cert from driver
echo Removing WDK test signature from %DRIVER_PATH%...
signtool remove /s "%DRIVER_PATH%"
if %ERRORLEVEL% NEQ 0 (
    echo [ERROR] Failed to remove WDK signature.
    exit /b 1
)

:: sign the driver with sanctum.pfx
echo Signing %DRIVER_PATH% with %PFX_FILE%...
signtool.exe sign /fd SHA256 /v /ph /f "%PFX_FILE%" /p "%PFX_PASSWORD%" "%DRIVER_PATH%"
if %ERRORLEVEL% NEQ 0 (
    echo [ERROR] Failed to sign the driver.
    exit /b 1
)

echo [SUCCESS] Driver signed successfully!

endlocal
exit /b 0

Once signed, run:

# Run this, and you will be able to find your hash
certmgr.exe -v path/to/driver.sys

Somewhere near the bottom, you will see Content Hash (To-Be-Signed Hash)::, above a sequence of bytes. These bytes (with no spaces) form your hash, so take this and put it into CHANGE THIS TO YOUR HASH in the build script.

Any time your certificate changes, you will need to update the hash in that field.

Updating the build script

So, now that we have a driver signed with an ELAM certificate we need to update our build.rs to properly build and link. If you were using the default Windows build script provided in the Windows Drivers project, this will be a minor amendment.

// build.rs

use std::fs::write;
use std::process::Command;
use std::env;

fn main() -> Result<(), wdk_build::ConfigError> {
    println!("Starting build process...");

    // Generate the ELAM `.rc` file dynamically
    let elam_rc_content = r#"MicrosoftElamCertificateInfo  MSElamCertInfoID
    {
        1,                        
        L"903E531C8BEF7C2D631BA6927206B073238F0F4489527DDDE267F2DC2FD51DCC\0", // To-Be-Signed Hash
        0x800C,                   
        L"\0"                     
    }"#;

    let out_dir = env::var("OUT_DIR").expect("OUT_DIR is not set");
    let elam_rc_path = format!("{}/elam.rc", out_dir);
    let elam_res_path = format!("{}/elam.res", out_dir);

    println!("Writing ELAM resource file: {}", elam_rc_path);
    write(&elam_rc_path, elam_rc_content).expect("Failed to write elam.rc");

    // Compile the `.rc` file into `.res``
    println!("Compiling ELAM resource file...");
    let rc_status = Command::new("rc")
        .args(&["/fo", &elam_res_path, &elam_rc_path])
        .status()
        .expect("Failed to execute rc.exe");

    if !rc_status.success() {
        panic!("Failed to compile ELAM resource file");
    }

    println!("Linking ELAM resource into the driver...");
    println!("cargo:rustc-link-arg={}", elam_res_path);

    // Configure wdk binary
    println!("Configuring WDK binary build...");
    wdk_build::configure_wdk_binary_build()?;

    Ok(())
}

Every time you build the driver with cargo make you will now have to re-sign the driver with the new certificate (the bat script above, make sure to do it from the Developer Command Prompt as admin).

Installing the Antimalware service

Now we have that, we can crack on with creating an Antimalware service. First, we need an installer binary which will install the service with the correct flags to enable it to run as an Antimalware PPL.

The installer program is as follows. Edit the strings etc as required, note that sanctum_ppl_runner.exe is the service binary we will create in the next step:

use windows::{core::PCWSTR, Win32::{Storage::FileSystem::{CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_READ_DATA, FILE_SHARE_READ, OPEN_EXISTING}, System::{Antimalware::InstallELAMCertificateInfo, Services::{ChangeServiceConfig2W, CreateServiceW, OpenSCManagerW, SC_MANAGER_ALL_ACCESS, SERVICE_CONFIG_LAUNCH_PROTECTED, SERVICE_DEMAND_START, SERVICE_ERROR_NORMAL, SERVICE_LAUNCH_PROTECTED_ANTIMALWARE_LIGHT, SERVICE_LAUNCH_PROTECTED_INFO, SERVICE_WIN32_OWN_PROCESS}}}};

fn main() {
    //
    // Step 1:
    // Install the ELAM certificate via the driver (.sys) file.
    //
    println!("[i] Starting Elam installer..");

    let mut path: Vec<u16> = vec![];
    r"C:\Users\flux\AppData\Roaming\Sanctum\sanctum.sys".encode_utf16().for_each(|c| path.push(c));
    path.push(0);

    // todo un hanrdcode this
    let result = unsafe {
        CreateFileW(
            PCWSTR(path.as_ptr()),
            FILE_READ_DATA.0, 
            FILE_SHARE_READ, 
            None, 
            OPEN_EXISTING, 
            FILE_ATTRIBUTE_NORMAL, 
            None,
        )
    };

    let handle = match result {
        Ok(h) => h,
        Err(e) => panic!("[!] An error occurred whilst trying to open a handle to the driver. {e}"),
    };

    if let Err(_) = unsafe { InstallELAMCertificateInfo(handle) } {
        panic!("[!] Failed to install ELAM certificate.");
    }

    println!("[+] ELAM certificate installed successfully!");


    //
    // Step 2:
    // Create a service with correct privileges
    //

    println!("[i] Attempting to create the service.");
    let result = unsafe {
        OpenSCManagerW(
            PCWSTR::null(), 
            PCWSTR::null(), 
            SC_MANAGER_ALL_ACCESS,
        )
    };

    let h_sc_mgr = match result {
        Ok(h) => h,
        Err(e) => panic!("[!] Unable to open SC Manager. {e}"),
    };

    // create an own process service
    
    let result = unsafe {
        CreateServiceW(
            h_sc_mgr, 
            PCWSTR(svc_name().as_ptr()),
            PCWSTR(svc_name().as_ptr()),
            SC_MANAGER_ALL_ACCESS, 
            SERVICE_WIN32_OWN_PROCESS, // Service that runs in its own process
            SERVICE_DEMAND_START, 
            SERVICE_ERROR_NORMAL, 
            PCWSTR(svc_bin_path().as_ptr()), 
            PCWSTR::null(), None, PCWSTR::null(), PCWSTR::null(), PCWSTR::null()
        )
    };

    let h_svc = match result {
        Ok(h) => h,
        Err(e) => panic!("[!] Failed to create service. {e}"),
    };

    let mut info= SERVICE_LAUNCH_PROTECTED_INFO::default();
    info.dwLaunchProtected = SERVICE_LAUNCH_PROTECTED_ANTIMALWARE_LIGHT;
    
    if let Err(e) = unsafe { ChangeServiceConfig2W(
        h_svc, 
        SERVICE_CONFIG_LAUNCH_PROTECTED, 
        Some(&mut info as *mut _ as *mut _)
    )} {
        panic!("[!] Error calling ChangeServiceConfig2W. {e}");
    }

    println!("[+] Successfully initialised the PPL AntiMalware service. It now needs staring with `net.exe start sanctum_ppl_runner`");

    
}

fn svc_name() -> Vec<u16> {
    let mut svc_name: Vec<u16> = vec![];
    "sanctum_ppl_runner".encode_utf16().for_each(|c| svc_name.push(c));
    svc_name.push(0);

    svc_name
}

fn svc_bin_path() -> Vec<u16> {
    let mut svc_path: Vec<u16> = vec![];
    // todo not hardcode
    r"C:\Users\flux\AppData\Roaming\Sanctum\sanctum_ppl_runner.exe".encode_utf16().for_each(|c| svc_path.push(c));
    svc_path.push(0);
    svc_path
}

As per tofile.dev, “InstallElamCertificateInfo Will look in the MSElamCertInfoID resource, check the certificate is valid, and install it. The driver can be anywhere on disk, and once the certificate is installed, we don’t need it again, and don’t need to actually run it. However in testsigning mode this step needs to be repeated if the machine reboots.”

Creating the service binary

If you want to look at the crate (within the Sanctum project) specifically for the service, check it here.

Build flags

Before we get going with the service, we need to build this with some additional configuration. Because we can only load ELAM / Microsoft correctly signed binaries, we need to statically link against the C runtime, allowing these to be dynamically loaded will result in errors when you run your service.

Note: This also needs to be applied to child processes you launch as PPL.

The cargo.toml should look like:

[package]
name = "your_package_name"
version = "0.1.0"
edition = "2024"
build = "build.rs"

[profile.release]
strip = true
lto = true
codegen-units = 1

And you should add a build script as follows:

// build.rs

fn main() {
    // Disable conflicting CRT libraries to prevent dynamic linking.
    println!("cargo:rustc-link-arg=/NODEFAULTLIB:libvcruntimed.lib");
    println!("cargo:rustc-link-arg=/NODEFAULTLIB:vcruntime.lib");
    println!("cargo:rustc-link-arg=/NODEFAULTLIB:vcruntimed.lib");
    println!("cargo:rustc-link-arg=/NODEFAULTLIB:libcmtd.lib");
    println!("cargo:rustc-link-arg=/NODEFAULTLIB:msvcrt.lib");
    println!("cargo:rustc-link-arg=/NODEFAULTLIB:msvcrtd.lib");
    println!("cargo:rustc-link-arg=/NODEFAULTLIB:libucrt.lib");
    println!("cargo:rustc-link-arg=/NODEFAULTLIB:libucrtd.lib");

    // Explicitly add static runtime libraries.
    println!("cargo:rustc-link-arg=/DEFAULTLIB:libcmt.lib");
    println!("cargo:rustc-link-arg=/DEFAULTLIB:libvcruntime.lib");
    println!("cargo:rustc-link-arg=/DEFAULTLIB:ucrt.lib");
}

The service

A note on running a PPL service - you won’t get a nice terminal window to see debug messages in. PPL processes run in a security context that isolates them from user interaction. This means exposing a UI (like a terminal window, or a MessageBox) would undermine the security benefits.

This makes debugging a little painful. With a PPL, you can’t attach a normal debugger, even if you are running as admin (as PPL will prevent even administrator access), but you can attach a kernel debugger to the process.

If you want point and shoot debugging, you can emit debug messages to the Windows Event Viewer. You can see my function in the snippet below which does log messages to the Event Viewer (event_log(“Starting SanctumPPLRunner service.”, EVENTLOG_INFORMATION_TYPE);). I’m not including the code for this function in this blog post, if you want to check it out, look in my source file here.

Important - Once your service binary is built, ensure the service and installer are build in release mode, and the service and driver are both signed with the ELAM certificate. If you need help signing the binary, check the next section where I have a bat scripts to do this.

Once all built and signed, run the installer in your VM with an admin shell, then sc.exe start your_service_name to start the service.

A bare bones service will need:

static SERVICE_STOP: AtomicBool = AtomicBool::new(false);

/// The service entrypoint for the binary which will be run via powershell / persistence
#[unsafe(no_mangle)]
pub unsafe extern "system" fn ServiceMain(_: u32, _: *mut PWSTR) {
    // register the service with SCM (service control manager)
    let h_status = match unsafe {RegisterServiceCtrlHandlerW(
        PCWSTR(svc_name().as_ptr()), 
        Some(service_handler)
    )} {
        Ok(h) => h,
        Err(e) => panic!("[!] Could not register service. {e}"),
    };

    // notify SCM that service is starting
    unsafe { update_service_status(h_status, SERVICE_START_PENDING.0) };

    // start the service main loop
    run_service(h_status);
}


/// Main service execution loop
fn run_service(h_status: SERVICE_STATUS_HANDLE) {
    unsafe {
        update_service_status(h_status, SERVICE_RUNNING.0);

        //
        // Ensure we have a registry key so we can write to the Windows Event Log
        //
        let _ = create_event_source_key();

        event_log("Starting SanctumPPLRunner service.", EVENTLOG_INFORMATION_TYPE);

        // event loop
        while !SERVICE_STOP.load(Ordering::SeqCst) {
            sleep(Duration::from_secs(1));
        }

        update_service_status(h_status, SERVICE_STOPPED.0);
    }
}

/// Handles service control events (e.g., stop)
unsafe extern "system" fn service_handler(control: u32) {
    match control {
        SERVICE_CONTROL_STOP => {
            SERVICE_STOP.store(true, Ordering::SeqCst);
        }
        _ => {}
    }
}

/// Update the service status in the SCM
unsafe fn update_service_status(h_status: SERVICE_STATUS_HANDLE, state: u32) {
    let mut service_status = SERVICE_STATUS {
        dwServiceType: SERVICE_WIN32_OWN_PROCESS,
        dwCurrentState: SERVICE_STATUS_CURRENT_STATE(state),
        dwControlsAccepted: if state == SERVICE_RUNNING.0 { 1 } else { 0 },
        dwWin32ExitCode: ERROR_SUCCESS.0,
        dwServiceSpecificExitCode: 0,
        dwCheckPoint: 0,
        dwWaitHint: 0,
    };

    unsafe {let _ = SetServiceStatus(h_status, &mut service_status); }
}

fn main() {
    let mut service_name: Vec<u16> = "SanctumPPLRunner\0".encode_utf16().collect();
    
    let service_table = [
        SERVICE_TABLE_ENTRYW {
            lpServiceName: PWSTR(service_name.as_mut_ptr()),
            lpServiceProc: Some(ServiceMain),
        },
        SERVICE_TABLE_ENTRYW::default(),
    ];

    unsafe {
        StartServiceCtrlDispatcherW(service_table.as_ptr()).unwrap();
    }
}

fn svc_name() -> Vec<u16> {
    let mut svc_name: Vec<u16> = vec![];
    "sanctum_ppl_runner".encode_utf16().for_each(|c| svc_name.push(c));
    svc_name.push(0);
    
    svc_name
}

Signing the service

This bat script will help you sign the service, again, run it from the Developer Command Prompt as admin:

@echo off
setlocal

:: paths
set SERVICE_BINARY=target\release\sanctum_ppl_runner.exe
set PFX_FILE=driver\sanctum.pfx
set PFX_PASSWORD=password

:: Check if signtool.exe is available
for /f "delims=" %%A in ('where signtool 2^>nul') do set SIGNTOOL_PATH=%%A

if not defined SIGNTOOL_PATH (
    echo [ERROR] signtool.exe not found. Ensure Windows SDK is installed.
    exit /b 1
)

:: Verify that the PFX file exists
if not exist "%PFX_FILE%" (
    echo [ERROR] Certificate file %PFX_FILE% not found.
    exit /b 1
)

:: Verify that the binary exists
if not exist "%SERVICE_BINARY%" (
    echo [ERROR] Service binary %SERVICE_BINARY% not found.
    exit /b 1
)

:: Sign the service binary
echo Signing %SERVICE_BINARY% with %PFX_FILE%...
"%SIGNTOOL_PATH%" sign /fd SHA256 /v /f "%PFX_FILE%" /p "%PFX_PASSWORD%" "%SERVICE_BINARY%"
if %ERRORLEVEL% NEQ 0 (
    echo [ERROR] Failed to sign the service binary.
    exit /b 1
)

:: Verify the signature
echo Verifying signature on %SERVICE_BINARY%...
"%SIGNTOOL_PATH%" verify /pa /v "%SERVICE_BINARY%"
if %ERRORLEVEL% NEQ 0 (
    echo [ERROR] Signature verification failed.
    exit /b 1
)

echo [SUCCESS] Service binary signed successfully!

endlocal
exit /b 0

Launching a child process

To launch a child process as PPL from the PPL service, we need to ensure the child process is signed with the ELAM certificate, and any dependencies are also valid and signed by Windows.

I had difficulties calling panic!() in this section, which was a little weird seeing as there is another panic in the boilerplate above (perhaps it is optimised out). The issue was the service was trying to load fcon.dll from C:\Windows\System32; this binary isn’t correctly signed by Microsoft (it has no pagehashes in its signature), so it is not allowed to be loaded into the process. To get around this, I log errors through Event Viewer and call std::process:exit().

In the snippet, the important part really is the flags we create the process with: EXTENDED_STARTUPINFO_PRESENT | CREATE_PROTECTED_PROCESS.

Here is a function you may wish to use in your service binary to run a child process as PPL (again the logging functions are not included in this post, but you can find them on my GitHub repo for Sanctum here):

/// Spawns a child process as Protected Process Light.
/// 
/// **Note** The child process MUST be signed with the ELAM certificate, and any DLLs it relies upon must either 
/// be signed correctly by Microsoft including the pagehashes in the signature, or signed by the ELAM certificate used
/// to sign this, and the child process.
fn spawn_child_ppl_process() {
    let mut startup_info = STARTUPINFOEXW::default();
    startup_info.StartupInfo.cb = size_of::<STARTUPINFOEXW>() as u32;
    let mut attribute_size_list: usize = 0;

    let _ = unsafe { InitializeProcThreadAttributeList(
        None,
        1, 
        None,
        &mut attribute_size_list) };

    if attribute_size_list == 0 {
        event_log("Error initialising thread attribute list", EVENTLOG_ERROR_TYPE);
        std::process::exit(1);
    }

    let mut attribute_list_mem = vec![0u8; attribute_size_list];
    startup_info.lpAttributeList = LPPROC_THREAD_ATTRIBUTE_LIST(attribute_list_mem.as_mut_ptr() as *mut _);

    if let Err(_) = unsafe { InitializeProcThreadAttributeList(
        Some(startup_info.lpAttributeList),
        1,
        None,
        &mut attribute_size_list) } {
            event_log("Error initialising thread attribute list", EVENTLOG_ERROR_TYPE);
            std::process::exit(1);
    }

    // update protection level to be the same as the PPL service
    let mut protection_level = PROTECTION_LEVEL_SAME;
    if let Err(e) = unsafe { UpdateProcThreadAttribute(
        startup_info.lpAttributeList, 
        0, 
        PROC_THREAD_ATTRIBUTE_PROTECTION_LEVEL as _,
        Some(&mut protection_level as *mut _ as *mut _),
        size_of_val(&protection_level), 
        None, 
        None,
    ) } {
        event_log(&format!("Error UpdateProcThreadAttribute, {}", e), EVENTLOG_ERROR_TYPE);
        std::process::exit(1);
    }

    // start the process
    let mut process_info = PROCESS_INFORMATION::default();
    // todo update this
    let path: Vec<u16> = r"C:\Users\flux\AppData\Roaming\Sanctum\etw_consumer.exe"
        .encode_utf16()
        .chain(std::iter::once(0))
        .collect();

    if let Err(e) = unsafe { CreateProcessW(
        PCWSTR(path.as_ptr()), 
        None,
        None, 
        None, 
        false, 
        EXTENDED_STARTUPINFO_PRESENT | CREATE_PROTECTED_PROCESS,
        None, 
        PCWSTR::null(), 
        &mut startup_info as *mut _ as *const _,
        &mut process_info,
    ) } {
        event_log(&format!("Error calling starting child PPL process via CreateProcessW, {}", e), EVENTLOG_ERROR_TYPE);
        std::process::exit(1);
    }

    event_log("SanctumPPLRunner started child process.", EVENTLOG_SUCCESS);
}

Results

And, as you can see, we now get both sanctum_ppl_runner (the service running as PPL), and a child process etw_consumer, which is also PPL. When looking at the user context for which they are running, they are both running as SYSTEM. Cool!

Windows Protected Process Light

Windows Protected Process Light

Finally, again a big thanks to the blog at tofile.dev, it really helped piece this process together!