Jumpserver Preauth RCE Exploit Chain

# Background # 背景

JumpServer is a well-known open-source security operations and auditing application [https://www.jumpserver.org/](https://www.jumpserver.org/), widely adopted for its simplicity, ease of use, and open-source security features. Around Sep 2023, we discovered a pre-auth RCE chain in JumpServer.
JumpServer 是一个著名的开源安全操作和审计应用程序 [https://www.jumpserver.org/](https://www.jumpserver.org/),因其简单、易用和开源安全功能而被广泛采用。2023 年 9 月左右,我们在 JumpServer 中发现了一条预授权 RCE 链。

The RCE chain combines two vulnerabilities:
RCE 链结合了两个漏洞:

 CVE-2023-42820: Vulnerability allowing the prediction of reset password verification codes in JumpServer.
– CVE-2023-42820:允许在 JumpServer 中预测重置密码验证码的漏洞。

 CVE-2023-42819: Vulnerability enabling authenticated users to perform arbitrary file read/write operations across directories in JumpServer.
– CVE-2023-42819:漏洞允许经过身份验证的用户在 JumpServer 中跨目录执行任意文件读/写操作。


Another related vulnerability is CVE-2023-46138:
另一个相关漏洞是 CVE-2023-46138:

 CVE-2023-46138: Unregistered Default Built-in Email Domain for Administrator Account Leads to Password Reset and Account Takeover.
– CVE-2023-46138:管理员帐户未注册的默认内置电子邮件域可导致密码重置和帐户接管。


# CVE-2023-42820

The flaw is beacuse of the leakage of the random number generator seed, leading to the predictability of reset password verification codes and subsequently allowing the takeover of any user account.
该漏洞是由于随机数生成器种子的泄漏,导致重置密码验证码的可预测性,并随后允许接管任何用户帐户。


## Insecure Random Number Generation
## 不安全的随机数生成

After downloading the source code [https://github.com/jumpserver/jumpserver](https://github.com/jumpserver/jumpserver), We followed the usual practice of scrutinizing the code logic related to authentication. After spending some time reading the code related to password reset functionality, We discovered that JumpServer’s password reset logic is quite common. The key steps are as follows:
下载源代码 [https://github.com/jumpserver/jumpserver](https://github.com/jumpserver/jumpserver) 后,我们遵循了通常的做法,仔细检查与身份验证相关的代码逻辑。在花了一些时间阅读与密码重置功能相关的代码后,我们发现 JumpServer 的密码重置逻辑非常普遍。关键步骤如下:

 1. The client requests the `/core/auth/password/forget/previewing/` endpoint with the submitted username parameter. The server queries the database to confirm the existence of the user and caches the user information.
– 1.客户端使用提交的用户名参数请求“/core/auth/password/forget/previewing/”端点。服务器查询数据库以确认用户的存在并缓存用户信息。

 2. The client submits an email or phone number to the `/api/v1/authentication/password/reset-code/` endpoint. After validating the legitimacy of the email or phone number, the server generates and sends a valid verification code to the email or phone.
– 2.客户端将电子邮件或电话号码提交到“/api/v1/authentication/password/reset-code/”端点。在验证电子邮件或电话号码的合法性后,服务器会生成有效的验证码并将其发送到电子邮件或手机。

 3. The client submits the verification code to the `/api/v1/authentication/password/forgot/` endpoint. The server verifies whether the cached information and the corresponding verification code match. If successful, it redirects the client to the password reset link.
– 3.客户端将验证码提交到“/api/v1/authentication/password/forgot/”端点。服务器验证缓存的信息是否与相应的验证码匹配。如果成功,它会将客户端重定向到密码重置链接。

 4. The client submits the new password to the `/api/v1/authentication/password/reset/` endpoint, and the server completes the password reset.
– 4.客户端将新密码提交到“/api/v1/authentication/password/reset/”端点,服务器完成密码重置。


Everything seems right, except for the verification code generation logic:
一切似乎都是正确的,除了验证码生成逻辑:


“`python ”’蟒蛇

def create(self, request, *args, **kwargs):
def create(self, request, *args, **kwargs):

    

    random_string(6, lower=False, upper=False) # [1]
random_string(6, lower=False, upper=False) # [1]

    


def random_string(length: int, lower=True, upper=True, digit=True, special_char=False):
def random_string(length:int, lower=True, upper=True, digit=True, special_char=False):

    args_names = [‘lower‘, upper‘, digit‘, special_char‘]
args_names = [‘下’, ‘上’, ‘数字’, ‘special_char’]

    args_values = [lower, upper, digit, special_char]
args_values = [下限、上限、数字、special_char]

    args_string = [string.ascii_lowercase, string.ascii_uppercase, string.digits, string_punctuation]
args_string = [string.ascii_lowercase, string.ascii_uppercase, string.digits, string_punctuation]

    args_string_map = dict(zip(args_names, args_string))
args_string_map = dict(zip(args_names, args_string))

    kwargs = dict(zip(args_names, args_values))
kwargs = dict(zip(args_names, args_values))

    kwargs_keys = list(kwargs.keys())
kwargs_keys = 列表(kwargs.keys())

    kwargs_values = list(kwargs.values())
kwargs_values = 列表(kwargs.values())

    args_true_count = len([for i in kwargs_values if i])
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 any(kwargs_values), f’Parameters {kwargs_keys} 必须至少有一个 ‘True”

    assert length >= args_true_count, f‘Expected length >= {args_true_count}, bug got {length}
断言长度 >= args_true_count, f’预期长度 >= {args_true_count}, bug got {length}’


    can_startswith_special_char = args_true_count == 1 and special_char
can_startswith_special_char = args_true_count == 1 和 special_char


    chars = ”.join([args_string_map[k] for k, v in kwargs.items() if v])
chars = ”.join([args_string_map[k] for k, v in kwargs.items() if v])


    while True: 而 True:

        password = list(random.choice(chars) for i in range(length))  # [2]
password = list(random.choice(chars) for i in range(length)) # [2]

        for k, v in kwargs.items():
对于 k, v 在 kwargs.items():

            if v and not (set(password) & set(args_string_map[k])):
if v and not (set(password) & set(args_string_map[k])):

                break 

        else: 还:

            if not can_startswith_special_char and password[0] in args_string_map[‘special_char‘]:
如果不是 args_string_map[‘special_char’] 中的 can_startswith_special_char 和 password[0]:

                continue 继续

            else: 还:

                break 


    password = ”.join(password)
password = ”.join(密码)

    return password 返回密码

“`


From the function call at [1], it can be confirmed that the verification code is a 6-digit numeric string. The logic for generating the verification code is based on Python’s `random` library, and the critical code snippet is:
从 [1] 处的函数调用中,可以确认验证码是 6 位数字字符串。生成验证码的逻辑基于 Python 的“随机”库,关键代码片段为:


“`python ”’蟒蛇

password = list(random.choice(chars) for i in range(length))  # [2]
password = list(random.choice(chars) for i in range(length)) # [2]

“`


The security risk here lies in the use of Python’s `random` library to generate random numbers (verification codes). However, the random numbers generated by the `random` library are not cryptographically secure, and the Python official documentation explicitly warns against using them for security or cryptographic purposes: [https://docs.python.org/3/library/random.html](https://docs.python.org/3/library/random.html)
这里的安全风险在于使用 Python 的“随机”库来生成随机数(验证码)。但是,“随机”库生成的随机数在加密上并不安全,Python 官方文档明确警告不要将它们用于安全或加密目的:[https://docs.python.org/3/library/random.html](https://docs.python.org/3/library/random.html)


> Warning >警告

> The pseudo-random generators of this module should not be used for security purposes. For security or cryptographic uses, see the `secrets` module.
> 出于安全目的,本模块的伪随机生成器不应用于此目的。有关安全或加密用途,请参阅“secrets”模块。


The `random` library uses the Mersenne Twister algorithm to produce pseudorandom numbers, and one of its characteristics is that providing the same random seed will generate the exact same sequence of random numbers. To illustrate:
“随机”库使用 Mersenne Twister 算法来生成伪随机数,其特征之一是提供相同的随机种子将生成完全相同的随机数序列。举例说明:


“`python ”’蟒蛇

>>> random.seed(0x2BBD9883B80)  # Set seed: 0x2BBD9883B80
>>> random.seed(0x2BBD9883B80) # 设置种子:0x2BBD9883B80

>>> [random.choice(range(10)) for i in range(10)]
>>> [random.choice(range(10)) for i in range(10)]

[6, 0, 4, 5, 9, 6, 8, 4, 6, 2]  # Generate a sequence of 10 random numbers
[6, 0, 4, 5, 9, 6, 8, 4, 6, 2] # 生成 10 个随机数的序列

>>> [random.choice(range(10)) for i in range(10)]
>>> [random.choice(range(10)) for i in range(10)]

[3, 6, 9, 1, 4, 0, 0, 3, 8, 0]  # Continue generating another sequence of 10 random numbers
[3, 6, 9, 1, 4, 0, 0, 3, 8, 0] # 继续生成另一个 10 个随机数序列

>>> random.seed(0x2BBD9883B80)  # Resetting the same seed: 0x2BBD9883B80
>>> random.seed(0x2BBD9883B80) # 重置相同的种子: 0x2BBD9883B80

>>> [random.choice(range(10)) for i in range(10)]
>>> [random.choice(range(10)) for i in range(10)]

[6, 0, 4, 5, 9, 6, 8, 4, 6, 2]  # As can be seen, it generates the same sequence as the first one
[6, 0, 4, 5, 9, 6, 8, 4, 6, 2] # 可以看出,它生成的序列与第一个序列相同

“`


In other words, if we can determine the seed used by the `random` library, we can predict the subsequent random numbers generated.
换句话说,如果我们能够确定“随机”库使用的种子,我们就可以预测随后生成的随机数。


While this might not be a secure coding practice, in many cases, such issues might not directly lead to severe consequences because, in general, we don’t know the seed parameter. Just when We were about to give up on auditing the code related to these features, We noticed that the seemingly ordinary graphic captcha feature also had a significant problem. When combined with the captcha issue discussed here, it led to the emergence of the vulnerability.
虽然这可能不是一种安全的编码实践,但在许多情况下,此类问题可能不会直接导致严重后果,因为一般来说,我们不知道种子参数。就在我们准备放弃审核与这些功能相关的代码时,我们注意到看似普通的图形验证码功能也存在一个重大问题。当与这里讨论的验证码问题相结合时,它导致了漏洞的出现。


## Random Seed Leakage ## 随机种子泄漏

Like most programs with user password login and password reset logic, developers set up a graphic captcha before submitting parameters to prevent simple programmatic brute-force attacks. JumpServer, developed based on the Django web framework, implements the captcha feature by introducing the `django-simple-captcha` library (https://github.com/mbi/django-simple-captcha) and registering it’s view. The logic for captcha code generation and validation in the `django-simple-captcha` library is as follows:
与大多数具有用户密码登录和密码重置逻辑的程序一样,开发人员在提交参数之前设置图形验证码,以防止简单的编程暴力攻击。JumpServer 基于 Django Web 框架开发,通过引入 ‘django-simple-captcha’ 库 (https://github.com/mbi/django-simple-captcha) 并注册其视图来实现验证码功能。在 ‘django-simple-captcha’ 库中生成和验证验证码的逻辑如下:


 1. The client requests the `/refresh` endpoint. The server generates the answer to the graphic captcha and stores it in the database, returning the key containing a 32-byte hexadecimal string hex_key.
– 1.客户端请求“/refresh”终结点。服务器生成图形验证码的答案并将其存储在数据库中,返回包含 32 字节十六进制字符串hex_key的密钥。

 2. The client, with hex_key, requests the `/image/{hex_key}` endpoint. The server, based on hex_key, retrieves the captcha answer from the database, generates an image with rotation and random noise to increase the difficulty of machine image recognition, and returns it to the client.
– 2.具有 hex_key 的客户端请求“/image/{hex_key}”端点。服务器基于hex_key,从数据库中检索验证码答案,生成具有旋转和随机噪声的图像,以增加机器图像识别的难度,并将其返回给客户端。

 3. The client submits hex_key and the captcha answer as parameters to the `/previewing` endpoint. The server checks if the graphic captcha matches the database, and if it does, continues with the remaining logic of the `/previewing` endpoint.
– 3.客户端将hex_key和验证码答案作为参数提交到“/previewing”终结点。服务器检查图形验证码是否与数据库匹配,如果匹配,则继续执行“/previewing”端点的其余逻辑。


In step 2, after generating the image, to increase the difficulty of machine image recognition, the hex_key is set as the random library’s random seed. The critical code snippet is:
在步骤2中,在生成图像后,为了增加机器图像识别的难度,将hex_key设置为随机库的随机种子。关键代码片段是:


“`python ”’蟒蛇

def captcha_image(request, key, scale=1):
def captcha_image(request, key, scale=1):

    if scale == 2 and not settings.CAPTCHA_2X_IMAGE:
如果 scale == 2 而不是设置。CAPTCHA_2X_IMAGE:

        raise Http404 提高 Http404

    try: 尝试:

        store = CaptchaStore.objects.get(hashkey=key)
store = CaptchaStore.objects.get(hashkey=key)

    except CaptchaStore.DoesNotExist:
除了 CaptchaStore.DoesNotExist:

        # HTTP 410 Gone status so that crawlers don’t index these expired urls.
# HTTP 410 消失状态,以便爬虫不会索引这些过期的 URL。

        return HttpResponse(status=410)
返回 HttpResponse(status=410)


    random.seed(key)  # Do not generate different images for the same key [1]
random.seed(key) # 不要为同一个键生成不同的图像 [1]

“`


In the insecure random number logic issue mentioned earlier, we pointed out that if we know the seed of the random library, we can predict the subsequent random number sequence. Note the code snippet [1] here; the `key` parameter is the hex_key accessible to the client.
在前面提到的不安全随机数逻辑问题中,我们指出,如果我们知道随机库的种子,我们就可以预测后续的随机数序列。请注意此处的代码片段 [1];“key”参数是客户端可访问的hex_key。


## Exploitation ## 利用

Combining the two issues mentioned above, we can achieve the prediction of the captcha code, leading to arbitrary account password resets, including administrator accounts. The exploitation process is outlined as follows:
结合上面提到的两个问题,我们可以实现对验证码的预测,导致任意的账户密码重置,包括管理员账户。开发过程概述如下:


 0. Request the `/refresh` endpoint to obtain hex_key, the random number seed to be used later.
– 0.请求“/refresh”端点以获取hex_key,即稍后要使用的随机数种子。

 1. Launch multiple threads, repeatedly call the `/image/{hex_key}` endpoint with hex_key as the request parameter, continuously resetting the random number seed of the current process to hex_key.
– 1.启动多个线程,以 hex_key 作为请求参数反复调用 ‘/image/{hex_key}’ 端点,不断将当前进程的随机数种子重置为 hex_key。

 2. The main thread sets hex_key as the seed for the random library, generates a random sequence of a certain length (`rand_str`) based on the server’s random_string function logic.
– 2.主线程将 hex_key 设置为随机库的种子,根据服务器的random_string函数逻辑生成一定长度 (’rand_str’) 的随机序列。

 3. The main thread requests the `/api/v1/authentication/password/reset-code/` endpoint, triggering the verification code generation logic.
– 3.主线程请求 ‘/api/v1/authentication/password/reset-code/’ 端点,触发验证码生成逻辑。

 4. The main thread iteratively extracts a 6-byte substring from rand_str (e.g., rand_str[i:i+6]) as the verification code, submits it to the `/api/v1/authentication/password/forgot/` endpoint, and checks for a successful redirection in the Location header to confirm whether the brute force attempt is successful.
– 4.主线程迭代地从rand_str(例如,rand_str[i:i+6])中提取一个 6 字节的子字符串作为验证码,将其提交到“/api/v1/authentication/password/forgot/”端点,并在 Location 标头中检查重定向是否成功,以确认暴力尝试是否成功。


As JumpServer uses Gunicorn to start the Python web application, and Gunicorn employs the pre-fork worker model, where requests are distributed among worker processes (https://docs.gunicorn.org/en/stable/design.html), the goal is to contaminate the random number seed of all processes as much as possible in steps 0 and 1 to increase the success rate in step 4. In actual vulnerability exploitation tests, it was observed that `rand_str` needed to be skipped by at least 980 bytes before performing the substring extraction operation to maximize success within 100 brute force attempts. The reason is that between setting the random number seed in step 1 and triggering the captcha code generation in step 3, there is some other code logic that uses the random library to generate a sequence of random numbers, requiring this length to be skipped.
由于 JumpServer 使用 Gunicorn 来启动 Python Web 应用程序,而 Gunicorn 采用 pre-fork worker 模型,其中请求分布在工作进程 (https://docs.gunicorn.org/en/stable/design.html) 之间,目标是在步骤 0 和 1 中尽可能多地污染所有进程的随机数种子,以提高步骤 4 中的成功率。在实际的漏洞利用测试中,观察到在执行子字符串提取操作之前,“rand_str”需要至少跳过 980 个字节,以在 100 次暴力尝试中最大限度地提高成功率。原因是在步骤 1 中设置随机数种子和在第 3 步中触发验证码生成之间,有一些其他代码逻辑使用随机库生成随机数序列,需要跳过此长度。


# Postauth RCE: CVE-2023-42819
# Postauth RCE:CVE-2023-42819

JumpServer supports setting up playbook scripts to automate a series of operations on a large number of machines. While this is a useful business feature in real-world scenarios, there is a vulnerability in the related API logic that allows directory traversal, enabling file creation, writing, modification, deletion, and other operations in any directory. Here is a snippet of the vulnerable code:
JumpServer 支持设置 playbook 脚本,以在大量机器上自动执行一系列操作。虽然这在实际场景中是一项有用的业务功能,但相关 API 逻辑中存在一个漏洞,该漏洞允许目录遍历,从而在任何目录中启用文件创建、写入、修改、删除和其他操作。以下是易受攻击代码的片段:


“`python ”’蟒蛇

def post(self, request, **kwargs):
def post(self, request, **kwargs):

    content = request.data.get(‘content‘, ”)
内容 = request.data.get(’内容’, ”)

    name = request.data.get(‘name‘, ”) # [1]
name = request.data.get(’name’, ”) # [1]


    def find_new_name(p, is_file=False):
def find_new_name(p, is_file=False):

        if not p: 如果不是 p:

            if is_file: 如果is_file:

                p = new_file.yml

            else: 还:

                p = new_dir

        np = os.path.join(full_path, p)  # [2]
np = os.path.join(full_path, p) # [2]

        n = 0

        while os.path.exists(np):
而 os.path.exists(np):

            n += 1

            np = os.path.join(full_path, {}({})‘.format(p, n))
np = os.path.join(full_path, ‘{}({})’.format(p, n))

        return np 返回 NP


    if is_directory: 如果is_directory:

        new_file_path = find_new_name(name)
new_file_path = find_new_name(名称)

        os.makedirs(new_file_path)
os.makedirs(new_file_path)

    else: 还:

        new_file_path = find_new_name(name, True)
new_file_path = find_new_name(name, True)

        with open(new_file_path, w‘) as f:
将 open(new_file_path, ‘w’) 设置为 f:

            f.write(content) f.write(内容)

“`


A straightforward path concatenation leads to directory traversal, as the parameter `name` from the request at [1] is directly concatenated at [2]. Therefore, the `name` parameter can be something like `../../test.py` or `/tmp/test.py`. Absolute paths can also be achieved because Python’s `os.path.join` function, when encountering absolute paths in subsequent parameters, ignores the previous concatenated content and directly adopts the absolute path.
直接的路径连接会导致目录遍历,因为来自 [1] 请求的参数 ‘name’ 直接连接在 [2] 处。因此,“name”参数可以类似于“../../test.py’ 或 ‘/tmp/test.py’。也可以实现绝对路径,因为 Python 的 os.path.join 函数在后续参数中遇到绝对路径时,会忽略之前的串联内容,直接采用绝对路径。


“`python ”’蟒蛇

>>> import os >>>导入操作系统

>>> os.path.join(“/etc/a/b“, /tmp/test.py“)
>>> os.path.join(“/etc/a/b”, “/tmp/test.py”)

/tmp/test.py

“`


Remote code execution can be achieved by writing to certain dynamically loaded Python files, in conjunction with the previously discussed CVE-2023-42820, forming a complete pre-auth RCE attack chain.
远程代码执行可以通过写入某些动态加载的 Python 文件来实现,并结合前面讨论的 CVE-2023-42820,形成完整的预身份验证 RCE 攻击链。



# CVE-2023-46138 # CVE-2023-46138 漏洞

During our security assessment of the password reset functionality, we discovered that the default administrator account `admin` is associated with a default email `[email protected]`. This maybe a attacker controllable domain, if a malicious attacker buy this domain, and then he can reset every default `admin` password in Jumpserver Instance. But because we have the CVE-2023-42820, so we don’t need to buy the domain to reset the admin password.
在对密码重置功能进行安全评估期间,我们发现默认管理员帐户“admin”与默认电子邮件“[email protected]”相关联。这可能是一个攻击者可控制的域,如果恶意攻击者购买了这个域,然后他可以重置 Jumpserver 实例中的每个默认“管理员”密码。但是因为我们有 CVE-2023-42820,所以我们不需要购买域名来重置管理员密码。



# More about `django-simple-captcha`
# 更多关于 ‘django-simple-captcha’ 的信息

Because the ‘django-simple-captcha’ library leaks the random number seed from the ‘random’ library and allows adjustable settings, it poses a security risk to any project using ‘django-simple-captcha’. For exmaple, `treeio`‘s (https://github.com/treeio/treeio) password reset functionality exhibits almost identical vulnerabilities, being similarly affected as CVE-2023-42820.
因为 ‘django-simple-captcha’ 库从 ‘random’ 库中泄漏了随机数种子并允许可调整的设置,它给任何使用 ‘django-simple-captcha’ 的项目带来了安全风险。例如,treeio(https://github.com/treeio/treeio)的密码重置功能表现出几乎相同的漏洞,受到与 CVE-2023-42820 类似的影响。


Take away: This revelation serves as a reminder that the introduction of uncontrollable code may amplify and escalate inherent security risks within an application. During the code audit process, it is crucial not to assume the security of any code, especially third-party code libraries.
要点:这一启示提醒我们,引入不可控的代码可能会放大和升级应用程序中的固有安全风险。在代码审计过程中,至关重要的是不要假设任何代码的安全性,尤其是第三方代码库。


# RCE demo Video # RCE演示视频

原文始发于Lawliet & Zhiniang Peng (@edwardzpeng):Jumpserver Preauth RCE Exploit Chain

版权声明:admin 发表于 2024年1月31日 下午6:17。
转载请注明:Jumpserver Preauth RCE Exploit Chain | CTF导航

相关文章