JumpServer中的Random随机种子问题

JumpServer是一个开源堡垒机,去年爆出了一个很精彩的漏洞CVE-2023-42820,里面涉及到很多知识点和巧妙的思路。

CVE-2023-42820

先看一下漏洞描述,意思是随机种子暴露可能造成密码重置。

This vulnerability is due to exposing the random number seed to the API, potentially allowing the randomly generated verification codes to be replayed, which could lead to password resets.

这个漏洞要求用户没有开启MFA(Multi-Factor Authentication, MFA多因素身份验证)。因为一旦开启MFA,重置了密码,也无法登录。

官方修复如下:
https://github.com/jumpserver/jumpserver/commit/ce645b1710c5821119f313e1b3d801470565aac0

官方修复

修复的变化是将token的生成用random_string方法生成。而该方法中加入代码random.seed(None)

伪随机数

random模块是用来生成伪随机数的,可以理解为伪随机数生成器(pseudo random number generator,PRNG)。根据维基百科的意思,这种实际是一个生成数字序列的算法,虽然特性接近于随机数序列,但是并不是真随机。它的序列由一个初始值决定,这个初始值又叫随机种子(seed)。

这里不禁困惑,什么场景要用伪随机数而不是随机数。比如游戏,很多游戏关卡看似是随机生成的,但这种生成又不能任由它完全随机,此时就需要用到伪随机数。

随机种子

随机种子的特点是,使用相同的种子会生成相同的随机数序列。那么Jumpserver修复用的None随机种子又是什么作用?搜索python官方的说明:https://docs.python.org/3/library/random.html

python seed说明

意思就是当random不设置seed值时,默认就是None。意味着会使用当前系统的时间作为seed(类似os.urandom())。如果当前系统提供了randomness srouces,就用其生成种子。

选取一个固定数字的seed和None作为seed,分别跑两次,对比一下结果。seed是固定数字时,结果是一样的。seed是None时,更像是随机数。

Random seed运行结果对比

random.random()本质 生成的是伪随机数。要生成真正的随机数,通常需要依赖于一些外部的随机性来源,比如硬件随机数生成器、环境噪声、放射性衰变等。想要更接近随机数,需要借助操作系统的资源,例如在Unix-like系统(包括Linux和macOS)中,可以从 /dev/random/dev/urandom中读取随机字节,这些字节基于系统的环境噪声。

修复分析

想要用python生成随机数,而不是伪随机数,约有以下三种方式

# 1. 使用os.urandom
import os
import binscii

random_bytes = os.urandom(16)
print(binascii.hexlify(random_bytes))

# 2. 使用secrets(python3.6及以上版本)
import secrets

random_bytes = secrets.token_bytes(16)
print(binascii.hexlify(random_bytes))

# 3. 使用系统API ,如/dev/random
def get_true_random_bytes(n):
    with open('/dev/random', 'rb') as f:
        return f.read(n)

random_bytes = get_true_random_bytes(16)
print(binascii.hexlify(random_bytes))

那么回过头来看这种修复方式,实际上是将伪随机数改为随机数。那么也就意味着伪随机数可能被推导出来。

环境分析

搭建环境。用账户密码admin/admin登录后,会自动转到更改密码的界面,对应的url如下。更改密码后就可以进入到系统首页。

http://ip:port/core/auth/password/reset/?token=ZuI7qWxb73wOZkCyY3tP7UlH6Dd51KdJS2xigu9jbQOkGuTNFL

漏洞描述中问题定位在密码重置,那么找到密码重置界面。如下。密码重置分两步。第一步,输入要找回的用户名和图形验证码。第二步,选择用邮箱和短信找回。输入邮箱账号点击发送,会收到一个验证码code,如果验证码正确就可以进入到修改密码的界面。但是admin默认的邮箱是admin@mycomany.com,所以实际的验证码我们无法获取。

忘记密码找回界面

找回密码

从页面的js代码中可以看到点击“发送”实际访问的url是const url = "/api/v1/authentication/password/reset-code/" + "?token=" + token;

环境的功能分析是比较直观的,从漏洞描述来看,大致的可能就是这个验证码code是可以推测出来的,然后就可以修改任意用户的密码了。

而随机种子生成的是token。从页面js来看,有两个地方用到了token(1)密码重置第一步中的图形验证码,其src的路径中包含了一个token (2)密码重置第二步的url中带了一个token (3)密码重置第二步的发送按钮,想/reset-code/这个路径传参时带了一个token。这个token是getQueryString获取的,那么很可能就是(2)的这个token。接下来就需要找到这些token和验证码code的关系是什么。

源代码分析

看一下点击发送后,code生成的过程。根据发送访问的url,从路由文件apps/authentication/urls/api_urls.py中查找这个password/reset-code/路由对应的类UserResetPasswordSendCodeApi

/password/reset-code/路由查找

该类中只有一个方法,create。猜测应该是生成code的方法。
UserResetPasswordSendCodeApi#create

代码的逻辑是从请求中获取token的值,然后根据token获取username。根据表格的form_type判断是用短信还是邮箱找回。找到其中关于code的部分,用random_string生成的。而这里的random_string正是官方修复加入random.seed(None)的地方。但前面也提到当seed不设置值时,就采用默认值None。只有当random.seed能被我们设置成一个固定值时,这里才能控制生成的code。

如果在这一步之前可以设置一个固定key或者得到已设置的固定key。那么这一步就有可能跑出同样的数值。那么往前回溯生成token的地方,有没有能设置或得到key的。

密码重置的路由可以定位到apps/users/views/profile/reset.py文件。
密码重置两步的路由

上面两步的代码分别对应reset.py中的两个类。

密码重置两步的源码

可以看第一步输完图形验证码后,会同样用random_string方法生成一个token,并带入跳转的url中。同时,会在缓存cache中放置token和用户信息(用户名、手机、邮箱),过期时间五分钟。但这些同样不涉及到固定的种子。那么就只剩下图形验证码的token生成过程。

图形验证码src=/core/auth/captcha/image/xxx。搜索这个路径。

/core/auth/captcha/

对应的是captcha.urls,但是进一步搜索这个字段缺搜不到东西。全局搜索captcha,发现lib中用到的是django-simple-captcha。查找该模块的源码:https://github.com/mbi/django-simple-captcha/tree/master

找到/image路由,该路由后的值为key的参数值,对应的处理视图为captcha_image
/image

定位到captcha_image视图,方法中将/image/xx路由后的参数值作为key当作固定的种子。
captcha_image

也就是说如果我们拿图形验证码key的值(这个在图形验证码的src中),当作随机种子,去跑code,由于伪随机数载seed一定时,值不变。可能将code匹配到。但是不禁困惑,这个随机种子在第三方组件中定义的,但是为什么在jumpserver中也能生效。

经过查询,发现随机种子在整个 Python 进程的范围内有效。这意味着一旦在代码中调用 random.seed,所有依赖于随机数生成器的函数(例如 random.random()、random.randint()、random.choice() 等)都会从这个设定的种子开始生成伪随机数序列。

整理一下上面的逻辑,在图形验证码阶段,获取图片src后的key。作为随机种子。然后拿这个种子去跑code。如果和真实的code相同,就可以进入到下一步更改密码的步骤。

code = random_string(6, lower=False, upper=False)

很容易得出如下的攻击代码
攻击代码1

但是这样生成的code,并不能通过校验。那么问题出在哪儿呢?首先需要怀疑随机种子是否有效。如果仔细回味随机种子在整个 Python 进程的范围内有效这句话,会有个疑问,如果JumpServer是多进程的,随机种子是否能对应到当前的进程中。

python多进程有很多写法

# 1. multiprocessing
from multiprocessing import Process

def worker():
    ...

if __name__ == "__main__":
    processes = []
    for _ in range(4):  # 创建4个进程
        p = Process(target=worker)
        p.start()
        processes.append(p)

    for p in processes:
        p.join()

# 2. Gunicorn
gunicorn --workers=4 myapp:app

# 3. subprocess (每run一个外部程序都是一个独立的进程)
import subprocess

subprocess.run(["ls", "-l"])

# 4. Docker 和 Kubernetes 每个容器包含一个进程

查看进程,会发现存在很多Gunicorn相关的进程。Gunicorn是一款适用于 UNIX 的 Python WSGI HTTP 服务器,用于运行python Web。所以可以确定,运行环境中存在多个进程,而我们设定的seed只对当前进程有效。

ps -efww

在典型的HTTP服务器中(如Gunicorn),每个请求通常会由不同的进程或线程来处理。所以如果我们不停地访问图形校验码链接,就可能让每个进程都拥有了同一个seed。然后就给上面的攻击代码加上了collision_seed()来不停地访问图形验证码,让每个进程的key都成为固定的seed。

攻击代码2

但是生成的code,还是错的。百思不得其解。回看文章开头的Random seed的运行对比会发现一个问题。其实运行的三个值并不相同。只有从头重新开始运行一次,才能再次得到0.13436424411240122

random.random() for _ in range(3)

[0.13436424411240122, 0.8474337369372327, 0.763774618976614]

所以对于用户密码重置的code,每运行一次random.choice结果都会改变。如果运行的次数没有对上程序本来运行的次数,那么code的值也是错误的。

简单来说,就是程序从random.seed(x)设定开始,到生成密码重置的code,这其中运行过多少次random,我们就要运行多少次。

往前回想,random.seed是在django-simple-captcha中设定的,查看设定seed的captcha_image方法,有两个打断点地方调用了random。
captcha_image方法中的

captcha_image用于验证码图片的生成,主要逻辑是根据给定的 key 从数据库中获取对应的 CAPTCHA 文本,并生成一个包含该文本的图像。设置字体、图像大小等参数,接着绘制charlist中的每个字符。然后rotate旋转字符图像,settings.CAPTCHA_LETTER_ROTATION设置了字符旋转角度的范围。对图像进行剪裁、遮罩、合并、中心对齐,再添加噪音和滤镜效果。

(1)rotate
在旋转字符时会在settings.CAPTCHA_LETTER_ROTATION的角度范围内随机生成一个角度。这个在系统配置captcha/conf/settings.py中设定为(-35,35)
CAPTCHA_LETTER_ROTATION
另外,在for循环中,每循环一次都会执行一遍random.randrange()。那么问题在于,会执行多少次循环?

循环的长度取决于charlist中的字符个数。而charlist是从验证码文本text = store.challenge中读取的。比如我们在图片中看到的6-1=就是change文本。django-simple-captcha库使用时会在配置文件中设置change的长度CAPTCHA_LENGTH,查找该字段,如下。长度为4。

CAPTCHA_LENGTH

所以我们在模拟第一个用到的random时,执行四次循环,每次随机的旋转角度范围是(-35, 35)

for i in range(4):
    random.randrange(-35, 35)

(2)noise
所谓的噪音包括随机线条、点、颜色变化、背景纹理等。这些干扰使得计算机视觉算法更难以提取出有效的信息。
noise_functions

CAPTCHA_NOISE_FUNCTIONS是一个配置变量,通常在配置文件中定义,map指定了噪音函数的列表。每个字符串表示一个函数的路径。其中noise_dots方法中用到了random。
noise_dots

random中的参数值,取决于image的size。而上面的代码会判断是否设定了settings.CAPTCHA_IMAGE_SIZE,在jumpserver中查找CAPTCHA_IMAGE_SIZE,配置于libs.py中。

CAPTCHA_IMAGE_SIZE

那么这步的random模拟脚本如下

for p in range(int(180 * 38 * 0.1)):
    random.randint(0, 180)
    random.randint(0, 38)

完整的poc可以看大佬们已经写好的https://github.com/vulhub/vulhub/blob/master/jumpserver/CVE-2023-42820/poc.py

值得一提的是,后来django-simple-captcha组件也做了相应的修复:https://github.com/mbi/django-simple-captcha/blob/master/CHANGES

SECURITY ISSUE: reset the random seed after an image was generate

图片生成后,随机种子也会被重置。

JumpServer还有几个漏洞与密码重置的功能有关,顺便在这篇一起说了。CVE-2023-43650。向邮箱发送的code是六位的数字,一分钟内有效。但是并没有对速率做限制。所以可以通过爆破的方式,在一分钟内进行1,000,000 次验证尝试,存在爆破code的可能。

官方修复:https://github.com/jumpserver/jumpserver/commit/9520a23f4c3c3add7c2e73af11c81f0b6689a471

官方在验证码校验的时候加入了次数限制,一旦超过三次,就会让code过期。
CVE-2023-43650修复

对此PHITHON提出了一个有意思的问题。前面提到token放入缓存有五分钟的有效期。也就是说这个邮箱找回的界面,在五分钟之内我们可以随意访问。虽然官方这种修复方式限制了code验证的次数。但是我们可以在五分钟里不断生成新的code,然后我们设定一个固定值如123456,一旦这五分钟生成的新code和这个值相同,也可以修改密码。

这个思路很巧妙,正向爆破的思路是code不变,我们不断暴力生成验证码去碰撞出真正的code。官方修复时限制了验证码的暴力生成。我们可以反向让验证码不变,暴力生成code来匹配。

后来官方修复,code失败也会让token失效。

  • 13
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值