Mitigating broadcast spoofs with Ghost Hunting
Don't bother bringing an EMF detector, this EDR has you covered from ghosts!
Intro
If you are unaware of my Ghost Hunting technique, check my previous post where I explain my thought process.
So, whilst designing the Ghost Hunting technique for the EDR; I have thought of one obvious bypass that a malware developer could easily implement.
Up until now, we have two signal sources - the driver, and an injected process. Ghost Hunting works by comparing the two signals to make sure that it was indeed our hooked syscall that made the syscall - if the driver emits an event but we have no matching syscall hook signal, then we know a process is doing something heavily indicative of stealthy malware.
The bypass
So, as a malware author - you could bypass the Ghost Hunting technique by having your malware:
- Emit an IPC message to the EDR engine, with the same structure as what the syscall hook would broadcast, essentially spoofing the message.
- Immediately following this, you make the syscall to the kernel via direct syscalls / hells gate etc, so our actual hooked function never runs.
And thus, the EDR would treat this as a ‘failed’ Ghost Hunt - i.e. we didn’t find evidence of bad behaviour as we received 2 signals.
The solution
The solution for this is fairly simple, Windows has an API we can tap into when we receive the IPC message, GetNamedPipeClientProcessId.
This function allows us to pass in a handle to the pipe instance, and get the PID (process ID) which communicated over the named pipe.
We can take this a step further, using a trait in Rust to ensure that all Syscall message structures must implement a get_pid()
function, which
will force us to include the PID of the process when we broadcast the syscall data to the engine from the hooked process.
Then, on the receiving end on the server, we can check if the SyscallMessage.pid == PID that sent the pip. If these are not the same; we know spoofing has taken place and we can quarantine that process / deal with it as we see fit.
Refactoring our code to include this trait:
pub trait HasPid {
fn get_pid(&self) -> u32;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyscallData<T: HasPid> {
pub inner: T,
}
/// Wrap a syscall message with an enum so that we can send messages between the process we have hooked and our EDR engine.
///
/// # Note
/// Each struct within the Syscall enum **MUST** contain the pid which it came from; which is required to ensure the integrity
/// of the Ghost Hunting process. This is enforced via the HasPid trait.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Syscall {
OpenProcess(SyscallData<OpenProcessData>),
VirtualAllocEx(SyscallData<VirtualAllocExData>)
}
impl Syscall {
pub fn get_pid(&self) -> u32 {
match self {
Syscall::OpenProcess(syscall_data) => syscall_data.inner.pid,
Syscall::VirtualAllocEx(syscall_data) => syscall_data.inner.pid,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenProcessData {
pub pid: u32,
pub target_pid: u32,
}
impl HasPid for OpenProcessData {
fn get_pid(&self) -> u32 {
self.pid
}
}
/// Data relating to arguments / local environment information when the hooked syscall ZwAllocateVirtualMemory
/// is called by a process.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VirtualAllocExData {
/// The base address is the base of the remote process which is stored as a usize but is actually a hex
/// address and will need converting if using as an address.
pub base_address: usize,
/// THe size of the allocated memory
pub region_size: usize,
/// A bitmask containing flags that specify the type of allocation to be performed.
pub allocation_type: u32,
/// A bitmask containing page protection flags that specify the protection desired for the committed
/// region of pages.
pub protect: u32,
/// The pid in which the allocation is taking place in
pub remote_pid: u32,
/// The pid of the process calling VirtualAllocEx
pub pid: u32,
}
impl HasPid for VirtualAllocExData {
fn get_pid(&self) -> u32 {
self.pid
}
}
And then in our hooked syscall stubs, we wrap the signal properly (using ZwOpenProcess as an example):
let data = Syscall::OpenProcess(SyscallData{
inner: OpenProcessData {
pid,
target_pid,
},
});
// send the telemetry to the engine
send_syscall_info_ipc(&data);
Finally, in our engine where we receive the message we refactor to check:
/// Starts the IPC server for the DLL injected into processes to communicate with
pub async fn run_ipc_for_injected_dll(
tx: Sender<Syscall>
) {
// ... snip ... deals with setting up the IPC server
// deserialise the request
match from_slice::<Syscall>(&buffer[..bytes_read]) {
Ok(syscall) => {
//
// As part of the Ghost Hunting technique, one way I have thought up to bypass this would be to spoof an
// IPC from the malware saying you are performing an operation via a hooked syscall; when in actuality you are
// using direct syscalls to evade detection etc.
//
// Therefore, in order to combat this we can enforce IPC messages to contain the HasPid trait, so that all inbound
// IPC messages contain a pid. We can then compare the pid offered by the message, with the PID the pipe actually came
// from to verify the message authenticity.
//
let pipe_pid = match get_pid_from_pipe(&connected_client) {
Some(p) => p,
None => {
// todo this case would be erroneous
},
};
if pipe_pid != syscall.get_pid() {
// todo this is bad and should do something
}
logger.log(LogLevel::Success, &format!("Data from injected DLL pipe: {:?}.", syscall));
if let Err(e) = tx_cl.send(syscall).await {
logger.log(LogLevel::Error, &format!("Error sending message from IPC msg from DLL. {e}"));
}
},
Err(e) => logger.log(LogLevel::Error, &format!("Error converting data to Syscall. {e}")),
}
}
/// Gets the PID that sent the named pipe, to ensure the pid we receive the message from is the same as the
/// pid wrapped inside the message - prevents false messages being sent to the server where an attacker may wish
/// to use a raw syscall and spoof the pipe message.
///
/// # Returns
/// - The PID as a u32 if success
/// - Otherwise, None
fn get_pid_from_pipe(connected_client: &NamedPipeServer) -> Option<u32> {
let handle = connected_client.as_handle().as_raw_handle();
let mut pid: u32 = 0;
if unsafe { GetNamedPipeClientProcessId(HANDLE(handle), &mut pid) }.is_err() {
return None;
}
Some(pid)
}
Closing thoughts
And that is that, signal spoofing thwarted! Hopefully! If you have any ideas for bypasses on this technique, I would love to hear them! Reach out at X, and don’t forget to star / follow my GitHub project!