一个使用http流量进行混淆的socks5代理。
服务器端:sogo-server
之前写了一个http代理,用起来也是十分地舒服,但是有几个点还是有些遗憾的:
- http代理只能代理http协议,相比socks5代理不够通用。。
- netty是个好框架,但是java占用内存是真的多。。
所以,我又写了一个socks5代理,起名叫sogo。
sogo本身包含sogo(client)和sogo-server。如果把sogo和sogo-server看成一个整体,一个黑盒,这个整体就是一个socks5代理。sogo(client)与本地电脑交互;sogo-server与目标网站交互;sogo(client)和sogo-server之间的交互就是http协议包裹payload进行通信。
以下是观看一个视频的日志:
2019/04/10 00:14:28 main.go:58: 与客户端握手成功
2019/04/10 00:14:28 main.go:207: 目的地址类型:3 域名长度:15 目标域名:www.youtube.com 目标端口:443
2019/04/10 00:14:28 main.go:58: 与客户端握手成功
2019/04/10 00:14:28 main.go:207: 目的地址类型:3 域名长度:11 目标域名:i.ytimg.com 目标端口:443
2019/04/10 00:14:28 main.go:58: 与客户端握手成功
2019/04/10 00:14:28 main.go:207: 目的地址类型:3 域名长度:13 目标域名:yt3.ggpht.com 目标端口:443
2019/04/10 00:14:35 main.go:58: 与客户端握手成功
2019/04/10 00:14:35 main.go:207: 目的地址类型:3 域名长度:32 目标域名:r2---sn-i3belnel.googlevideo.com 目标端口:443
2019/04/10 00:14:35 main.go:58: 与客户端握手成功
2019/04/10 00:14:35 main.go:207: 目的地址类型:3 域名长度:32 目标域名:r2---sn-i3belnel.googlevideo.com 目标端口:443
2019/04/10 00:14:35 main.go:58: 与客户端握手成功
2019/04/10 00:14:35 main.go:207: 目的地址类型:3 域名长度:32 目标域名:r2---sn-i3belnel.googlevideo.com 目标端口:443
以下是访问github的日志:
2019/04/10 00:15:57 main.go:58: 与客户端握手成功
2019/04/10 00:15:57 main.go:207: 目的地址类型:3 域名长度:10 目标域名:github.com 目标端口:443
2019/04/10 00:15:57 main.go:58: 与客户端握手成功
2019/04/10 00:15:57 main.go:207: 目的地址类型:3 域名长度:10 目标域名:github.com 目标端口:443
2019/04/10 00:15:59 main.go:58: 与客户端握手成功
2019/04/10 00:15:59 main.go:207: 目的地址类型:3 域名长度:15 目标域名:live.github.com 目标端口:443
2019/04/10 00:16:00 main.go:58: 与客户端握手成功
2019/04/10 00:16:00 main.go:207: 目的地址类型:3 域名长度:14 目标域名:api.github.com 目标端口:443
sogo项目最好的两个特性如下:
- 使用http包裹payload(有意义的数据)。
- 将sogo-server所在的ip:端口伪装成一个http网站。
效用、坚固、美观——对软件产品的三个要求。上面两个特性,既可以说是坚固,也可以说是美观,至于效用就不用说了,在这里谈坚固和美观的前提就是效用被完整地实现。用通俗地话来说,这个代理的坚固和美观就是:伪装、防止被识别。
sogo(client)与本地电脑交互,因此需要实现socks5协议,与本地用户(比如chrome)握手协商。
一个典型的sock5握手的顺序:
- client:0x05 0x01 0x00
- server: 0x05 0x00
- client: 0x05 0x01 0x00 0x01 ip1 ip2 ip3 ip4 0x00 0x50
- server: 0x05 0x00 0x00 0x01 0x00 0x00 0x00 0x00 0x10 0x10
- client与server开始盲转发
这一部分代码见如下两个函数:
//file: sogo/main.go
//读 5 1 0 写回 5 0
func handshake(clientCon net.Conn) error {
var buf = make([]byte, 300)
numRead, err := clientCon.Read(buf)
if err != nil {
return err
} else if numRead == 3 && buf[0] == 0X05 && buf[1] == 0X01 && buf[2] == 0X00 {
return mio.WriteAll(clientCon, []byte{0x05, 0x00})
} else {
log.Printf("%d", buf[:numRead])
return mio.WriteAll(clientCon, []byte{0x05, 0x00})
}
}
func getTargetAddr(clientCon net.Conn) (string, error) {
var buf = make([]byte, 1024)
numRead, err := clientCon.Read(buf)
if err != nil {
return "", err
} else if numRead > 3 && buf[0] == 0X05 && buf[1] == 0X01 && buf[2] == 0X00 {
if buf[3] == 3 {
log.Printf("目的地址类型:%d 域名长度:%d 目标域名:%s 目标端口:%s", buf[3], buf[4], buf[5:5+buf[4]], strconv.Itoa(int(binary.BigEndian.Uint16(buf[5+buf[4]:7+buf[4]]))))
writeErr := mio.WriteAll(clientCon, []byte{0x05, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x10, 0x10})
return string(buf[5:5+buf[4]]) + ":" + strconv.Itoa(int(binary.BigEndian.Uint16(buf[5+buf[4]:7+buf[4]]))), writeErr
} else if buf[3] == 1 {
log.Printf("目的地址类型:%d 目标域名:%s 目标端口:%s", buf[3], net.IPv4(buf[4], buf[5], buf[6], buf[7]).String(), strconv.Itoa(int(binary.BigEndian.Uint16(buf[8:10]))))
writeErr := mio.WriteAll(clientCon, []byte{0x05, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x10, 0x10})
return net.IPv4(buf[4], buf[5], buf[6], buf[7]).String() + ":" + strconv.Itoa(int(binary.BigEndian.Uint16(buf[8:10]))), writeErr
} else {
return "", errors.New("不能处理ipv6")
}
} else {
return "", errors.New("不能处理非CONNECT请求")
}
}
完成handshake, getTargetAddr 之后,chrome就会发送真实的http请求了,sogo(client) 要做的就是将这部分http请求进行加密,然后加上http请求的头,发送到sogo-server。
第一部分:如何将真实的http请求,再进行加密,最后加上假的http请求头,变成伪装好的http请求,发送给sogo-server。
//file: sogo/mio/prefix.go
var fakeHost = "qtgwuehaoisdhuaishdaisuhdasiuhlassjd.com" //虚假host
func AppendHttpRequestPrefix(buf []byte, addr string) []byte {
Simple(&buf, len(buf))//对真实的http请求的简单加密
// 演示base64编码
addrBase64 := base64.NewEncoding("abcdefghijpqrzABCKLMNOkDEFGHIJl345678mnoPQRSTUVstuvwxyWXYZ0129+/").EncodeToString([]byte(addr))
buf = append([]byte("POST /target?at="+addrBase64+" HTTP/1.1\r\nHost: "+fakeHost+"\r\nAccept: */*\r\nContent-Type: text/plain\r\naccept-encoding: gzip, deflate\r\ncontent-length: "+strconv.Itoa(len(buf))+"\r\n\r\n"), buf...)
return buf
}
包裹完毕之后返回的[]byte就可以发送给sogo-server了。
第二部分:将sogo-server从目标网站获得的真实响应进行简单加密,包裹http响应头,发送给sogo(client)。
//file: sogo-server/mio/prefix.go
func AppendHttpResponsePrefix(buf []byte) []byte {
Simple(&buf, len(buf))
buf = append([]byte("HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: "+strconv.Itoa(len(buf))+"\r\n\r\n"), buf...)
return buf
}
包裹完毕之后返回的[]byte就可以发送给sogo(client)了。
先看以下伪装好的请求的样子:
POST /target?at={targetAddrBase64} HTTP/1.1
Host: {fakehost}
Accept: */*
Content-Type: text/plain
accept-encoding: gzip, deflate
content-length: {content-length}
{payload-after-crypto}
sogo-server拿到这个伪装好的请求,要做的事有:
- 获取{targetAddrBase64},拿 7DDF 到真实的目标网站地址
- 获取请求头的Host字段,如果不是定义好的fakehost,则说明是直接访问sogo-server,这时sogo-server就是个到混淆网站的反向代理(这就是之前提到的第二个特性。下面将会详细解释如何实现
- 获取{content-length},根据这个content-length确定payload部分的长度。
- 读取指定长度的payload,解密,并创建到targetAddr的连接,转发至targetAddr
这些步骤很明确吧。其实有一些细节,挺麻烦的。
tcp是面向流的协议,也就是会有很多个连续的上面的片段,要合理划分出这些片段。有些人称这个为解决“tcp粘包”,谷歌tcp粘包就能搜到如何实现这个需求。但是注意,不要称这个为“tcp粘包”,别人会说tcp是面向流的协议,哪来什么包,你知识体系有问题,你看过tcp协议没有。这些话都是知乎上某一问题的答案说的。所以,别说“tcp粘包”,但是可以用这个关键词去搜索如何解决这个问题。
如果,现在你看了如何解决这个问题,其实就是一句话,在tcp上层定义自己的应用层协议:也就是tcp报文的格式。http这个应用层协议就是一种tcp报文的一种定义。
我们的伪装好的报文就是http协议,所以要做的就是实现自己的http请求解析器,获取我们关心的信息。
sogo的http请求解析器,在:
//file sogo-server/server.go
func read(clientConn net.Conn, redundancy []byte) (payload, redundancyRetain []byte, target string, readErr error)
这一部分有点繁杂。。不多解释,自己看代码吧。
这一部分就是第二特性:将sogo-server所在的ip:端口伪装成一个http网站。
上一节,我们提到 {fakehost}。我们故意将{fakehost}定义为一个复杂、很长的域名。我们伪装的请求,都会带有如下请求头
Host: {fakehost}
如果,http请求的Host不是这个{fakehost}则说明这不是一个经sogo(client)的请求,而是直接请求了sogo-server。也就是,有人来嗅探啦!
对这种,我们就会将该请求,原封不动地转到伪装站。(其实还是有点修改的,但这是细节,看代码吧)所以,直接访问sogo-server-ip:80 就是访问伪装站:80。
wget https://github.com/arloor/sogo/releases/download/v1.0/sogo-server
wget https://github.com/arloor/sogo/releases/download/v1.0/sogo-server.json
chmod +x sogo-server
mv -f sogo-server /usr/local/bin/
mv -f sogo-server.json /usr/local/bin/
kill -9 $(lsof -i:80|tail -1|awk '$1!=""{print $2}') #关闭80端口应用
ulimit -n 65536 #设置进程最多打开文件数量,防止 too many openfiles错误(太多连接
(sogo-server &)
# 国内机器下面两个wget会很慢,考虑本地下载再上传到服务器吧
wget https://github.com/arloor/sogo/releases/download/v1.0/sogo.json
wget https://github.com/arloor/sogo/releases/download/v1.0/sogo
chmod +x sogo
mv -f sogo /usr/local/bin/
mv -f sogo.json /usr/local/bin/
ulimit -n 65536 #设置进程最多打开文件数量,防止 too many openfiles错误(太多连接
# 运行前,先修改/usr/local/bin/sogo.json
(sogo &) #以 /usr/local/bin/sogo.json 为配置文件 该配置下,服务端地址被设置为proxy
#(sogo -c path &) #以path指向的文件为配置文件
到Release下载sogo.exe
和sogo.json
。
sogo.json内容如下:
{
"ClientPort": 8888,
"Use": 0,
"Servers": [
{
"ProxyAddr": "proxy",
"ProxyPort": 80,
"UserName": "a",
"Password": "b"
}
],
"Dev":false
}
先修改ProxyAddr
为服务端安装的地址即可。其他配置项是高级功能,例如多服务器管理,多用户管理(用户认证)等等。
shadowsocks是没有多用户管理的,ss每个端口对应一个用户。sogo则使用用户名+密码认证,使多个用户使用同一个服务器端口。
修改好之后,双击sogo.exe
,这时会发现该目录下多了一个 sogo_8888.log 的文件,这就说明,在本地的8888端口启动好了这个sock5代理。(没有界面哦。
这篇博客梳理了一下sogo的实现原理,总之,sogo是一个优雅的代理。sogo代码不多,对go语言、网络编程感兴趣的人可以看看。