0%

CobaltStrike beacon二开指南

CobaltStrike beacon二开指南

本文首发跳跳糖
https://tttang.com/archive/1789/

update

因为geacon_pro项目摸了300多个star了 500个辣,超乎了我们的预期,所以把这个文章再订正一下,补充更多当时我在缺乏资料时强行开发踩过的坑。

喜大普奔,geacon_pro项目已经加入404 Starlink
已经退出了呜呜

2023/4/4 update

最后pro项目赚取了接近1k个star,但由于pro项目攻击性过强且存在被滥用风险,我们已决定将此项目转为私有,不再对外开放,请勿使用plus/pro项目对计算机系统进行攻击,所造成的风险和危害均由使用者承担。
pro项目转为私有后已经与starlink进行沟通并下架

本文同样只对当时对plus项目进行开发的历程进行记录,本文记录的思路仅供相关安全技术学习使用

2023/5/17 update

被thehackernews点名批评了。。。算是知名度提升的一种表现?那么接下来这个项目也会慢慢的被标记上特征然后疯狂报毒了捏
Hackers Using Golang Variant of Cobalt Strike to Target Apple macOS Systems

原文

CobaltStrike真好用啊,但是缺点就是太好用了被各类安全厂商严防死守,研究了半天shellcode loader和白加黑之类的免杀,多多少少都有翻车的记录,有没有更好的办法呢?
要不就自己从头写一个beacon吧,没有出现过就不会被杀软抓住(确信),该文章基于为我自己写的初版项目geacon_plus,与pro版本代码在封装方面有部分出入,但两个功能差距不大

项目链接geacon_plus,暂只支持CS4.0,感觉加一个高版本支持是不是能多摸点star。。。加高版本支持了,现在能在config.go中改一个配置切换到4.1+支持模式,给个star8,求求了呜呜

姊妹项目geacon_pro已上线,整体差距不大,但支持4.1+版本,两项目使用的免杀和执行等手段基本一致,但由于代码风格不同懒得合并,就分成两个发了。本文主要讲一下开发时的一些协议格式和思路,免杀的具体思路可参考geacon_pro的README

为了降低本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号,进程位数等信息。metadata的生成位于packet/packet.goMakeMetaInfo下。metadata的收集由于各平台代码不好兼容,所以单独放在sysinfo packet下各自实现,geacon原项目以及pro版提供了4.1+支持,可以参考那边的代码对不同版本进行适配。第一次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。(但事实上该部分直接狂抄geacon原项目即可,不需要太多考虑)

我在具体实现的时候写了一个有点奇怪的代码,即重复生成了两次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请求均发送该数据

geacon原项目中还有一个比较大的bug,就是sysinfo下的GetMetaDataFlag,用的是if else if。。。只要有一个是真的其他的就被pass了。

最近发现了新的bug,原项目这个函数在进程是x86的时候会+1,实际上是不需要加的,+1导致反而解析出问题,server端不会显示pid和arch,去掉即可

以及进程名,手写CreateProcess拉去的beacon在server显示不到进程名,进程名是使用os.argv[0]获取的,理论上来说CreateProcess的前两个参数,会被当成argv放进来,官方文档里面有这一段

If both lpApplicationName and lpCommandLine are non-NULL, the null-terminated string pointed to by lpApplicationName specifies the module to execute, and the null-terminated string pointed to by lpCommandLine specifies the command line. The new process can use GetCommandLine to retrieve the entire command line. Console processes written in C can use the argc and argv arguments to parse the command line. Because argv[0] is the module name, C programmers generally repeat the module name as the first token in the command line.

也就是说如果把path当lpApplicationName,args当lpCommandLine,那么argv0会有问题,但是我的实现里提供了lpApplicationName的方法只有cmd.exe+/C cmd,实际上是用cmd.exe拉起进程,不知道这里argv是不是有问题,令一种只给lpCommandLine的本身argv[0]就是进程名,为什么获取不到捏?

不过整体问题不是很大,直接用os.Excuteable()拿名字就行了

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
    reply content
}
16bytes hash

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

POST response

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

c2profile

仅实现c2profile中http-get/post块的部分内容,实现对流量的伪装,这里使用的是jQuery风格的请求。需同步c2config.go和server使用的c2profile,务必同步否则用不了
主要的伪装就是发送数据的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

key := data[0:4]
data = data[4:]
data = XOR(data, key)

netbios(u)也贴一下,两个以a/A为偏移的编码

func NetbiosEncode(data []byte, key byte) []byte {
    var result []byte
    for _, value := range data {
        buf := make([]byte, 2)
        buf[0] = (value >> 4) + key
        buf[1] = value&0xf + key
        result = append(result, buf...)
    }
    return result
}

func NetbiosDecode(data []byte, key byte) []byte {
    var result []byte
    for i := 0; i < len(data); i += 2 {
        result = append(result, (data[i]-key)<<4+(data[i+1]-key)&0xf)
    }
    return result
}

功能实现

因为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的一个封装,内部维护了一个读取的指针,就能很方便的顺序读取了

在main设计的思路中,因为一次可能下发多个命令,所以使用while循环反复迭代(go没有while用for代替),同时进行错误处理,所有的命令处理函数均返回一个error结果,如果error不为空则回传错误信息。每个任务是否向server发送请求在任务自己的处理函数中实现。这样子有利于后续使用多线程处理任务时结果的回传

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之类的有手就行操作就不提了,讲两个麻烦一点的。

file browser

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即可,收到的命令是CMD_TYPE_UPLOAD_STARTCMD_TYPE_UPLOAD_LOOP,也可以把start改为打开一个文件,不然连续上传两个一样的文件还append就会出问题

文件下载

另一个比较麻烦的功能是文件下载,毕竟beacon不开tcp链接,只能每次http一点一点传
下载时需要先发送一个CALLBACK_FILE标识文件下载开始,然后循环发送CALLBACK_FILE_WRITE分段传输文件,传输完成后发送CALLBACK_FILE_CLOSE结束传输。

查看了CS原生beacon的回传策略,其维护了一个全局的下载列表数组,每次从server GET一个命令之后,检查downloadList,如果有内容的话就把每个文件回传一个包,这样子做到文件下载不阻塞,但是文件下载的速度就和sleep的时长有关。

我这边一开始是直接写了个go func用协程的方式回传,这样子就速率随意且不阻塞了,但是在下载的同时执行命令或者干其他事情的话,server端会报一个收到的counter比预期小的错误,认为是重放攻击,然后导致下载失败。。

一开始以为是counter的生成竞争了,后来直接控制台输出了一下后发现并不是,那就估计是counter的生成和发送产生了竞争,可能先生成的后发送了,然后把生成和发送的函数打了一个包定义成临界区解决了异步下载问题

list drive

之前的写法完全错误,可能是我的C基础和Java基础太烂,加上没看懂反汇编出来的服务端源码导致的,有一个师傅后续指出了这个问题,并且给出了修好的函数,我就说这个实现应该不会太过愚蠢

func listDrivesImpl(b []byte) error {
    bitMask, err := windows.GetLogicalDrives()
    if err != nil {
        return err
    }
    // geacon_pro FIX CMD_TYPE_DRIVES BUG #34
    result := []byte(util.Sprintf("%d", bitMask))
    packet.PushResult(packet.CALLBACK_PENDING, util.BytesCombine(b[0:4], result))
    return nil
}

timeStomp

这个功能挺好玩的,就是把一个文件的创建修改访问时间和另一个文件同步,这样子把马传上去的时候起一个合理的名字就可以和其他的文件融为一体了,隐蔽很好用。也是用windows API的SetFileTimeGetFileTime就行,直接抄msdn改go,linux和mac下直接用touch命令同步时间戳。

进程管理

具体实现位于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包还自带了不少windows下的数据结构和常量,推荐使用。部分windows api已经在该包中实现了,但同时也有一部分没有,没有的部分还是用经典的NewProc写法调用。直接调用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结构体,一次性完成

黑魔法版本,这么写估计是因为go的数组和切片是玄幻的胖指针

    var b bytes.Buffer
    _ = binary.Write(&b, binary.LittleEndian, uint32(len(privs)))
    for _, p := range privs {
        _ = binary.Write(&b, binary.LittleEndian, p)
        _ = binary.Write(&b, binary.LittleEndian, windows.SE_PRIVILEGE_ENABLED)
    }

结构体版本,非常易用

        var tokenPrivileges windows.Tokenprivileges
        tokenPrivileges.PrivilegeCount = 1
        tokenPrivileges.Privileges[0] = windows.LUIDAndAttributes{
            Luid:       LUID,
            Attributes: windows.SE_PRIVILEGE_ENABLED,
        }

由于CS下发的输入为需要提升的特权名,返回值为成功提升的特权名,而AdjustTokenPrivileges在给与的权限没有成功提升的情况下,得到的错误只会是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)

为了实现该功能,定义了一个数据结构job,通过多线程的方式使job的工作变为异步,并支持jobkill终止任务

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。

使用spawnTempProcess统一拉起傀儡进程,由于job对isX64ignoreToken进行了分类,所以需在此处进行进程位数与路径的判断,并使用createProcessNative判断是否使用token创建进程

此处用于注入的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捏

数据读取

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

在新开线程应答结果的情况下,如果在main函数中统一向server应答就会有点难处理,导致两次应答之类的,所以设计的时候直接每个命令的处理函数自行回复

    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])
        }
    }

job命令还发了一个sleep time下来,但是不知道有什么用。。。所以在读取命名管道的时候,如果第一次没读上,就sleep那个time,然后再读一次。。。

后渗透模块

具体实现位于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])))

如果想使用远程注入执行的,可以使用execAsmInject函数,该函数调用spawnTempProcess拉起傀儡进程注入,此时整个C#文件及其参数作为进程注入时的参数传入

也可以用go对windows api的封装库实现,即execAsmGo方法,此时则需要对C#二进制和参数进行进一步分离后执行

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

字符集兼容

一开始自己测试的时候全英文环境好像也没遇到过乱码问题,项目公开之后大家一测一堆乱码,尤其是linux和mac上,中文文件全都显示为乱码,然后花了一天终于解决了这个编码问题。

说实话嗷,感觉这个开发部分跟学习安全已经没什么关系了,纯开发+体力活

通信编码

通信的编码在生成metadata的时候就被确定了,那两个我一直不知道是干什么的GetCodePageANSIGetCodePageOEM就是确定本地字符集的。CS server会将这个字符集作为和beacon沟通的字符集,即beacon发来的数据均视为该字符集,而下发的命令也使用该字符集编码。在简中windows系统上,默认的字符集是gbk,对应的codepage编号是936,utf8对应的编号是65001。

但是测试下来的结果是cmd执行出来的结果并没有乱码,而go实现的列目录等操作得到的是乱码。为什么呢?这里要提到之前我说的

output有一个utf8版本,但实际测试证明,使用utf8反而会导致中文乱码,不使用还不会

因为CS server会用metadata中指定的字符集进行通信,当我们使用普通版的OUTPUT时,server就以指定的字符集进行处理,使用CALLBACK UTF8反而会强行指定server将数据当成utf8处理,当时测试的数据是用shell命令用匿名管道从cmd.exe中取出来的,是gbk字符集,用UTF8处理自然会出问题。

然而,只有从cmd.exe这些地方取出来的字符串是gbk的,而go使用utf8处理所有字符串!所以go实现的功能中返回的结果就会出现乱码,比如go的列目录,如果有中文目录,回传的是UTF8的字符串,却被当成gbk处理,结果只能是乱码

解决方案

似乎很简单,只要把go实现的功能改成CALLBACK UTF8,而从原生位置管道读出来的全都原版OUTPUT即可。但是不要忘记一个很严肃的问题,server也会使用这个字符集和beacon沟通,由于beacon是用go写的,所以也只能正确处理UTF8字符串,而当前情况下server下发的是gbk的命令,显然无法正常执行

想了半天,需要处理的内容出现在三个位置

  1. server下发的输出
  2. go产生的utf8字符串
  3. windows产生的本地字符集

需要注意的还有一个问题,即GO调用winapi时的行为,以CreateProcess为例,该函数实际上是CreateProcessA/CreateProcessW的一个宏,该宏指向哪个函数在C代码编译时通过制定字符集确定,A表示ANSI,W表示wide word,前者即为使用ANSI字符集,后者表示使用UTF16,由于go中所有字符串都是当utf8处理的,所以go中windows包下的CreateProcess直接调用CreateProcessW,同理,对于StringToUTF16Ptr之类的函数也均认为输入的字符串为UTF8格式。

综上所述,最后感觉最优的做法是在获取字符集的时候硬编码为UTF8,由于go本身用utf8处理数据,那就直接以go为抽象层,起码保证go和server的数据都是utf8的,从windows拿来的数据由go处理再转换为utf8回传。这样子的话处理点也只需要在PushResult函数处修改即可,不需要每个功能一个个改

go对utf8的支持很完善,提供了很多好用的函数,utf8.Valid可以较为准确的判断对应字符串是不是utf8格式的,之后进行转码即可。转码方面有两个实现方案,第一个是使用go的charset+transform包,这两个包涵盖了差不多所有字符集,可以通过字符集的名称查找字符集并转换到UTF8,只需要手动维护一个ANSI code到字符集名称的字典即可,并且支持各种平台,实现如下

var codepageMap = map[uint32]string{
    936:   "gbk", // gb2312
    950:   "big5",
    54936: "gb18030",
    65001: "utf8",
}

func codepageToUTF8(b []byte) ([]byte, error) {
    // if is not utf8
    if !utf8.Valid(b) {
        transformer, _ := charset.Lookup(codepageMap[sysinfo.ANSICodePage])
        if transformer == nil {
            return nil, errors.New("unknown codepage")
        }
        reader := transform.NewReader(bytes.NewReader(b), transformer.NewDecoder())
        result, err := io.ReadAll(reader)
        if err != nil {
            return nil, err
        }
        return result, nil
    }
    return b, nil
}

但是有一个很明显的缺陷,charset包自带了全套字符集,导致其体积较大,trip过的二进制文件本身就已经高达5M,如果使用该包将会额外增加约800k的体积

第二个方案是使用winapi对内容进行转码,思路也很明确,由于linux和mac都直接默认utf8字符集,只有windows在搞奇怪的本地字符集,linux和mac上无需考虑转码(注:原项目将linux和mac也硬编码成了gbk,所以会有乱码,尤其是linux下甚至还给端序写错了。。。。排错排半天)
故单独将windows转码即可,linux和mac下实现一个空函数

const MB_PRECOMPOSED = 2

func codepageToUTF8Native(b []byte) ([]byte, error) {
    if !utf8.Valid(b) {
        // set last args to zero to get the needed size
        cnt, err := windows.MultiByteToWideChar(sysinfo.ANSICodePage, MB_PRECOMPOSED, &b[0], int32(len(b)), nil, 0)
        if err != nil {
            return nil, err
        }
        utf16Bytes := make([]uint16, cnt)
        cnt, err = windows.MultiByteToWideChar(sysinfo.ANSICodePage, MB_PRECOMPOSED, &b[0], int32(len(b)), &utf16Bytes[0], cnt)
        if err != nil {
            return nil, err
        }
        utf8Bytes := utf16.Decode(utf16Bytes)
        return []byte(string(utf8Bytes)), nil
    }
    return b, nil
}

MultiByteToWideChar这个函数用不好就会溢出,当输出字符串的长度(最后一个参数)为0时,该函数仅返回所需的大小,所以这里分别调用以先获取所需空间再进行转码,该方案就直接用本来已经依赖的windows包,不会对编译后的体积产生影响

仍未处理的问题

因为所有输出都是从PushResult走,因此在这里添加一个转码就是对所有的结果进行utf8编码,但是这么做仍然会导致问题。因为此处的结果是组装完成的结果,对于部分类型的数据,如CALLBACK_PENDING类型的结果,会在回传数据的前面添加一个四字节的pending request bytes。而这个字节仅是一个uint32,但传给utf8.Valid时有可能会被误判为非utf8字符串,导致整个结果被再次转码,进一步导致超级乱码。。。

有两个思路,第一个是只处理CALLBACK_OUTPUT的内容,因为shell几个常见的从windows拿原生输出的都是这个,并且没有额外的prepend padding之类的东西,不会误报。但这样子有另一个问题,进程注入一类的操作也是拿原生输出,并且callback type是随命令下发自定义的,希望这些功能得到的结果大多数是全英文的吧。。。要么就输出结果直接是utf8吧。。。。
另一个思路就是分类处理,把所有有填充的命令类型枚举出来,然后把填充去掉之后再转,是一个比较保险的思路,但是就要一个个分类讨论了,懒ing,出了问题再说吧

迷之阻塞及其处理

使用类似shell calcshell tasklist之类的命令的时候,会出现永久等待 ,前者是因为命令没回显,windows.ReadFile(hRPipe, buf, &bytesRead, &read)会一直等待输出,后者是因为tasklist不退出,windows.WaitForSingleObject永久等待。解决方案也很简单,WaitForSingleObject等待10s否则超时,PeekNamedPipe检查一下有没有输出再决定是否去读。顺便还做了个WaitForSingleObject的超时判断,超时就把这个process kill掉,免得一直挂着。不过这样子还是有问题,因为shell是用cmd.exe去拉,实际上kill掉的是cmd.exe,而tasklist就永久挂在那了,如果是run执行的倒是能正常关掉

参考文献

这篇文章详细描述了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