Casting Spells and Data Types - Mantis Bug Tracker CVE-2026-30849

Mantis Bug Tracker SOAP API Authentication Bypass

Today, we’re focused on CVE-2026-30849, a critical SOAP API authentication bypass in MantisBT version ≤2.28.0 affecting deployments using MySQL. Discovered by SynerComm, the advisory aptly describes the issue as follows:

Mantis Bug Tracker instances running on MySQL and compatible databases are affected by an authentication bypass vulnerability in the SOAP API, as a result of improper type checking on the password parameter.

MantisBT Advisory

Rated at a healthy 9.3 CVSS, This vulnerability harkens back to the vulnerabilities of old: improper type checking in PHP applications, a classic. I won’t bore the reader with a history lesson, but this has been an issue in PHP applications longer than most pentesters have been pentesters (myself included).

However, this issue does not revolve around loose vs strict comparisons, as is so often the case. Rather, the underlying issue is the result of how MySQL handles type casting during comparisons of different data types. The ultimate outcome: an attacker can log in to the administrator account with the password 0, providing complete administrative control over the SOAP API.

A Love Letter to SOAP

I love a good SOAP API. There’s nothing uniquely wrong with SOAP, though others might disagree. However, in my experience I’ve found that it’s often legacy functionality, uses different authentication schemes/functions from the rest of the application, has been supplanted by a REST API, or has generally been subject to less poking and prodding from irritating researchers like myself.

The MantisConnect SOAP API has 72 unique operations. All functions, barring the version check, demand that a user provide a username and password, as it should.

The mc_config_get_string SOAP operation

However, the SOAP API uses a different authentication method than the rest of the application, allowing for authentication with a password, API key, or a cookie (wink wink) in the same password parameter.

Following The Sinks

Taking a look at the above operation, we can see that the function beings with a call to mci_check_login at [1] that expects both a username and password. If a user ID is returned, the rest of the operation will be performed.

function mc_config_get_string( $p_username, $p_password, $p_config_var ) {
	$t_user_id = mci_check_login( $p_username, $p_password ); // [1]
	if( $t_user_id === false ) {
		return mci_fault_login_failed();
	}

	if( !mci_has_readonly_access( $t_user_id ) ) {
		return mci_fault_access_denied( $t_user_id );
[...]
}

A close look at this function reveals a series of if/else statements

function mci_check_login( $p_username, $p_password ) {
	static $s_already_called = false;

	if( $s_already_called === true ) {
		return auth_get_current_user_id();
	}

	$s_already_called = true;

	if( mci_is_mantis_offline() ) {
		return false;
	}

	# Must not pass in null password, otherwise, authentication will be by-passed
	# by auth_attempt_script_login().
	$t_password = ( $p_password === null ) ? '' : $p_password; // [1]

	if( api_token_validate( $p_username, $t_password ) ) {
		# Token is valid, then login the user without worrying about a password.
		if( auth_attempt_script_login( $p_username ) === false ) {
			return false;
		}
	} else {
		# User cookie
		$t_user_id = auth_user_id_from_cookie( $p_password ); // [2]
		if( $t_user_id !== false ) {
			# Cookie is valid
			if( auth_attempt_script_login( $p_username ) === false ) { // [3]
				return false;
			}
		} else {
			# Use regular passwords
			if( auth_attempt_script_login( $p_username, $t_password ) === false ) {
				return false;
			}
		}
	}

	# Set language to user's language
	lang_push( lang_get_default() );

	return auth_get_current_user_id();
}

The astute reader will notice an interesting comment, one that I have not added in there on my own:

# Must not pass in null password, otherwise, authentication will be by-passed by auth_attempt_script_login()

This implies that, should a null password be passed to the auth_attempt_script_login function, or rather no password at all, then it may be possible to login using only the username. Of course, the application makes sure that the user controlled password isn’t null at [1].

However, further into the if statement, we see an interesting function call at [3]: a call to auth_attempt_script_login that only passes in the username. Perhaps if we can pass the check at [2], it may be possible to get that null password we’re seeking and authenticate.

One Username Please

Before we analyze the cookie logic, it’s worth taking a brief detour to discuss the auth_attempt_script_login function.

function auth_attempt_script_login( $p_username, $p_password = null ) { // [1]
	global $g_script_login_cookie;

	$t_username = $p_username;
	$t_password = $p_password;

	$t_anon_allowed = auth_anonymous_enabled();
	if( $t_anon_allowed == ON ) {
		$t_anonymous_account = auth_anonymous_account();
	} else {
		$t_anonymous_account = '';
	}

	# if no username supplied, then attempt to log in as anonymous user.
	if( is_blank( $t_username ) || ( strcasecmp( $t_username, $t_anonymous_account ) == 0 ) ) {
		if( $t_anon_allowed == OFF ) {
			return false;
		}

		$t_username = $t_anonymous_account;

		# do not use password validation.
		$t_password = null;
	}

	$t_user_id = auth_get_user_id_from_login_name( $t_username ); // [2]
	if( $t_user_id === false ) {
		$t_user_id = auth_auto_create_user( $t_username, $p_password );
		if( $t_user_id === false ) {
			return false;
		}
	}

	$t_user = user_get_row( $t_user_id ); // [3]

	# check for disabled account
	if( OFF == $t_user['enabled'] ) {
		return false;
	}

	# validate password if supplied
	if( null !== $t_password ) { // [4]
		if( !auth_does_password_match( $t_user_id, $t_password ) ) {
			return false;
		}
	}

	# ok, we're good to login now
	# With cases like RSS feeds and MantisConnect there is a login per operation, hence, there is no
	# real significance of incrementing login count.
	# increment login count
	# user_increment_login_count( $t_user_id );
	# set the cookies
	$g_script_login_cookie = $t_user['cookie_string'];

	# cache user id for future reference
	current_user_set( $t_user_id );

	return true; // [5]
}
  • At [1], we can see that the function signature uses a null password by default.

  • At [2], the application retrieves a user ID based on the provided username

  • At [3], the application retrieves all of the users information based on the user ID

  • And most importantly, at [4], if a password is provided, it will check that the password is valid. If not, it will continue down to [5] and return true from the function, completing authentication.

The outcome is clear: so long as we can reach this sink with a valid username and no password, we can authenticate as the user. The question becomes: how can we pass the check in auth_user_id_from_cookie

The function auth_user_id_from_cookie serves as a wrapper to user_get_id_by_cookie, which passes in the user controlled password.

function user_get_id_by_cookie( $p_cookie_string, $p_throw = false ) {
	if( $t_user = user_search_cache( 'cookie_string', $p_cookie_string ) ) {
		return (int)$t_user['id'];
	}

	db_param_push();
	$t_query = 'SELECT * FROM {user} WHERE cookie_string=' . db_param();
	$t_result = db_query( $t_query, array( $p_cookie_string ) );

	$t_row = db_fetch_array( $t_result );

	if( !$t_row ) {
		if( $p_throw ) {
			throw new ClientException(
				"User Cookie String '$p_cookie_string' not found",
				ERROR_USER_BY_NAME_NOT_FOUND,
				array( $p_cookie_string )
			);
		}
		return false;
	}

	user_cache_database_result( $t_row );
	return (int)$t_row['id'];
}

There are some key considerations in this check:

  • It is checking only for the cookie, not for the username associated with the cookie.

  • The function will return the id from the retrieved row, so long as a row is retrieved at all.

So long as we can retrieve any valid cookie string from the application, then we’d be able to pass the check successfully and hit the auth_attempt_script_login with only the username provided, thus logging in. But how might that be accomplished?

Casting Spells

So long as we can retrieve any valid cookie string from the application, then we’d be able to pass the check successfully and hit the auth_attempt_script_login with only the username provided, thus logging in. But how might that be accomplished?

The magic trick here lies in how MySQL attempts to cast values when comparing two different types. Let’s consider the above SQL query, SELECT * FROM {user} WHERE cookie_string= . This is an exact comparison, which expects that the query will check the exact value of a cookie string. However, if MySQL attempts to compare a string to an integer, it will attempt to turn the string into an integer to ensure the comparison can still be performed. As such, it will take the string and do one of two things:

  1. If the string starts with a leading integer, it converts the string to that leading integer, i.e. 1ABCDE becomes 1.

  2. If the string does not start with a leading integer, the string is cast to the value 0.

To put it into perspective, the below shows the above SQL query (abbreviated to just the username and cookie_string). We can see that performing a WHERE clause that compares a string to an incomplete string returns no results, as expected.

However, the cookie string does not start with a leading integer. If we instead perform the WHERE clause to compare the cookie_string to 0…

We successfully retrieve the row. This means that, so long as a user with a cookie_string exists, it would be possible to iterate through 0-9 and retrieve at least one of them, passing the check. However, as it is more likely than not that a cookie_string will not begin with a leading integer, 0 will usually suffice.

Exploitation

So what does this all mean? At this point, we know the following:

  • Every SOAP operation reaches mci_login_check

  • In mci_login_check, there is a call to auth_user_id_from_cookie that, if we can pass an integer in our password (0-9, depending on the leading integer of the cookie_string), will return true.

  • If auth_user_id_from_cookie is true, then we can reach auth_attempt_script_login with only our provided username and no password, and so long as the username is valid, it will authenticate successfully. It is worth noting at this point that the default username is administrator .

The question now becomes: how do we pass in an integer as our password? Fortunately, that’s the simplest part of the entire ordeal. SOAP requests can have multiple namespaces defined as part of their envelope, including those that explicitly define integer types. By applying the additional namespaces xmlns:xsi="<http://www.w3.org/2001/XMLSchema-instance>" xmlns:xsd="<http://www.w3.org/2001/XMLSchema>" to the envelope, we can also define the integer data type. We’d then explicitly type the password as an integer, which is as simple as applying xsi:type="xsd:integer" to the password parameter. Below is a valid HTTP request used to reach bypass authentication on the mc_login operation:

Impact

The SOAP API does not contain any functions like user operations and, during the course of this research, I did not identify any operations that may have additional vulnerabilities, such as arbitrary file uploads. However, as an administrator, an attacker has complete control over all tickets and projects in the environment, including private tickets, notes, conversations, projects and much more.

Disclosure Timeline

I want to add that the Mantis team was a pleasure to work with and clearly care deeply about their security. They validated and patched the issue very quickly. Thank you!

  • March 1st, 2026 - Vulnerability submitted to MantisBT team

  • March 6th, 2026 - Proposed patch created

  • March 16th, 2026 - Fixed version 2.28.1 released

  • March 23rd, 2026 - Advisory published