二开
红队武器化(二):frp静态特征消除以及流量改造
本文档使用 MrDoc 发布
-
+
红队武器化(二):frp静态特征消除以及流量改造
本文将简单介绍 frp 这款隧道代理工具的项目结构和代码运行流程以及如何通过对 frp 二次开发(后面简称"二开")来消除其静态特征和流量特征从而规避杀软以及 EDR 的检测。 # 前言 大家好,我是拖更博主 r0leG3n7。本文将简单介绍 frp 这款隧道代理工具的项目结构和代码运行流程以及如何通过对 frp 二次开发(后面简称"二开")来消除其静态特征和流量特征从而规避杀软以及 EDR 的检测。如有任何错误和不足欢迎各位师傅指正,转载请注明文章出处。 # frp 项目分析过程 致敬伟大的原项目:[https://github.com/fatedier/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()第一个传入参数为配置文件内容的字符串,返回值与之前一致。 ```php 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]下面添加这一行,不然客户端连接到服务端会报错。 ```php disable_custom_tls_first_byte = false ``` 因为从 frp 的 0.50.0 版本开始,frp 就将禁用发送自定义字节的默认值设置为 true,如果使用了 frp 自定义字节就需要加上这一行  最后一定要记得服务端 frps 的配置文件加上强制 TLS 连接。 ```php tls_only = True ``` 客户端 frpc 的配置文件也加上使用 TLS 加密,不然前面做的一切都白费。 ```php 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/boy-hack/go-strip) [https://github.com/burrowers/garble](https://github.com/burrowers/garble) [https://github.com/masterqiu01/cross-file-obfuscator](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 ```php 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 末尾追加如下 ```php export GOROOT=/usr/local/go export GOPATH=$HOME/go # 推荐设置工作目录 export PATH=$PATH:$GOROOT/bin:$GOPATH/bin ``` 3、使配置生效 ```php source ~/.zshrc ``` ### garble 安装 ```php export GOPROXY=https://goproxy.io go install mvdan.cc/garble@latest ``` ### 编译 1、在 kali 使用 garble 编译前建议先使用原生的 go 编译器编译一次,主要是让其下载对应的依赖 ```php go build -o ./frpc.exe ./cmd/frpc ``` 2、然后再用 garble 编译(在 kali 上编译需要指定目标架构,我这里编译的是 windows 的 amd64) ```php 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 不想指定目标架构,也可以使用如下命令跨平台编译 ```php 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](https://github.com/ph4ntonn/Stowaway),它也是一个很值得去二开的一个项目,Stowaway 的优点是它既能做客户端也能做服务端,往往搭建二级代理或者三级代理上传一个可执行程序就足够了。
admin
2026年2月5日 17:41
转发文档
收藏文档
上一篇
下一篇
手机扫码
复制链接
手机扫一扫转发分享
复制链接
Markdown文件
PDF文档(打印)
分享
链接
类型
密码
更新密码