Jumpserver Preauth RCE Exploit Chain

# Background

JumpServer is a well-known open-source Privileged Access Management (PAM) system [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.

The RCE chain combines two vulnerabilities:

- CVE-2023-42820: Vulnerability allowing the prediction of reset password verification codes in JumpServer.

- CVE-2023-42819: Vulnerability enabling authenticated users to perform arbitrary file read/write operations across directories in JumpServer.


Another related vulnerability is CVE-2023-46138:

- CVE-2023-46138: Unregistered Default Built-in Email Domain for Administrator Account Leads to Password Reset and Account Takeover.


# 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:

- 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.

- 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.

- 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.

- 4. The client submits the new password to the `/api/v1/authentication/password/reset/` endpoint, and the server completes the password reset.


Everything seems right, except for the verification code generation logic:


```python

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

    ...

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

    ...


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))  # [2]

        for k, v in kwargs.items():

            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']:

                continue

            else:

                break


    password = ''.join(password)

    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:


```python

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)


> Warning

> The pseudo-random generators of this module should not be used for security purposes. For security or cryptographic uses, see the `secrets` module.


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:


```python

>>> random.seed(0x2BBD9883B80)  # Set seed: 0x2BBD9883B80

>>> [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

>>> [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

>>> random.seed(0x2BBD9883B80)  # Resetting the same seed: 0x2BBD9883B80

>>> [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

```


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:


- 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.

- 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.

- 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.


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:


```python

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

    if scale == 2 and not settings.CAPTCHA_2X_IMAGE:

        raise Http404

    try:

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

    except CaptchaStore.DoesNotExist:

        # HTTP 410 Gone status so that crawlers don't index these expired urls.

        return HttpResponse(status=410)


    random.seed(key)  # Do not generate different images for the same 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.


## 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.

- 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.

- 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.

- 3. The main thread requests the `/api/v1/authentication/password/reset-code/` endpoint, triggering the verification code generation logic.

- 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.


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.


# 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:


```python

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

    content = request.data.get('content', '')

    name = request.data.get('name', '') # [1]


    def find_new_name(p, is_file=False):

        if not p:

            if is_file:

                p = 'new_file.yml'

            else:

                p = 'new_dir'

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

        n = 0

        while os.path.exists(np):

            n += 1

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

        return np


    if is_directory:

        new_file_path = find_new_name(name)

        os.makedirs(new_file_path)

    else:

        new_file_path = find_new_name(name, True)

        with open(new_file_path, 'w') as f:

            f.write(content)

```


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.


```python

>>> import os

>>> 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.



# 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 `admin@mycomany.com`. 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.



# More about `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.


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

# Credit


Lawliet & Zhiniang Peng (@edwardzpeng)