0%

ProcessHollowing与ReflectiveDLL

ProcessHollowing与ReflectiveDLL

前段时间稍微学了下进程镂空这个操作(ProcessHollowing),顺带就学了一点简单的windows PE结构,顺带复习了一下经典ReflectiveDll loader原理,理解了一下注入都是怎么做到的,最后扩展学习到了跨位数进程注入。。。

ProcessHollowing

一个已经有五六年历史的技术了,我最近才听说。。。与进程注入类似,都是拉起一个傀儡进程后使其运行恶意代码,和进程注入较大的区别是不使用CreateRemoteThread等函数,并且可以直接将一个PE放进去跑,无需对其进行额外操作(如添加ReflectiveLoader)

既然不使用CreateRemoteThread,那么必然得使用其他的危险函数,这里用的是SetThreadContext,具体思路其实很简单,读一个PE到内存中(可以不触碰磁盘),拉起傀儡进程,将傀儡进程的内存空间用NtUnmapViewOfSection一把释放掉,这样子这个进程里面就没有东西了,此时我们再将我们的PE文件填进去,就完成了一个套皮进程的运行,当然,在实现上还有很多需要考虑的地方

代码github上抄下来然后魔改的,基本上和这个代码差距不大
abdullah2993/go-runpe
C++版本(这个版本我用go抄了一遍之后跑不起来。。。)
idiotc4t/ProcessHollow

首先是windows进程相关结构体,最主要的结构体就是TEB,这个结构体类似于linux下的task struct,放了一堆进程相关信息。TEB下有一个PEB,PEB中记录了进程加载的各种dll的基址,地狱之门等脱钩技术,以及手动寻找函数地址以减少IAT暴露的导入函数等方法,都是通过PEB下面这个InMemoryOrderModuleList对dll进行遍历实现的
如下是一篇获取PEB并解析DLL技术的完整解说
从学习Windows PEB到Hell’s Gate

当然,上述内容和本操作似乎无关。。在本次代码中是通过rdx寄存器拿到某个结构体,并偏移16位拿到当前进程镜像基址,C++注释中说拿的是PEB,但没有资料说rdx指向PEB,并且PEB偏移16位也不是imageBase,表示怀疑,且未能寻找到相关资料

拿到目标进程基址后,使用NtUnmapViewOfSection释放对应PE文件占用的内存,此处即为镂空,为我们即将载入的PE腾出空间。此处仅释放了PE对应的空间,原TEB仍然保留,所以IAT表仍然存在,无需进行重新导入模块等操作。

golang中提供了PE库,可以不手写windows PEheader结构体对一个PE文件结构进行分析,一个PE文件的结果如下图所示:
image-20230406194340240

可以看到文件在磁盘上的大小一般来说小于在内存中的大小,主要原因好像是各个节在内存中是需要按页对其的,而在磁盘上的对齐可能和内存上不一样?不过文件在磁盘上时,其PE header中写的各项大小以及偏移即为其装载到内存中的对应的大小和偏移,所以对着数据分配内存就行

推荐阅读
PE文件格式详细解析(一)
开局的DOS头和DOS stub没什么用,用来兼容远古系统的,DOS头就是经典的那个MZ开头的魔数,然后还有一个This program cannot be run in DOS mod经典字符串,唯一作用是指明PE头在哪,PE头开头是PE魔数,以及一些相关属性,包含一个Option_header,包含了ImageBase,AddressOfEntryPoint等关键成员

当然,golang pe库一键解析,这里了解一下大概结构即可。
不过这里的ImageBase不会被使用,我们就着原PE的baseaddr alloc分配空间,并填入内存,据说这样子可以不考虑重定向(原理未知)
傀儡进程的实现与检测

先写header,把DOS/PE header和各section header写到内存里,然后从各section header中解析出各段的地址,这个地址都是相当于基址的偏移,就着基址加一下写进去,最后将rcx寄存器改成当前镜像的EntryPoint,resumeThread就能完成劫持(这里一开始是抄的网上代码Alloc整个imageSize的内存并且是RWX,defender直接静态报毒,然后我改成了每个段独立分配并且写完之后按照段上的权限修改成RW或者RX之类的,就不报毒了,怎么回事捏)

    for _, section := range image.Sections {
        var flag uint32 = 0
        // 0x20000000 可执行 0x40000000 可读 0x80000000 可写
        // 理论上来说所有段都是可读的,且不存在rwx三个一起,在可读基础上对可写可执行做判断
        fmt.Printf("%s number of relocations: %d\n", section.Name, section.NumberOfRelocations)
        if section.Characteristics&0x40000000 == 0x40000000 {
            fmt.Printf("section %s is readable\n", section.Name)
            if section.Characteristics&0x20000000 == 0x20000000 {
                fmt.Printf("section %s is executable\n", section.Name)
                flag = windows.PAGE_EXECUTE_READ
            } else if section.Characteristics&0x80000000 == 0x80000000 {
                fmt.Printf("section %s is writeable\n", section.Name)
                flag = windows.PAGE_READWRITE
            } else {
                flag = windows.PAGE_READONLY
            }
        } else {
            fmt.Printf("not readable section %s", section.Name)
        }

        sectionData, err := section.Data()
        if err != nil {
            panic(fmt.Sprintf("section.Data failed: %v", err))

        }
        if len(sectionData) != 0 {
            //先写再改,不然有的只读段会出错
            err = windows.WriteProcessMemory(hProcess, allocAddr+uintptr(section.VirtualAddress), &sectionData[0], uintptr(section.Size), nil)
            if err != nil {
                panic(fmt.Sprintf("WriteProcessMemory failed: %v", err))

            }
            var oldProtect uint32
            err = windows.VirtualProtectEx(hProcess, allocAddr+uintptr(section.VirtualAddress), uintptr(section.Size), flag, &oldProtect)
            if err != nil {
                panic(fmt.Sprintf("VirtualProtectEx failed: %v", err))

            }
        }
    }

其中有几个想不太通的地方,如果稍微调试一下就会发现原image的baseAddr和header中的初始BaseAddr是会有区别的,这样子会牵扯到PE中绝对地址的重定向,所有需要被重定向的绝对地址会位于.reloc节中,在pe加载进内存时应当计算出header中基址与实际装载基址的差将reloc节中的每一项中对应的内容改掉,有一些示例代码中也给出了对应的修改代码,但是我复现的时候以及抄的代码里是可以去掉重定向环节的。section header中有一项NumberOfRelocations,调试的时候发现每一个节的值均为0,是因为现代二进制程序都是PIE吗,是不是现在全都用地址无关码了?但是每个文件的reloc节也不是空的,都有万吧个字节内容,不懂捏

再一个是入口点修改,网上的资料大多是说入口为eax(32位,如果64位应该对应rax),而这里改的是rcx,即context中rax后面一个寄存器,C++代码中的注释写的是修改rax,但代码中是rcx,抽象。以及一开始提到的从rdx寄存器中找镜像基址。。。

综上所述,这个操作暂时只理解了流程,未理解深入原理,并且存在一系列未解之谜。。。

顺便比较一下三个注入方案,CreateRemoteThread创建远程线程,QueueUserAPC的好处在于劫持已存在线程,没有创建环节,ProcessHollowing可以直接注入一个原始pe进去,没有新的线程启动,调用的方法为SetThreadContext

今天逛街的时候刷到了另一个用SetThreadContext的注入方法,也和我想象的略微一致了一些。就是简单地注入shellcode,然后把PC寄存器指过去,直接执行,相较于进程镂空省去了给新进程配环境的环节,所以自然而然的能注入的内容也就是本身就能直接执行的shellcode
通过进程注入方法绕过杀软测试
不过这个进程注入关杀软的想法难以苟同,如果杀软不做自我保护允许高权限进程随便关,那感觉就乱杀了,注不注入意义不大。
以及显然这里最关键的执行点是改了寄存器,而文末的防御手段居然没提SetThreadContext,难以理解
也有可能是我浅薄了?搞不好这个函数本身被调用的很频繁所以不好对这个函数做检测?

ReflectiveDLL

考虑一下注入类型的操作,如果注入的只是简单的shellcode的话,啥也不用考虑,注进去直接从头执行就可以了,然而如果注入的是一个拥有众多功能的木马,一般来说就跑不起来,关键点在于注入的木马大多是依赖于外部的动态链接库的,即使你说你是顶级静态编译打包一切,windows的ntdll等操作系统级别的基础库也是打包不进去的。正常的程序在加载时操作系统会通过PE loader为其创建好对应的IAT表,而我们注入进去的代码可就一无所有了。

如果对比进程镂空与反射dll的话,进程镂空真的比反射Dll方便很多,因为镂空的进程保留了原TEB,可以就着旧进程的IAT表,这样子ntdll等基础模块的函数是天生就能使用的,自己需要的函数也可以用现成的LoadLibrary和GetProcAddress等方法临时加载(胡言乱语中),而当我们CreateRemoteThread的时候,注入进去的新线程可没有人给你填IAT,最基础的ntdll函数都没有,就完全没法运行了。这也就是reflective loader的作用,手动寻找当前进程中ntdll的位置,手动提取LoadLibrary等方法,然后遍历PE的导入表,手动修复IAT
深入理解反射式 dll 注入技术
ReflectiveLoader.c

In the context of injecting a DLL, there are two paths to go down when it comes to executing code within a foreign process: find some existing code within the foreign process which loads a DLL, or put some new bootstrap code into the foreign process which goes on to load a DLL.

而ReflectiveDLL就是为我们写好了一个现成的bootstrap code which goes on to load a DLL,充当PE loader的角色还原一个程序需要运行时的环境

reflective loader就做这么几个工作:

  1. 通过caller()函数找到当前reflective loader()函数地址,然后向前寻找,直到找到MZ这个DOS头,找到image基址。(注意到我们注入reflective dll的时候都是直接将文件注入进去的,而非进程镂空一样解析PE格式按段写入)

  2. 使用汇编__readgsqword读取gs寄存器偏移0x60位的数据,amd64机器下gs寄存器指向TEB,偏移0x60位即为指向PEB的指针,PEB中有一个LDR_DATA结构体,里面存储了人见人爱的InMemoryOrderModuleList,根据windows上的加载惯例,第一个module是PE本身,第二个是ntdll,第三个是kernel32,通过遍历这个链表,就能够拿到内存中已加载的各个Dll的基址。接下来遍历Dll的导出表,翻出我们需要的函数的地址。(实际上,很多相关操作都是这样获取到常见函数并进行直接调用的)

image-20230407120857163

image-20230407120913253

  1. 接下来Alloc一个新的空间,正式将注入的dll以其内存中的格式分配进去,由于上一步里面只解析了VirtualAlloc,LoadLibrary等函数,这里的写入是手动偏移然后一个字节一个字节复制进去的,同样为了方便起见,Alloc的时候直接上的RDX,感觉是一个很明显的特征。。。不过由于没有提取VirtualProtect,所以也没法改
  2. 从PE头中找import table,把需要导入的dll和函数分别用之前从kernel32里翻出来的LoadLibrary和GetProcAddress加载进来,填入IAT,提供完整的运行环境。这一步是根据导入表中的name来寻找函数的,所以之前写reflective dll注入的时候,可以通过直接将dll中的ExitProcess改成ExitThread\x00来更改调用的函数
  3. 通过reloc节将所有需要重定向的位置进行重定向
  4. 调用dllMain,完成利用

pe2shellcode

类似的,可以通过上述的原理直接在内存中装载pe,同样的添加一段shellcode类型的loader完成模块导入和重定向等技术即可
无文件执行:一切皆是shellcode

有一个地方没有很想通,按照上述情况讨论,CreateRemoteThread启动的应该是一个全新的环境,起码没有继承原进程的PEB,所以需要自己找ntdll里的方法然后恢复导入表,但是正常使用CreateRemoteThread时,不可能也是一个裸环境吧,但是我确实没有写过正常的CreateRemoteThread的应用。。。不是很清楚具体用法。然后搜了一下感觉全是调用LoadLibrary注入dll的操作。。。

对于dll注入那种调用远程LoadLibrary的CreateRemoteThread操作能够成功也是很玄学的,因为windows奇怪的机制,每个进程的ntdll和kernel32的地址在每次开机后都是一样的,所以可以直接提供当前进程的LoadLibrary地址去调用远程(但是LoadLibrary难道不依赖任何其他函数吗?)

PE2Shellcode这个项目里面还有一个有意思的issue,注入到wow64进程时,他的PEB应该是32位的还是64位的呢?Stack Overflow上有一个回答说wow64有两个peb,一个32位一个64位,并且不同的应用交互的时候看的PEB还不一样。。。

wow64与x64跨位数注入

算是看到那个issue后继续搜索得到的一个附加项?lrj之前去面试的时候也被问到过对应的问题,32位进程和64位进程互相注入应该怎么操作呢?当时他没答上来,我认为wow64会进行系统调用转换,以为只要payload的位数和目标进程一致,注入就能成功,即使位数不同数据结构不同,但是对应位数的payload本身就被设计在对应数据结构下运行,所以也没有问题,不过没有手动实践过,然后这个文章指出我的想法不对
DLL Injection and WoW64
这里指出问题在于x86的CreateRemoteThread无法成功在64位进程上生效(我猜是虽然转换了调用格式但是x86的远程线程也是32位的,无法在64位进程中运行?),而x86的进程只加载了64位的ntdll,没法调用64位的kernel32里的CreateRemoteThread

自己写了个demo try了一下,shellcode用的msfvenom生成的calc,32位和64位各一份,配合process hacker看具体情况,由于go在x86下不能调试,只能用这种Press enter to continue的办法手动停下来

    err := windows.CreateProcess(windows.StringToUTF16Ptr("C:\\Windows\\sysnative\\notepad.exe"), nil, nil, nil, false, 0, nil, nil, &si, &pi)
    if err != nil && err != windows.ERROR_SUCCESS {
        panic(err)
    }
    fmt.Print("Press enter to continue...")
    bufio.NewReader(os.Stdin).ReadBytes('\n')
    hProcess := pi.Process
    defer windows.CloseHandle(hProcess)
    kernel32 := windows.NewLazySystemDLL("kernel32.dll")
    procVirtualAllocEx := kernel32.NewProc("VirtualAllocEx")
    addr, _, err := procVirtualAllocEx.Call(uintptr(hProcess), 0, uintptr(len(calc)), windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_EXECUTE_READWRITE)
    if err != nil && err != windows.ERROR_SUCCESS {
        panic(err)
    }

    fmt.Println("alloc addr: ", addr)

    err = windows.WriteProcessMemory(hProcess, addr, &calc[0], uintptr(len(calc)), nil)
    if err != nil && err != windows.ERROR_SUCCESS {
        panic(err)
    }
    fmt.Print("Press enter to continue...")
    bufio.NewReader(os.Stdin).ReadBytes('\n')
    procCreateRemoteThread := kernel32.NewProc("CreateRemoteThread")
    hThread, _, err := procCreateRemoteThread.Call(uintptr(hProcess), 0, 0, addr, 0, 0, 0)
    defer windows.CloseHandle(windows.Handle(hThread))
    if err != nil && err != windows.ERROR_SUCCESS {
        panic(err)
    }
    fmt.Print("Press enter to continue...")
    bufio.NewReader(os.Stdin).ReadBytes('\n')
    windows.WaitForSingleObject(windows.Handle(hThread), windows.INFINITE)

显然,x86注入x86 shellcode到x86进程和x64注入x64 shellcode到x64进程是毫无问题的,同时,注入的shellcode与目标的架构不一致也是绝对不行的

然而,x64进程是可以顺利的将x86 shellcode注入到x86进程中并顺利运行的,而x86进程却无法将x64 shellcode注入到x64进程中成功运行,前两步创建x64进程以及virtualAlloc和writeProcessMemory都是成功的,但是CreateRemoteThread直接返回一个access denied就挂掉了,与前文观察到的现象一致

最好的解决方案可能是直接在32位进程里调用64位api,有人提出了名为天堂之门(Heaven’s Gate)的技术,Mixing x86 with x64 code
这篇文章中指出:

Summing things up, for every process (x86 & x64) running on 64-bits Windows there are allocated two code segments
cs = 0x23 -> x86 mode
cs = 0x33 -> x64 mode

简单的说,就是直接改寄存器让运行在x86下的进程切换到x64状态下调用x64函数,完成了再切回x86。
windows使用了两套代码分别实现32位和64位的系统调用,想要在32位和64位间切换,只需要修改段寄存器,使用对应的代码段即可。虽然说起来好像很简单,但实际上使用还是很复杂的。因为LoadLibrary等函数位于kernel32,而32位进程默认只加载ntdll.dll,wow64.dll,wow64cpu.dll以及wow64win.dll,手动实现LoadLibrary很复杂并且他失败了。所以最后又使用了经典从寄存器拿TEB,最后从PEB里面拿InMemoryOrderModuleList最后遍历模块的方式,这样子就能调用64位ntdll里面的部分方法了,然后再手搓汇编在32位下进行64位函数调用。缺陷在于能够调用的函数仅限于ntdll,因为真的加载不上其他dll。。。这里写了能调用的一部分函数,没有QueueUserAPC和CreateRemoteThread等方法,但是有SetThreadContext,可以考虑用进程镂空的方法进行注入

2023/4/24 update

今天lrj给我发了一篇微信公众号,里面提到了32位注入64位,并且公众号里写的是通过段寄存器切换执行远程注入,也就是天堂之门技术,然而这和上述讨论的可调用的函数范围有悖,所以进行了简单研究

首先lrj和我说ntdll里面有一堆undocumented api,其中的NtCreateThreadEx是可以直接创建远程线程的,所以只要遍历Ntdll找到这个函数理论上就可以实现远程注入(真不太熟,我是windows垃圾。。。),以及ntdll里面有一个叫LdrLoadLibrary的方法,似乎能够通过这个函数加载dll,所以实际上ntdll里面好像应有尽有,只是我不知道罢了。。。。
不过下述文章中也提到这个加载有bug,在不同的windows版本上不一定都能跑通
天堂之门 (Heaven’s Gate) C语言实现

由于不想碰C,所以网上找到了一个go实现的天堂之门,赞美光明
Gopher Heaven

使用方式非常简单,开箱即用,能够直接拿到64位ntdll然后找对应的函数地址
最后一句调用完成

_, err = heaven.Syscall(NtCreateThreadEx, uint64(uintptr(unsafe.Pointer(&hThread))), windows.GENERIC_EXECUTE, 0, uint64(hProcess), uint64(addr), uint64(addr), 0, 0, 0, 0, 0)

由于是undocumented api,想找个调用范例还挺难的,搜也搜不到什么详细的参数解释,导致一开始第一个入参hThread我没转成地址,结果调用就一直出错,我还以为这个库写的有问题。。。

既然是创建远程线程的函数,就应该有一个地方返回创建的线程,所以第一个参数就是作为输出的thread handle,因此需要传一个指针过去

接下来结果参数也看不懂,但是很明显的hProcess就是一个process handle,指明要注入的进程,addr是shellcode在目标进程里的地址,为什么重复两遍不清楚,反正这么写能用,剩下的全部default填0

为什么又研究这个玩意,主要是因为公众号里说32位跳64位注入64位shellcode可能在一定程度上能够绕过检测。
虽然我不知道他说的对不对,但是我先写一版能用的以备不时之需