红队武器化(二):frp静态特征消除以及流量改造
前言
大家好,我是拖更博主r0leG3n7。本文将简单介绍frp这款隧道代理工具的项目结构和代码运行流程以及如何通过对frp二次开发(后面简称"二开")来消除其静态特征和流量特征从而规避杀软以及EDR的检测。如有任何错误和不足欢迎各位师傅指正,转载请注明文章出处。
frp项目分析过程
致敬伟大的原项目:https://github.com/fatedier/frp,我选择的是较新版本0.65.0的frp。(我写这篇文章的时候frp刚刚更新到0.66.0,但应该不影响我当时二开的就是最新版的frp[狗头])。
先问大家一个问题,如果我需要对某个开源项目进行二开,我有必要把这个开源项目里所有的代码结构都分析得明明白白的嘛?那当然是没那个必要,对于这种大型的开源项目,我们需要很明确自己想要把这个项目改成什么样子,确定自己的需求,确定项目有哪些对于你来说是"缺陷"的地方,这样就不会在庞大的代码海洋里迷失自己。
需求分析
1、首先来到我们软件生命周期最重要的需求分析阶段,我的大致的需求就是要改frp的静态特征和流量特征来绕过EDR检测达到免杀的效果,确定大致的需求以后我需要知道frp有哪些特征。
原版的frp有如下几个典型特征:
1)frp的服务端和客户端启动时都会默认读取同目录名为frpc.ini或者frps.ini的配置文件。
2)frp的客户端与服务端发起TCP连接时会发送诸如版本号、架构、token、run id等信息进行登录认证。
3)frp的客户端与服务端在连接成功或者失败时都会在控制台输出一些debug信息或者提示信息。
4)frp的客户端与服务端在TCP连接建立后的第一个应用层数据包会发送一个自定义的字节,这个字节的值为0x17。
5)frp的客户端与服务端在TCP连接建立成功后,服务端可以通过对某些API接口发起get请求、post请求或者put请求去控制客户端,比如/api/reload、/api/stop等。
2、确定大致的需求并且定位"缺陷"以后,我要明确我的需求,明确我要把它改成什么样子。
我明确的需求:
1)对于frp的服务端和客户端在本地读取配置文件的行为,我可以把配置文件信息想象成shellcode,按照loader加载shellcode那样处理。我想到的是将配置文件硬编码在程序里面;或者将配置文件加密后通过命令行传入frp,frp客户端与服务端尝试建立连接时再进行解密;或者通过远程URL加载;还有最重要的是去除通过文件路径读取配置文件的功能。
2)对于frp的服务端和客户端建立连接失败时的输出的错误信息,我要进行删除或者修改;对于建立连接成功时发送的登录信息或者代理信息,我要进行TLS加密或者将默认变量名、键值对修改;对于TLS建立连接的默认自定义字节以及服务端控制客户端的api默认接口名也是一样地做修改处理。
项目分析
1、程序所需的依赖写在了项目的go.mod文件中,在GoLand的IDEA可以按"Alt键+回车键"自动下载对应的依赖。

2、项目的Makefile是编译命令文件,在这里可以找到服务端代码入口/cmd/frps以及客户端的代码入口/cmd/frpc。

3、我们可以直接定位到客户端/cmd/frpc/mian.go,编译时会自动搜索该目录下的mian.go文件作为编译的入口,重点关注sub.Execute()。

4、按alt跟进sub.Execute(),它的主要功能是rootCmd.Execute()这行 。

5、按alt跟进rootCmd.Execute(),rootCmd.Execute()会执行rootCmd中的RunE,RunE中包含两个关键的函数runMultipleClients()和runClient(),这两个函数主要的功能就是加载配置文件然后建立与服务端的链接。我们主要看runClient()函数,一般情况下一个frpc客户端只加载一个配置文件,所以不用怎么去考虑改runMultipleClients(),我二开的时候索性直接删掉了命令行配置文件路径的输入。runClient()传入一个名为cfgFile的全局变量,它是frp客户加载配置文件的路径。

6、纵观整个rootCmd.Execute()过程,我们都没有看到给cfgFile全局变量赋值的地方。但是我们知道原版的frpc客户端是从命令行输入配置文件路径的,我们可以从包的init()函数看到程序是怎么从命令行中获取用户输入的配置文件路径赋值cfgFile变量的。init()函数是 Go 语言中的一个特殊函数,通常用于资源、包和变量的初始化。它的特点是每个包的 init() 在程序运行期间只执行一次;init()无需手动调用,会在main()之前自动执行,导入包的 init() 先于当前包的 init() 执行。

7、回到rootCmd.Execute(),按alt跟进runClient()函数,我们来到了这次二开中最重要的函数config.LoadClientConfig(),它是我们修改配置文件传参关键。从返回值我们可以知道,它会返回配置文件的基本配置信息、代理配置信息、配置文件格式等。config.LoadClientConfig()的传入参数是配置文件路径,这个函数是需要完全改写的,我上面的需求已经说的很明确了,我会从硬编码、远程URL输入或者命令行输入去读取配置文件,不会有从文件路径读取配置文件的行为,减少文件落地。

8、虽然说要完全改写config.LoadClientConfig(),但是我们还是要按alt跟进看一下它的内部逻辑以便我们更精确无误地对它进行修改,config.LoadClientConfig()存在读取并转换配置文件的legacy.ParseClientConfig()方法。

9、按alt跟进legacy.ParseClientConfig(),legacy.ParseClientConfig()函数通过文件读取函数GetRenderedConfFromFile()以及传入的文件路径来读取配置文件信息并将其赋值content变量,然后将content的类型转化为字节数组后将其作为参数传给UnmarshalClientConfFromIni()方法,UnmarshalClientConfFromIni()将转换后的基础配置文件信息赋值给cfg。

10、同样地,legacy.ParseClientConfig()通过legacy.LoadAllProxyConfsFromIni(),将转换后的代理配置文件信息等赋值给变量proxyCfgs和visitorCfgs。这时候我们知道配置文件信息主要是靠UnmarshalClientConfFromIni()和LoadAllProxyConfsFromIni()两个函数进行转换的,到时候我们二开的时候就照着这两个函数简单修改一下就行了。

11、了解完它是怎么读取并转化配置文件信息后,我们再回到上面的runClient()函数,再大致了解一下它是怎么通过startService方法以及转化后的配置文件信息启动服务的,这里注意startService方法第五个参数cfgFile为配置文件路径,到时候服务端调用/api/reload接口重新加载配置文件时候会用到。因为我二开时将通过文件路径读取配置文件信息这个行为删除了,这个参数到时候会变成空值,这个参数置空以后服务端调用该接口可能会报错。


12、service.go的NewService创建服务对象方法。

13、service.go的Run运行服务对象方法。

frp项目二开过程
本节我将介绍如何对frp原项目进行二开改造隐藏其静态特征和流量特征,包括修改传参方式,修改frp默认输出,修改frp静态字符串,修改frp的TLS流量特征等。相信看过四大名著《三国演义》的都知道赵云在长坂坡七进七出,单骑救主的故事,我第一次了解到这个故事的时候我就觉得不可思议,真的有人能从这么多的魏军人马中带着个婴儿死里逃生吗?在二开了frp之后,我就悟到了。frp客户端就是赵云,配置文件就是阿斗,单骑救主护送阿斗回蜀就是frp客户端与服务端建立连接的通信过程。赵云之所以会被在茫茫人海中被魏军检测到,并不只是因为他喊了那句"我乃常山赵子龙",更多的是因为他有对阿斗进行明目张胆地"取餐"这个行为,不过好在他能及时调整,将阿斗硬编码到自己的怀里,才做到了七进七出。我觉得单骑救主这个故事可以有更多opsec的改进方案让他变得更加合理更加地叫人信服,至于怎么改,请看下面听我娓娓道来。
传参方式
传参方式的修改在上面需求的第一条已经提出来了,我的最终方案是去除通过文件路径读取配置文件的部分;如果frp收到命令行传入的加密配置文件,就解密该配置文件进行连接;如果读取不到命令行传入的加密配置文件,就读取硬编码的配置文件进行连接。
1、首先去除init()函数中接收对配置文件路径的输入,新增一个全局变量eStr,用于接收用户控制台输入的加密后的配置文件信息,使用示例"-e <加密的配置文件信息>"。
rootCmd.PersistentFlags().StringVarP()方法参数说明:
第一个参数为接收控制台输入的指针
第二个参数为参数名称
第三个参数为传入参数的简写,比如"-c ./frpc.ini"
第四个参数为参数的默认值(StringVarP就必须为字符串类型,BoolVarP就必须为布尔类型,以此类推)
第五个参数为参数介绍说明

2、修改rootCmd.Execute()逻辑,当eStr变量不为空(也就说收到来自用户在命令行输入的加密配置文件内容),就对传入的加密配置文件内容进行解密,将它解密后的明文传给一个自定义的cfgContent变量;如果eStr变量为空,就将硬编码的配置文件信息传给cfgContent变量。cfgContent变量最终会作为参数传给修改后的runClient()函数。

3、修改runClient()函数运行逻辑,之前runClient传入第一个参数是配置文件路径,我现在将这个参数改成配置文件内容,到时候硬编码的配置文件或者解密后的配置文件可以直接作为参数调用这个函数,修改的地方主要是config.LoadClientConfig()这个部分,将其修改为了一个新的函数config.LoadClientConfigFromContent(),用于接收传入的配置文件内容并将其转换。

4、config.LoadClientConfigFromContent()第一个传入参数为配置文件内容的字符串,返回值与之前一致。
func LoadClientConfigFromContent(content string, strict bool) (
*v1.ClientCommonConfig,
[]v1.ProxyConfigurer,
[]v1.VisitorConfigurer,
bool, error,
) {
var (
cliCfg *v1.ClientCommonConfig
proxyCfgs = make([]v1.ProxyConfigurer, 0)
visitorCfgs = make([]v1.VisitorConfigurer, 0)
isLegacyFormat bool
)
contentBytes := []byte(content)
// Render template with values
renderedContent, err := RenderWithTemplate(contentBytes, GetValues())
if err != nil {
return nil, nil, nil, false, fmt.Errorf("render template error: %v", err)
}
if DetectLegacyINIFormat(renderedContent) {
// Parse legacy INI format
legacyCommon, err := legacy.UnmarshalClientConfFromIni(renderedContent)
if err != nil {
return nil, nil, nil, true, err
}
// Parse all proxy and visitor configs from the same content
legacyProxyCfgs, legacyVisitorCfgs, err := legacy.LoadAllProxyConfsFromIni(legacyCommon.User, renderedContent, legacyCommon.Start)
if err != nil {
return nil, nil, nil, true, err
}
cliCfg = legacy.Convert_ClientCommonConf_To_v1(&legacyCommon)
for _, c := range legacyProxyCfgs {
proxyCfgs = append(proxyCfgs, legacy.Convert_ProxyConf_To_v1(c))
}
for _, c := range legacyVisitorCfgs {
visitorCfgs = append(visitorCfgs, legacy.Convert_VisitorConf_To_v1(c))
}
isLegacyFormat = true
} else {
allCfg := v1.ClientConfig{}
if err := LoadConfigure(renderedContent, &allCfg, strict); err != nil {
return nil, nil, nil, false, err
}
cliCfg = &allCfg.ClientCommonConfig
for _, c := range allCfg.Proxies {
proxyCfgs = append(proxyCfgs, c.ProxyConfigurer)
}
for _, c := range allCfg.Visitors {
visitorCfgs = append(visitorCfgs, c.VisitorConfigurer)
}
}
if len(cliCfg.Start) > 0 {
startSet := sets.New(cliCfg.Start...)
proxyCfgs = lo.Filter(proxyCfgs, func(c v1.ProxyConfigurer, _ int) bool {
return startSet.Has(c.GetBaseConfig().Name)
})
visitorCfgs = lo.Filter(visitorCfgs, func(c v1.VisitorConfigurer, _ int) bool {
return startSet.Has(c.GetBaseConfig().Name)
})
}
proxyCfgs = lo.Filter(proxyCfgs, func(c v1.ProxyConfigurer, _ int) bool {
enabled := c.GetBaseConfig().Enabled
return enabled == nil || *enabled
})
visitorCfgs = lo.Filter(visitorCfgs, func(c v1.VisitorConfigurer, _ int) bool {
enabled := c.GetBaseConfig().Enabled
return enabled == nil || *enabled
})
if cliCfg != nil {
if err := cliCfg.Complete(); err != nil {
return nil, nil, nil, isLegacyFormat, err
}
}
for _, c := range proxyCfgs {
c.Complete(cliCfg.User)
}
for _, c := range visitorCfgs {
c.Complete(cliCfg)
}
return cliCfg, proxyCfgs, visitorCfgs, isLegacyFormat, nil
}
5、将config.LoadClientConfigFromContent()返回的cfg, proxyCfgs, visitorCfgs作为参数传给startService(),传参方式这部分就修改完成了。注意startService()的第五个参数为配置文件路径,我修改frp的传参方式以后这个配置文件路径的值就不存在了,所以我把它的值置空了,这个参数在后面调用/api/reload重新加载配置文件时候会用到,如果仍需要调用这个api建议将其修改为一个系统默认路径,不然调用时可能会导致frp客户端异常。

6、运行效果就是先用加密程序将frpc配置文件加密后的字节数组以base64编码的字符串输出。

7、frpc客户端启动时如果接收到-e 传入的Base64编码的字符串,frpc客户端就会解密该字符串并转化配置文件;如果接收不到-e 传入的Base64编码的字符串,frpc客户端就会读取代码内硬编码的配置文件。从这里我们也可以看到frp客户端在连接到服务端时会输出一些诸如"client/service.go:331"的信息,这些都是要做处理的。

流量改造
上面讲frp特征的时候提到frp的客户端与服务端在TCP连接建立后的第一个应用层数据包会发送一个值为0x17的自定义字节,这个是frp在流量层面最显著的特征之一,就好像赵云的武器"龙胆亮银枪"以及坐骑"照夜玉狮子",使得赵云在茫茫魏军人马中被一眼认出。
我们先用wireshark看下frp原味的通信流量,下图是frp的TLS的握手流量,可以从"Client Hello"和"Server Hello"这几个关键字快速定位,点击"Transport Layer Security",我们可以看到这部分的第一个字节是0x16。

再看frp部分的应用流量,点击"Transport Layer Security",我们可以看到这部分的第一个字节是0x17。

再通过搜索0x17和0x16定位到项目中/pkg/util/net/tls.go和/server/server.go,我们看到自定义字节变量FRPTLSHeadByte的值为0x17,以及判断0x16和x017 switch逻辑,似乎就能跟上面wireshark看到的流量特征扯上一些联系?我之前听有些卖frp免杀课的人说修改了这个变量FRPTLSHeadByte(旧版的frp好像是另一个变量名)的值,就能消除上面wireshark看到的frp的0x17流量特征。


事实真的是这样的吗?其实他们只说对了一半,修改变量FRPTLSHeadByte确实能消除frp一部分的流量特征。但无论你怎么改FRPTLSHeadByte的值,如果像上面那样看frp的TLS的握手流量和frp的应用流量,无论你怎么改,看到的还是0x17。因为上面看到的那部分就是TLS的正常流量,TLS记录协议头固定为5字节,第一个字节0x17代表应用数据,如果是0x16代表TLS握手数据;第二第三个字节代表TLS版本;第四第五个字节代表数据长度。

他们错误地把FRP自定义字节理解为TLS记录协议头固定字节的第一个字节,因为FRP自定义字节默认值刚刚好就是0x17,和应用数据的TLS记录协议头第一个字节一样。但实际上FRP自定义字节是客户端与服务端在TCP连接建立后的第一个应用层数据包发送的第一个字节,为了方便大家理解,我把FRP自定义字节从默认值0x17改成了0x18,这个字节会在TLS握手之前发送,可以结合下图去理解。

在wireshark内,我们右键frp的tcp流量,选择"追踪流",再选择"TCPStream"

再选择显示为"Hex转储",我们就能看到frp自定义字节修改前的样子:

frp自定义字节修改后的样子:

但其实仅修改这一个字节还是不够,有部分厂商的流量设备已经能自动识别这类单字节修改后的流量,要想在流量层面隐藏得更好需要修改/pkg/util/net/tls.go的CheckAndEnableTLSServerConnWithTimeout()函数,在TLS握手之前多填充几个自定义字节。

在修改完自定义字节以后,需要frpc配置文件中[common]下面添加这一行,不然客户端连接到服务端会报错。
disable_custom_tls_first_byte = false
因为从frp的0.50.0版本开始,frp就将禁用发送自定义字节的默认值设置为true,如果使用了frp自定义字节就需要加上这一行

最后一定要记得服务端frps的配置文件加上强制TLS连接。
tls_only = True
客户端frpc的配置文件也加上使用TLS加密,不然前面做的一切都白费。
tls_enable = true
修改控制台输出
这一步主要是防止赵云有事没事就喊一句"我乃常山赵子龙"。其实就是简单改一些frp默认控制台输出,减少被检测识别的概率。
/client/server.go



/client/control.go

/pkg/auth/token.go

/cmd/frpc/sub/root.go

其他frp特征
默认字符串
/pkg/msg/msg.go中有frp客户端与服务端通信时的登录信息以及代理信息,在使用了TLS加密以后这个不修改其实也不会有很大影响,但是改了总比没改会好点。


默认盐值
client/service.go和server/service.go存在默认盐值crypto.DefaultSalt,frp身份认证过程不是直接交换密码或者token,而是进行带盐值的hash计算和比较,不修改默认盐值可能存在被爆破的风险。

配置文件验证
/cmd/frpc/sub/verify.go
这里也有一个包含校验读取配置文件方法的go文件,它也会有个读取本地文件的行为,这里可以直接删除掉/cmd/frpc/sub/verify.go,对编译和运行没影响,不删除反而在读取不到本地配置文件时会输出一些frp的特征,还能缩小编译后的文件体积。

版本信息
/pkg/util/version/version.go中存在frp的版本信息

api
/cmd/frpc/sub/admin.go
1、frp服务端提供了三个api接口控制frp客户端,这三个接口的功能分别是重新加载配置文件、查看代理状态、停止frp客户端运行。

2、重新加载配置文件接口会读取frp首次与客户端连接时输入的配置文件路径。

3、svr.configFilePath的来源于startService()方法传入的第五个参数,这部分要在runClient方法内修改。

/client/admin_api.go

混淆与编译
市面上常用的go语言工具混淆有三种,分别是:go-strip、cross-file-obfuscator和garble。附上项目地址:
https://github.com/boy-hack/go-strip
https://github.com/burrowers/garble
https://github.com/masterqiu01/cross-file-obfuscator
大家现在千万别用go-strip去混淆,go-strip前两年生存环境还可以,现在的杀软很容易识别到go-strip,并且只要是go-strip混淆就判定为恶意软件。我现在使用的最多的是garble,它能混淆一些类名以及字符串,并且能较好地压缩文件体积,garble需要go语言1.25.0以上的版本才能使用,下面简单介绍一下Kali怎么安装1.25.0以上的go语言以及garble。
go安装
1、下载解压go
wget https://mirrors.aliyun.com/golang/go1.25.4.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.25.4.linux-amd64.tar.gz
nano ~/.zshrc
2、配置环境变量,~/.zshrc末尾追加如下
export GOROOT=/usr/local/go
export GOPATH=$HOME/go # 推荐设置工作目录
export PATH=$PATH:$GOROOT/bin:$GOPATH/bin
3、使配置生效
source ~/.zshrc
garble安装
export GOPROXY=https://goproxy.io
go install mvdan.cc/garble@latest
编译
1、在kali使用garble编译前建议先使用原生的go编译器编译一次,主要是让其下载对应的依赖
go build -o ./frpc.exe ./cmd/frpc
2、然后再用garble编译(在kali上编译需要指定目标架构,我这里编译的是windows的amd64)
export GOOS=windows
export GOARCH=amd64
garble build -o ./frpc-1.exe ./cmd/frpc
3、garble压缩效果,原生的go编译器编译出来的大小为23Mb左右,garble编译出来的为18Mb左右

4、garble的混淆效果如下:
原生的go编译器

garble

5、如果kali不想指定目标架构,也可以使用如下命令跨平台编译
make -f Makefile.cross-compiles
免杀效果
客户端在卡巴斯基和核晶环境下通过frp代理做端口扫描能稳定运行

在没有签名没有做反沙箱状态下上传至VT,只有ESET-NOD32识别出来了是frp客户端

总结
frp在做完传参方式修改、流量改造以及garble混淆后基本就能bypass大部分杀软了。传参方式修改是必须做的,它不仅可以减少文件落地,更是一种针对frp特定的反沙箱操作,某些沙箱提供带有特定文件(比如frpc.ini和frps.ini)的环境来针对性地检测frp。在此基础再加上一些比如延迟执行等反沙箱操作就更好了,如果能把frp做成BOF插件从内存加载那就更opsec了。流量改造方面除了修改frp自定义字符,frp客户端与服务端连接时尽量以域名进行连接,不要直接暴露原生IP,服务端的域名要做cdn防护或者使用云平台PaaS进行转发,然后配置好自定义的TLS证书(最好TLS证书也像配置文件那样内嵌到程序里面,减少文件落地),这样能减少被溯源的概率。静态特征方面,除了garble混淆以外,如果在确定了是目标环境是什么杀软以后,可以考虑使用UPX对frp进行加壳,某些杀软对UPX并不敏感,加壳可以解决百分之90的静态特征问题,但是加壳以后UPX也成为了它的静态特征,不过UPX的静态特征也是可以处理的,其他静态特征处理可以翻看我公众号之前的文章,此处就不再赘述了。除了以上这些二开的点,我们还可以把frp做成系统服务进行权限维持、添加ICMP隧道类型等。
frp的优点就是稳定,但是缺点也很明显,当你需要搭建二级代理或者三级代理的时候,你就不得不把frp二级或三级代理的服务端也上传上去,这意味着你要上传很多个客户端以及服务端,而且对他们都要做免杀处理。隧道代理工具我目前用的比较多的除了frp还有Stowaway,项目地址:https://github.com/ph4ntonn/Stowaway,它也是一个很值得去二开的一个项目,Stowaway的优点是它既能做客户端也能做服务端,往往搭建二级代理或者三级代理上传一个可执行程序就足够了。