JumpServer Remote Code Execution Vulnerability

Introduction

JumpServer is the first open-source jump server based on Django. It provides friendly web UI for dev-ops management administrators and users to access the remote resources such as servers. databases, and so on.

The project can be found at https://github.com/jumpserver/jumpserver

Few days ago, an adversary was released to address a remote code execution vulnerability. The infected versions are below or equal to 2.6.1 while the fix is in the version 2.62. In addition to the official fix, a temporary solution was also provided as to disable the vulnerable interfaces as below:

/api/v1/authentication/connection-token/
/api/v1/users/connection-token/

Analysis – Overview

According to the infected versions, we can easily find out that the fix is in the version of 2.6.2. So let’s find out the differences between 2.6.1 and 2.6.2. Github provides a page to diff two tagged versions – https://github.com/jumpserver/jumpserver/compare/v2.6.1…v2.6.2

According to the git log, we can find that the fix of the vulnerability is in the commit 82077a3. Here is the fix: https://github.com/jumpserver/jumpserver/commit/82077a3ae1d5f20a99e83d1ccf19d683ed3af71e

commit 82077a3ae1d5f20a99e83d1ccf19d683ed3af71e

In this commit, a get_permissions() method was deleted from the class UserConnectionTokenApi(). Some authentication of user scope was added in the class CeleryLogWebsocket().

In the method get_permissions(), the logic is to get the param user-only from the request params. If it is existed, the set the permissions to AllowAny, which seems to be the root cause. The fix is just to check if the user is authenticated and is the administrator before to accept the Websocket traffic.

Analysis – UserConnectionTokenApi()

We need to acknowledge how and where the two related classes are called.

The framework is based on Django so it is easy to follow them in the code. The class is called as a view class in the following two URL config files. This matches the interfaces provided in the security adversary.

apps/users/urls/api_urls.py
path('connection-token/', auth_api.UserConnectionTokenApi.as_view(),
         name='connection-token'),

# apps/authentication/urls/api_urls.py
path('connection-token/',
         api.UserConnectionTokenApi.as_view(), name='connection-token'),

/api/v1/authentication/connection-token/
/api/v1/users/connection-token/

UserConnectionTokenApi() inherited from RootOrgViewMixin() in apps/orgs/mixins/api.py.

There are no suspicious part here…. Let’s skip it for now.

Analysis – CeleryLogWebsocket()

In apps/ops/urls/ws_urls.py:

path('ws/ops/tasks/log/', ws.CeleryLogWebsocket, name='task-log-ws'),

In the method receive(), the code extracts the value of the param task from text_data and calls the method handle_task() with the task_id which can be manipulated by users.

class CeleryLogWebsocket(JsonWebsocketConsumer):
    disconnected = False

    def connect(self):
        self.accept()

    def receive(self, text_data=None, bytes_data=None, **kwargs):
        data = json.loads(text_data)  # MyComment: the test_data should be in JSON 
        task_id = data.get("task")
        if task_id:
            self.handle_task(task_id)

    def wait_util_log_path_exist(self, task_id):
        log_path = get_celery_task_log_path(task_id)  # MyComment: CELERY_LOG_DIR + task_id[0] + task_id[1] + task_id + '.log'
        while not self.disconnected:
            if not os.path.exists(log_path):
                self.send_json({'message': '.', 'task': task_id})
                time.sleep(0.5)
                continue
            self.send_json({'message': '\r\n'})
            try:
                logger.debug('Task log path: {}'.format(log_path))
                task_log_f = open(log_path, 'rb')  # MyComment: read the log content
                return task_log_f
            except OSError:
                return None

    def read_log_file(self, task_id):
        task_log_f = self.wait_util_log_path_exist(task_id)
        if not task_log_f:
            logger.debug('Task log file is None: {}'.format(task_id))
            return

        task_end_mark = []
        while not self.disconnected:
            data = task_log_f.read(4096)
            if data:
                data = data.replace(b'\n', b'\r\n')
                self.send_json(
                    {'message': data.decode(errors='ignore'), 'task': task_id}
                )
                if data.find(b'succeeded in') != -1:
                    task_end_mark.append(1)
                if data.find(bytes(task_id, 'utf8')) != -1:
                    task_end_mark.append(1)
            elif len(task_end_mark) == 2:
                logger.debug('Task log end: {}'.format(task_id))
                break
            time.sleep(0.2)
        task_log_f.close()

    def handle_task(self, task_id):
        logger.info("Task id: {}".format(task_id))
        thread = threading.Thread(target=self.read_log_file, args=(task_id,))
        thread.start()

    def disconnect(self, close_code):
        self.disconnected = True
        self.close()

I made some comments in the code. It is clear that we can use this class to read files whose names end with “.log”


Update:

Alright, I found some articles talking about the RCE part. Briefly, it’s to use the read log vulnerability to find “user”, “asset”, and “system_user” and call the UserConnectionTokenApi() post() method to get a token. Normally, this token is used to start a TTY in the web console.

Interesting…

Leave a Reply