# CobaltStrike beacon二开指南

z3ratu1 2022-10-26 09:56:00

CobaltStrike真好用啊,但是缺点就是太好用了被各类安全厂商严防死守,研究了半天shellcode loader和白加黑之类的免杀,多多少少都有翻车的记录,有没有更好的办法呢?
要不就自己从头写一个beacon吧,没有出现过就不会被杀软抓住(确信),由于手上常用版本为几年前摸来的CobaltStrike4.0,因此该指南所开发的beacon仅适配4.0版本。或许可以通过修改metadata部分对较高版本进行基本适配

项目链接geacon_plus

为了降低本c语言垃圾的开发难度,使用的是新时代脚本语言golang,刚好有一天逛街看到了这个项目geacon,于是出现了这个玩具项目以及其开发指南。

使用与geacon项目相同,需自行填入config.go中的CS server的公钥以及回连地址

项目实现了stageless的http/https beacon,支持c2profile的部分选项,支持在主流操作系统(windows,linux,macOS)上的文件操作,命令执行,进程管理等功能,在windows平台上实现了Reflective DLL Injection,PowerShell模块内存加载,windows token相关,C#内存加载等功能。

开发过程中经历了翻了CS服务端的字节码,下方法断点强行调试硬看字节数组,参考了一堆开源项目,也查阅了不少windows api文档,以及一些基本概念。学到许多.jpg,但我真的是windows菜狗,很多地方都不懂,很多地方可能存在一定的问题,还望各位师傅多多交流,带带弟弟

通信协议

geacon项目较为解析了CobaltStrike的通信流程,覆盖了beacon对server命令请求,结果回传等基本功能,有一个封装好的基础协议会对进一步开发有很大的帮助,先简要看一下8
注意CS中所有的数字均采用大端序存储

beacon请求类型

beacon会以两种方式对server发起请求,get和post。get方式即发送心跳包,同时server的命令也是通过对get的response下发,而post则是用于对命令执行的结果进行回传,server对post的response好像没什么用,无需进行处理。

CobaltStrike加密流程

如果开启CobaltStrike的stage模式,可以通过请求stage url来获取到服务端的公钥,这个就是CS会话加密的基础。此处将公钥内嵌,定义在config/config.go中的RsaPublicKey,beacon在向server发送get请求时,会将使用该公钥对数据包进行加密,以此确保会话的安全性,然而,如果server回传私钥加密的数据,则可以被任何持有公钥的对手解密。因此,get请求中会携带AES key,server的应答以及beacon的post请求和应答均会使用该aeskey进行AESCBC加密,IV为硬编码的abcdefghijklmnop

GET request

beacon在发送GET请求的时候发送的是一份metadata,涵盖了目标计算机的操作系统,用户名,内网IP地址,beacon pid号,进程位数等信息。4.0下的metadata数据格式可以详见packet/packet.go下的MakeMetaInfo函数,有点长这里就懒得写了。。。第一次get请求会被视作beacon的上线请求,server从中解析metadata并显示出来,其余的get请求只被视为fetch命令的心跳包。
metadata在不同的CS版本中会存在出入,故若需要适配不同版本,请务必修改该部分

metadata中有几个需要注意的数据,首先是GlobalKey,这个是beacon后续与server沟通使用的密钥。用于加密的AESKey和验证hash的HmacKey分别为其sha256后的前后16位,其次是beacon id,每个beacon会话在产生时会随机生成一个beacon id,server将会使用该id对beacon进行跟踪,因此需要保证会话过程中beacon id不变,且不同的会话需要有不同的beacon id。

我在具体实现的时候写了一个有点奇怪的代码,即重复生成了两次metadata

    // command without computer name
    tempPacket := MakeMetaInfo()
    publicKey, err := util.GetPublicKey()
    config.ComputerNameLength = publicKey.Size() - len(tempPacket) - 11

    packetUnencrypted := MakeMetaInfo()
    packetEncrypted, err := util.RsaEncrypt(packetUnencrypted, publicKey)

这里是参考了原项目中的issue,用户名有时候会过长,超出了RSA加密时块的总长度限制,因此需要先生成一个没有username的metainfo,然后计算出一个合适的长度,判断username是否需要被截断。
最终将生成的metadata存入到全局变量encryptedMetaInfo中,后续get请求均发送该数据

GET response

get的响应是下发的指令,其解析流程就在main函数中,其大致结构如下

aes encode data{
    4bytes timestap
    4bytes data length
        (4bytes cmd type
        4bytes cmdlen
        len bytes cmd)
        ...
}
16bytes hash

一次请求可能下发多个任务,即会出现多个cmd段(小括号内)内容。我们实际关注的也就cmd type和cmd内容,对于具体的cmd type后续讲解

POST request

POST请求用于回传结果,大体结构如下

4bytes packet len
aes encode data{
    4bytes counter
    4bytes result len
    4bytes callback type
    4bytes reply content
}
16bytes hash

需要注意的为counter,这个需要递增,否则server会拒绝这个包并认为这是重放攻击
callback type被用于指定应答的类型,CS服务端会根据type决定该数据的存储方式,因此部分应答数据可以被处理并且在CS图形化界面进行交互,比如偷来的hash,扫描出来的主机或者屏幕截图之类的,就会有和普通输出不同的callback type

POST response

没什么用,想处理也行,但是好像就是没什么用。。。所以跳过

c2profile

仅实现c2profile中http-get/post块的部分内容,实现对流量的伪装,这里使用的是jQuery风格的请求
主要的伪装就是发送数据的prepend,append,以及数据的加密。该操作是在数据生成完成后进行的,即request时需要对AES加密并填充hash后的数据进行编码和pending,而response时则是先将pending和编码去除再进行AES解密

可以查看packet/http.go下的HttpGet和HttpPost函数确认逻辑,实际的处理很是简单,以解析结果为例

func resolveServerResponse(res *req.Resp) ([]byte, error) {
    method := res.Request().Method
    // response body string
    data := res.Bytes()
    switch method {
    case "GET":
        data = bytes.TrimSuffix(bytes.TrimPrefix(data, []byte(config.GetServerPrepend)), []byte(config.GetServerAppend))
        var err error
        data, err = util.DecryptField(config.GetServerEncryptType, data)
        if err != nil {
            return nil, err
        }
    case "POST":
        data = bytes.TrimSuffix(bytes.TrimPrefix(data, []byte(config.PostServerPrepend)), []byte(config.PostServerAppend))
        var err error
        data, err = util.DecryptField(config.PostServerEncryptType, data)
        if err != nil {
            return nil, err
        }
    default:
        panic("invalid http method type " + method)
    }
    return data, nil
}

DecryptField用于处理http-get中的编码,常见的编码有base64/base64url/netbios/netbiosu/mask,除mask外都是可以直接搜索得到的算法,mask需要额外查找,最后在CS server的c2profile/Program.class中翻到了全部相关操作的实现
mask会随机生成4bytes数据放在原数据的最前面,然后将原数据与该key进行异或进行加密,实现位于util/util.go,限于篇幅不贴代码

功能实现

因为golang支持交叉编译,并且又不用自己多写代码,所以可以将功能分为基础功能和windows专用功能,基础功能可以实现在各个平台上使用,不过geacon项目本身已经将基础功能实现的差不多了,所以稍微写一点

各种功能的命令下发实现可以在CS server的beacon/TaskBeacon.class查看,可以较为清晰的看出各个命令的内容构造。这会对解析下发指令时提供的参数带来很大的帮助
大部分的参数均为四字节参数长度+参数的形式,简单的封装一个函数

func parseAnArg(buf *bytes.Buffer) ([]byte, error) {
    argLen := packet.ReadInt(buf)
    if argLen != 0 {
        arg := make([]byte, argLen)
        _, err := buf.Read(arg)
        if err != nil {
            return nil, err
        }
        return arg, nil
    } else {
        return nil, nil
    }
}

这里传入的参数为一个Buffer,是对[]byte的一个封装,内部维护了一个读取的指针,就能很方便的顺序读取了

cmd type

command/misc.go下定义了收集的部分cmd type,该值用于指示接下来的参数是对应哪个命令的,部分定义可以在server的beacon/Tasks.class下找到

call back type

post回送请求时指明应答类型的数据,同样定义在command/misc.go,常用的就是output,pending和error,output有一个utf8版本,但实际测试证明,使用utf8反而会导致中文乱码,不使用还不会
pending是一个比较有意思的应答,用于响应一个等待数据的请求,使用该类应答时一般会在收到的命令中得到一个pending序列号

在server的beacon/BeaconC2.classprocess_beacon_callback_decrypted中可以找到对不同callback的处理

文件系统操作

具体实现位于command/fileSystem.go
cd,pwd,mv,cp,rm之类的有手就行操作就不提了,讲两个麻烦一点的。

CS右键上线机器有一个file browse功能,对应cmd type 53,可以图形化查看目标机器的文件系统,这个需要回传约定好的数据格式,部分内容如下所示

        modTimeStr = file.ModTime().Format("02/01/2006 15:04:05")

        if file.IsDir() {
            resultStr += fmt.Sprintf("\nD\t0\t%s\t%s", modTimeStr, file.Name())
        } else {
            resultStr += fmt.Sprintf("\nF\t%d\t%s\t%s", file.Size(), modTimeStr, file.Name())
        }

除此之外,该请求会下发一个pending request,所以在最后响应的时候还需要在结果前将下发的pending request号放上去,应答时的callback type设置为pending

文件上传时可能会因为文件较大而分批次发送,但处理却很简单,只需要将文件的写入模式改为append即可
另一个比较麻烦的功能是文件下载,毕竟beacon不开tcp链接,只能每次http一点一点传
下载时需要先发送一个CALLBACK_FILE标识文件下载开始,然后循环发送CALLBACK_FILE_WRITE分段传输文件,传输完成后发送CALLBACK_FILE_CLOSE结束传输。
这里的实现上还有一点点小瑕疵,文件传输应该以异步的形式实现,懒,以后再说8

进程管理

具体实现位于command/proc.go
该部分同样比较简单,为了能够使该代码能够跨平台运行,使用了第三方库github.com/shirou/gopsutil/v3/process对进程信息进行收集

有意思的是,虽然callback中有一项CALLBACK_PROCESS_LIST,但对于列出所有进程这个操作仍然发送了一个pending request,所以还是得用CALLBACK_PENDING

同样的,需要使用约定好的数据格式

result += fmt.Sprintf("\n%s\t%d\t%d\t%s\t%s\t%d", name, pPid, pid, archString, owner, sessionId)

网络查看

实现位于command/network.go
为确保多平台兼容性,使用第三方包获取网卡信息,同样需要以特定的格式传输才能支持CS server正确解析并以图形化形式交互

        for _, a := range addrs {
            switch v := a.(type) {
            case *net.IPNet:
                // ipv6 to4 is nil
                if v.IP.To4() == nil {
                    continue
                }
                if !strings.HasPrefix(v.IP.String(), "169.254") && !v.IP.IsLoopback() {
                    mask := fmt.Sprintf("%d.%d.%d.%d", v.Mask[0], v.Mask[1], v.Mask[2], v.Mask[3])
                    result += fmt.Sprintf("%s\t%s\t%d\t%s\n", v.IP, mask, i.MTU, i.HardwareAddr)
                }
            }
        }
    }

暂时只记录ipv4,然后这种返回数据需要被记录用于图形化显示的,似乎都会携带一个pending request,所以返回时需使用CALLBACK_PENDING

命令执行

实现位于command/exec_*.go
这个功能在windows上的实现会复杂一些,先说linux和mac下的情况

CS有三个普通的命令执行相关命令,run,shell和execute,其中shell是用cmd.exe执行命令,exec是后台执行不需要回显,run是执行需要回显,而实际上,run和shell均由78号CMD_TYPE_SHELL下发。该命令有两个参数,path和args,其区别在于,shell指令会在用户输入的命令前添加%COMSPEC% /C,并将%COMSPEC%作为path,剩下内容作为args,而run指令则直接置空path,将整个命令放入args中。execute参数同run,但不考虑回显

linux/mac环境

在linux和Mac下,对于shell,将%COMSPEC% /C替换为bash -c之类的即可,run和execute也都套一层bash -c来执行

windows环境

对于windows则需要一些额外考虑,%COMSPEC%是windows下的环境变量,一般指向cmd.exe,所以需要先把环境变量解析出来。比较关键的是这个参数的提供方案,run和exec将整个命令作为commandline传入,shell将cmd.exe作为app,剩下所有值作为commandline。这是因为windows下参数不是由空格分隔传入的,不像java的exec一样可以传入一个string数组。查看CreateProcess的文档,需将所有参数作为一个字符串传入commandline,应用程序自行分辨。若path处置空,则将参数处第一个值作为path,剩下的值作为参数。

故此处可以简单地将path和args不作处理的传入CreateProcess。run和shell的回显通过在创建进程时创建匿名管道获取,由于考虑到后期存在令牌窃取等功能,在创建进程时封装成了createProcessNative,当存在被窃取的令牌且下发命令没有ignoreToken时,使用CreateProcessWithTokenW使用窃取的令牌创建进程,否则调用CreateProcess创建进程

令牌相关

从此之后的功能仅适用于windows平台
windows相关功能推荐使用windows包,syscall包似乎已经"deprecated"了,部分windows api已经在该包中实现了,但同时也有一部分没有,直接调用windows包下的函数可以直接以err != nil来判断成功与否,对于使用NewProc从dll中获取的函数调用总会返回错误,需要比较if err != nil && err != windows.SEVERITY_SUCCESS,并且还需要将传入的参数用uintptr(unsafe.Pointer())进行强制类型转换。并且传入的字符串为UTF16字符串,可以使用windows.StringToUTF16Ptr进行转换

令牌相关的实现位于command/token.go,实现了令牌窃取相关功能。
之前对windows的令牌所知甚少,在读了好多文章以及看了一堆MSDN文档后有了一点点基础认知

首先,windows的令牌分为两种,一个是primary token,另一个是 impersonation tokenStack Overflow上有一个很好的回答,前者是进程的token,而后者是线程的token,线程会默认继承进程的token,但是也可以模拟出一个impersonation token以模拟出的权限进行交互。

该类别共有Runas,GetPrivs,StealToken,MakeToken四个主要方法,Rev2Self就是简单的将窃取的token释放,并将线程的token换回原来的primary token

Runas

Runas通过提供用户的口令以创建一个对应用户token的进程,使用CreateProcessWithLogonW实现,也可以尝试使用LogonUserA获得primary token后调用CreateProcessAsUserACreateProcessWithTokenW使用token创建进程。
不过CreateProcessAsUserA相较于CreateProcessWithTokenW,前者所需的权限跟多,为SE_INCREASE_QUOTA_NAMESE_ASSIGNPRIMARYTOKEN_NAME,后者所需的是SE_IMPERSONATE_NAME
CreateProcessWithLogonW无需任何特权,但只能登陆可交互的账号。处于健全考虑,前者失败时会尝试再次调用后者

Runas暂时使用CreateProcessWithLogonW直接输入口令实现,但实际测试时go会报一个api error,暂时不知道原因,所以暂时没法用

GetPrivs

提升当前权限特权,出于安全考虑,部分token拥有的权限是被关闭的,需要主动打开方可使用,在cmd中输入whoami /priv可以看到拥有的权限以及其启用状态,使用的windows api为LookupPrivilegeValueAdjustTokenPrivileges,前者将特权名转化为LUID,后者接受LUID并尝试将其对应的特权使能。代码中有两个实现,一个是将每个权限构造一个Tokenprivileges结构体,然后依次调用AdjustTokenPrivileges,另一个是进行一些黑魔法内存操作,构造出一个放了全部特权的Tokenprivileges结构体,一次性完成

由于CS下发的输入为需要提升的特权名,返回值为成功提升的特权,而当输入多个特权时,得到的错误只会是ERROR_NOT_ALL_ASSIGNED,无法确切知道哪些权限获取成功,故使用每次仅赋值一个的方案

StealToken

流程很简单,OpenProcess获取进程句柄,然后OpenProcessToken获得其primary token的句柄,使用ImpersonateLoggedOnUser将当前线程的token设置为窃取的token,调用DuplicateTokenEx复制一个新的primary token出来用于后续进程的创建,至于 能不能成功,就要看你当前运行的账户权限了。

OpenProcess至少以PROCESS_QUERY_LIMITED_INFORMATION权限打开进程,OpenProcessToken则需要TOKEN_QUERYTOKEN_DUPLICATE以调用ImpersonateLoggedOnUser,如果仅调用DuplicateTokenEx则只需要duplicate权限,但为了使DuplicateTokenEx获得的token能够用于创建新进程,需要至少TOKEN_ASSIGN_PRIMARY, TOKEN_QUERY, TOKEN_DUPLICATE四个权限

需要注意的是,impersonate token是无法用于创建新进程的,所以DuplicateTokenEx需要取得一个primary token给CreateProcessAsUser等函数使用,此处偷来的token会被存到全局变量stolenToken中,以便后续使用。
(不用的时候记得关handle)

MakeToken

与Runas类似,也是给予用户口令后创建一个token存下来,顺序即为LogonUserA获取token,DuplicateTokenEx复制token,但LogonUserA处很怪,当参数dwLogonTypeLOGON32_LOGON_INTERACTIVE时,怎么输入口令都错误,而LOGON32_LOGON_NEW_CREDENTIALS时,则随便输入也是正确的,但得到的token并不能正常使用,对windows的理解有限,暂时不能指出哪里出现了问题

job处理

实现位于command/job.go,Server端实现位于beacon/job.classbeacon/job/目录下
这个是CS用于扩展和自身自带的一些常用功能的关键功能,实际上就是反射dll注入,以不落地的方式实现扩展的功能,常见功能如port scan,hashdump,screenshot等均是job类型命令。使用该类命令会下发两个指令,1. 注入dll(cmd type 1/9/43/44/89/90),2. 从命名管道读取数据(cmd type 40)

dll注入

注入dll分为两种主要形式,SpawnInject,前者通过拉起一个预定义的傀儡进程(定义在config/c2profile.goSpawnToX86/64)后远程写入dll创建远程线程执行,后者则是直接将dll注入到指定pid的进程中创建远程线程执行,均区分了x86和x64,spawn还区分了是否使用偷来的token创建新进程

显然,64位的dll不能注入到32位的程序里面去,所以此处需要对路径进行简单的判断(虽然该项目应该只能编译出64位的程序),64位windows通过syswow64支持32位程序的运行,但是64位和32位都依赖system32下的dll,所以程序运行会对目录会有一个重定向,64位的进程可以看见64位的system32目录和32位的syswow64目录,而32位的进程,则可以看见32位的system32目录和64位的sysnative目录(分别对应64位下的syswow和system32),由于我们图形化查看文件夹的文件资源管理器是64位的,所以我们只能看见system32和syswow64

此处的dll经过了CS server的patch,是所谓的reflective DLL,无需对其导出表等进行解析,可以像shellcode一样直接从起始位置开始执行,在patch的同时也添加了命令的参数和写入结果的命名管道,该命名管道名称会由紧随其后的job指令指明。创建远程线程的方法可以说是非常的喜闻乐见了,VirtualAllocEx->WriteProcessMemory->VirtualProtectEx->CreateRemoteThreadEx

内存写入可以用RtlCopyMemory代替WriteProcessMemory,远程线程的创建也可以使用APC那一套,内存分配也可以改,看个人喜好和杀软绕过了

简单测试下来,defender似乎会对几个比较关键的进程进行严密监控,比如spawn一个rundll32再注入的话,会被defender抓,但如果是一个notepad就没有问题,卡巴斯基似乎会检查内存,notepad也会概率抓,360核晶的话感觉是调用进程注入类的函数就抓,不管你注入的是啥不管你调用的是哪个。。。

所以在config.go下增加了一个自定义选项,injectSelf,使用后将spawn类job不创建远程进程注入而是直接注入自身进程。360核晶只抓远程线程创建,只要在自己的进程内创建即不会被拦截。然而这里有一个问题,即spawn类job的dll在退出时调用的是ExitProcess,若在自己进程内注入会导致beacon退出,需要进行额外patch。搜索的结果是直接将dll中的ExitProcess字符串替换为ExitThread缺位补零即可。。。虽然可以猜测可能windows的导出表是通过字符串去寻找函数的,但还是觉得多多少少有点离谱,不是很懂windows捏

数据读取

job指令会下发命名管道名和任务类型,同时需要维护一个job列表,支持使用jobs命令和jobkill取消,因此指令的读取应当采用异步的形式完成,go提供了非常简单易用的异步与同步,直接使用go func()即可创建一个异步线程(协程?),在线程中读取结果并回传即可。同时,采用channel的方式进行同步,为每个job提供一个stopCh,使用for select语句检查是否收到结束信号,需要结束线程时,向stopCh发出结束信号,即可断开管道终止本次任务

    for {
        select {
        case <-j.stopCh:
            return result + fmt.Sprintf("\njob %d canceled", j.jid), nil
        default:
            n, err := pipe.Read(buf)
            // if you kill the process, pipe will be closed and there will receive an EOF
            if err != nil {
                if err != io.EOF && err != windows.ERROR_PIPE_NOT_CONNECTED {
                    return "", err
                }
                return result, nil
            }
            result += string(buf[:n])
        }
    }

后渗透模块

具体实现位于command/lateralMovement.go
暂时只实现了内存执行powershell module和内存执行C#程序,不过有这两个好像大部分功能也都支持了(大概)

内存执行powershell

内存执行powershell module的思路很简单,CS server先后下发37,79两个指令,最后用run指令来执行powershell
37为CMD_TYPE_IMPORT_PS,简单的下发需要导入的ps脚本,拿一个全局变量存住,79是CMD_TYPE_WEB_DELIVERY,参数为端口,要求beacon在本地的对应端口启动一个一次性的http服务,提供的内容即为之前下发的ps脚本。最后的run指令会使用powershell先去127.0.0.1:port下载对应的脚本后再执行命令。因为是提供一次性服务,所以webdelivery直接用socket手写一个即可

内存执行C

server端代码在beacon/JobSimple.class
至于内存执行C#,虽然有单独下发的命令号70/71/87/88,但其实实现与spawn差距不大,通过拉起一个进程注入一个创建内存执行C#环境的dll,然后将C#程序作为参数创建远程线程,可以套用spawn的实现完成,但此时没有命名管道,需要spawn的时候创建匿名管道拿输出,但注入远程dll容易被抓,所以同样实现了一版使用go第三方包直接执行的方案,也实现了injectSelf的方案,但是拿不到回显。。。

下发的参数有一些复杂

//misc.go parseExecAsm
buf := bytes.NewBuffer(b)
callBackType := packet.ReadShort(buf)
sleepTime := packet.ReadShort(buf)
offset := packet.ReadInt(buf)
description, err := parseAnArg(buf)
csharp, err := parseAnArg(buf)
dll := buf.Bytes()

其中csharp中包含的是csharp程序的二进制以及参数,如果原生实现需要再次解析

csharpBuf := bytes.NewBuffer(csharp)
csharpBin, _ := parseAnArg(csharpBuf)
csharpArgs := csharpBuf.Bytes()
args := windows.UTF16PtrToString((*uint16)(unsafe.Pointer(&csharpArgs[0])))

内存执行C#好像还挺牛逼的,杀软都不抓?

参考文献

这篇文章详细描述了token窃取的方法,学到许多.jpg
Understanding and Defending Against Access Token Theft: Finding Alternatives to winlogon.exe
geacon原项目
darkr4y/geacon
鸡哥对beacon命令号的解释
魔改CS beacon
后期找到的这个beacon逆向对我go重写实现也有很大的帮助
WBGlIl/ReBeacon_Src

评论

Z

z3ratu1

这个人很懒,没有留下任何介绍

twitter weibo github wechat

随机分类

Windows安全 文章:88 篇
Web安全 文章:248 篇
运维安全 文章:62 篇
神器分享 文章:71 篇
逆向安全 文章:70 篇

扫码关注公众号

WeChat Offical Account QRCode

最新评论

Article_kelp

因为这里的静态目录访功能应该理解为绑定在static路径下的内置路由,你需要用s

N

Nas

师傅您好!_static_url_path那 flag在当前目录下 通过原型链污

Z

zhangy

你好,为什么我也是用windows2016和win10,但是流量是smb3,加密

K

k0uaz

foniw师傅提到的setfge当在类的字段名成是age时不会自动调用。因为获取

Yukong

🐮皮

目录