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