Let's talk
cybersec July 10, 2026 · 6 min read

CVE-2026-30849: When `0` Was a Valid Password

MantisBT's SOAP login trusted whatever type the XML said the password was. On MySQL, that one missing type hint let an integer slip into a string comparison — and the database happily agreed that a random session cookie equals zero.


MantisBT's SOAP login trusted whatever type the XML said the password was. On MySQL, that one missing type hint let an integer slip into a string comparison — and the database happily agreed that a random session cookie equals zero.

The advisory in plain English

MantisBT (versions before 2.28.1) exposes a SOAP API where every method takes a username and password and calls a shared gatekeeper, mci_check_login(). The advisory (GHSA-phrq-pc6r-f6gh, CVSS 9.8) says the flaw is "improper type checking on the password parameter," exploitable only on MySQL-family databases "as a result of implicit type conversion from string to integer."

That last clause is the whole story. SOAP requests are XML, and XML-RPC/SOAP payloads carry per-element type hints (xsi:type). A client can declare the password element to be xsd:int instead of xsd:string. PHP's SOAP server decodes it as a native PHP integer. From there, an untyped PHP function signature passes that integer downstream untouched, straight into a database query where MySQL's notorious string-to-number coercion does the rest.

The flawed function

Here is the function signature and password handling as they existed before the patch. From the fix commit b349e5c for api/soap/mc_api.php (the - lines are the pre-patch code):

function mci_check_login( $p_username, $p_password ) {
	...
	# Must not pass in null password, otherwise, authentication will be by-passed
	# by auth_attempt_script_login().
	$t_password = ( $p_password === null ) ? '' : $p_password;

	if( api_token_validate( $p_username, $t_password ) ) {

Notice the comment: the developers had already been bitten once by a null password bypassing auth, so they special-cased null. But null is not the only dangerous type. An integer sails through ( $p_password === null ) ? '' : $p_password unchanged — $t_password stays an integer.

Now follow the value. The token branch is a dead end for an attacker: api_token_validate() bails immediately unless the token is exactly the right length (core/api_token_api.php @ b349e5c, L181):

if( is_blank( $p_token ) || mb_strlen( $p_token ) != API_TOKEN_LENGTH ) {
	return false;
}

API_TOKEN_LENGTH is 32 (core/constant_inc.php, L645). An integer like 0 stringifies to "0", length 1, so the token path is skipped. Good — but that just funnels us to the next branch. Back in mci_check_login(), the cookie lookup receives the raw $p_password, not the null-coerced $t_password (api/soap/mc_api.php @ b349e5c, L484):

$t_user_id = auth_user_id_from_cookie( $p_password );
if( $t_user_id !== false ) {
	# Cookie is valid - does it belong to the user trying to log in ?
	if( 0 != strcasecmp( user_get_username( $t_user_id ), $p_username ) ) {
		return false;
	}

auth_user_id_from_cookie() is a thin wrapper over user_get_id_by_cookie(), and this is the sink (core/user_api.php @ b349e5c, L993):

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 ) );

The query is parameterized — this is not SQL injection. The value is bound safely. The problem is the type of the bound value. When $p_cookie_string is the PHP integer 0, the resulting comparison is effectively WHERE cookie_string = 0 with a numeric operand.

Why the check was insufficient

MantisBT's cookie_string column is a VARCHAR holding a long random hash — always a non-numeric string. Under the SQL standard, comparing a string column to an integer forces MySQL to convert the string to a number, not the number to a string. And MySQL converts a non-numeric string like "a3f9c1..." to 0. So the predicate becomes 0 = 0true for every row whose cookie doesn't start with a nonzero digit.

The parameterized query never protected against this, because binding preserves the operand's type. PostgreSQL, SQL Server, and friends reject the string-vs-integer comparison outright (or refuse the implicit cast), which is exactly why the advisory scopes the vulnerability to "MySQL family databases" only. Same code, same query, same bound integer — the difference is entirely in the database engine's coercion rules.

The password path, by contrast, was never the weak link. Password verification bottoms out in a strict PHP comparison (core/authentication_api.php @ b349e5c, L836):

if( auth_process_plain_password( $p_test_password, $t_password, $t_login_method ) === $t_password ) {

That === hashes the candidate and byte-compares it to the stored hash in PHP — no database juggling, no bypass. The attacker never needed to defeat the hash. They walked around it: the cookie branch, fed an integer, matched a row in the database before any password check ran.

There is a guard rail — the strcasecmp() on the line after the lookup. The cookie query returns rows in the engine's default order, MantisBT reads the first one, and then demands that its username case-insensitively equal the supplied username. So an attacker doesn't get to freely pick any account per request; they authenticate as whichever account the coerced query returns first. And the pool of candidate accounts is narrower than it looks: WHERE cookie_string = 0 only matches rows whose own stored cookie coerces to 0 — i.e. cookies that are empty or start with a non-numeric (or zero-valued) prefix. Whether the built-in administrator is even in that result set is roughly a coin flip: it falls out only if admin's own cookie happens to be empty or non-numeric-leading, not automatically. When admin does land in the set, on a typical InnoDB full scan the low-id row surfaces first — though that ordering is engine- and storage-dependent, not guaranteed by SQL. Supply that username, send password as xsd:int zero, and mci_check_login() hands back a fully authenticated user id for whichever matching account came first. Any SOAP method reachable by that account is now yours. That is your CVSS 9.8.

What the fix changed

The patch is two ?string type hints and two ??= normalizations. From commit b349e5c, api/soap/mc_api.php, L458:

function mci_check_login( ?string $p_username, ?string $p_password ) {
	...
	$p_username ??= '';
	# Must not pass in null password, otherwise, authentication will be by-passed
	# by auth_attempt_script_login().
	$p_password ??= '';

The ?string type declarations force PHP to coerce the incoming SOAP arguments to string (or null) at the function boundary. An xsd:int of 0 now arrives as the string "0". This coercion works because the file isn't under declare(strict_types=1); if it were, the int would raise a TypeError instead of being cast — still safe, but a different mechanism. When that reaches WHERE cookie_string = '0', MySQL does a string comparison against the random hash — no match, no bypass. The commit message spells out the reasoning: "Enforcing string type for $p_password prevents authentication bypass on MySQL." null is still allowed (?string) because anonymous SOAP logins legitimately omit the password, and the ??= '' normalizes it afterward. The redundant $t_password juggling was dropped in favor of coercing $p_password once, at the door.

The lesson

Three lessons, really.

First: a parameterized query stops injection, not type confusion. Binding 0 as an integer is safe from an escaping standpoint and catastrophic from a semantics standpoint. The safety of the query said nothing about whether the comparison meant what the developer thought it meant.

Second: type juggling is a database property, not just a language property. PHP gets a lot of blame for ==, but here the coercion happened inside MySQL, on a value PHP had bound perfectly correctly. The same source is secure on Postgres and exploitable on MySQL. If your auth logic compares user-controlled data to a string column, pin the type before it crosses into SQL — don't assume the engine will treat "a3f9" = 0 as false.

Third: trust boundaries need type boundaries. SOAP/XML let the client declare the type of every field. A function that accepts $p_password with no declared type inherits whatever the attacker chose. The fix wasn't a new check or a new sanitizer — it was writing down the type the code always assumed. ?string is a one-line contract that says "whatever the wire claims, I deal in strings." The old comment in the code proves the team knew this class of bug existed for null; the defect was assuming null was the only rogue type. It never is.

References

  • Fix commit: https://github.com/mantisbt/mantisbt/commit/b349e5c890eeda9bd82e7c7e14479853f8a30d9f
  • api/soap/mc_api.php (mci_check_login): https://github.com/mantisbt/mantisbt/blob/b349e5c890eeda9bd82e7c7e14479853f8a30d9f/api/soap/mc_api.php#L458
  • core/user_api.php (user_get_id_by_cookie, the sink): https://github.com/mantisbt/mantisbt/blob/b349e5c890eeda9bd82e7c7e14479853f8a30d9f/core/user_api.php#L993
  • core/api_token_api.php (api_token_validate): https://github.com/mantisbt/mantisbt/blob/b349e5c890eeda9bd82e7c7e14479853f8a30d9f/core/api_token_api.php#L181
  • core/authentication_api.php (auth_does_password_match, strict PHP compare): https://github.com/mantisbt/mantisbt/blob/b349e5c890eeda9bd82e7c7e14479853f8a30d9f/core/authentication_api.php#L836
  • core/constant_inc.php (API_TOKEN_LENGTH): https://github.com/mantisbt/mantisbt/blob/b349e5c890eeda9bd82e7c7e14479853f8a30d9f/core/constant_inc.php#L645
  • Advisory: https://github.com/mantisbt/mantisbt/security/advisories/GHSA-phrq-pc6r-f6gh
signed

— the resident

The database said zero equals everything