Original flaw
Earlier advisory · CVE-2026-25521Locutus parse_str used an overridable key check before assigning into JavaScript objects.
Sink: locutus src/php/strings/parse_str.js parsed-segment assignment
Originally written by huntr-helper
How AI contributed: Incomplete remediationPublished Mar 27, 2026 in
locutusjs/locutus
Summary A prototype pollution vulnerability exists in the parse_str function of the npm package locutus. An attacker can pollute Object.prototype by overriding RegExp.prototype.test and then passing a crafted query string to parse_str, bypassing the prototype pollution guard. This vulnerability stems from an incomplete fix for CVE-2026-25521. The CVE-2026-25521 patch replaced the String.prototype.includes()-based guard with a RegExp.prototype.test()-based guard. However, RegExp.prototype.test is itself a writable prototype method that can be overridden, making the new guard bypassable in the same way as the original — trading one hijackable built-in for another. Package locutus (npm) Affected versions >= 2.0.39, <= 3.0.24 Tested and confirmed vulnerable on 2.0.39 and 3.0.24 (latest). Version 2.0.38 (pre-fix) uses a different guard (String.prototype.includes) and is not affected by this specific bypass. --- Description Details The vulnerability resides in parse_str.js where the RegExp.prototype.test() function is used to check whether user-provided input contains forbidden keys: The previous guard (fixed in CVE-2026-25521) used String.prototype.includes(): The CVE-2026-25521 fix correctly identified that String.prototype.includes can be hijacked. However, the replacement guard using RegExp.prototype.test() suffers from the same class of weakness — RegExp.prototype.test is a writable method on the prototype chain and can be overridden to always return false, completely disabling the guard. The robust fix is to use direct string comparison operators (===) in native control flow (for/if) instead of prototype methods like RegExp.prototype.test(), since === is a language-level operator that cannot be overridden. PoC Steps to reproduce 1. Install locutus using npm install locutus 2. Run the following code snippet: Expected behavior Prototype pollution should be prevented and ({}).polluted should print undefined. Actual behavior Object.prototype is polluted. This is printed on the console: Impact This is a prototype pollution vulnerability with the same impact as CVE-2026-25521. The attack requires a chaining scenario — an attacker needs a separate prototype pollution gadget (e.g., from another npm package in the same application) to override RegExp.prototype.test before exploiting parse_str. This is realistic in Node.js applications that use multiple npm packages, where one package's vulnerability can disable another package's defenses. Any application that processes attacker-controlled input using locutus/php/strings/parse_str may be affected. It could potentially lead to: 1. Authentication bypass 2. Denial of service 3. Remote code execution (if polluted property is passed to sinks like eval or child_process) Resources · Original advisory: https://github.com/locutusjs/locutus/security/advisories/GHSA-rxrv-835q-v5mh · Fix commit (incomplete): https://github.com/locutusjs/locutus/commit/042af9ca7fde2ff599120783e720a17f335bb01c · Vulnerable file: https://github.com/locutusjs/locutus/blob/main/src/php/strings/parse_str.js#L77 Maintainer response Thank you for the follow-up report. This issue was reproduced locally against [email protected], confirming that the earlier parse_str guard was incomplete: if RegExp.prototype.test was already compromised, the guard could be bypassed and parse_str('__proto__[polluted]=yes', result) could still pollute Object.prototype. This is now fixed on main and released in [email protected]. Fix Shipped In · PR: locutusjs/locutus#597 · Merge commit on main: 345a6211e1e6f939f96a7090bfeff642c9fcf9e4 · Release: v3.0.25 What the Fix Does The new fix no longer relies on a regex-prototype guard for safety. Instead, src/php/strings/parse_str.ts now rejects dangerous key paths during parsed-segment assignment, so the sink itself is hardened even if RegExp.prototype.test has been tampered with beforehand. Tested Repro Before the Fix · Override RegExp.prototype.test to always return false · Call parse_str('__proto__[polluted]=yes', result) · Observe ({}).polluted === 'yes' Tested State After the Fix in 3.0.25 · Dangerous key paths are skipped during assignment · The same chained repro no longer pollutes Object.prototype · The regression is covered by test/custom/parse_str-prototype-pollution.vitest.ts --- The locutus team is treating this as a real package vulnerability with patched version 3.0.25. The vulnerable range should end at < 3.0.25.
AI-written code used an overwritable RegExp.prototype.test guard, allowing prototype pollution via crafted query strings in parse_str.
Case facts
Only the highlighted steps are this advisory. The first step is the earlier flaw the AI tried, and failed, to close.
Original flaw
Earlier advisory · CVE-2026-25521Sink: locutus src/php/strings/parse_str.js parsed-segment assignment
Originally written by huntr-helper
This advisoryCVE-2026-33994 (GHSA-VC8F-X9PP-WF5P)
AI tried to fix this
The AI-assisted change was a real security patch, but it left the same advisory reachable.
Missed: Relied on the mutable RegExp.prototype.test method.
Fixed again
This is the patch that actually stops the same attack path.
@@ -74,7 +74,7 @@ module.exports = function parse_str(str, array) { key = _fixStr(tmp[0]) value = tmp.length < 2 ? '' : _fixStr(tmp[1]) - if (key.includes('__proto__') || key.includes('constructor') || key.includes('prototype')) {+ if (/__proto__|constructor|prototype/.test(key)) { break } @@ -2,6 +2,11 @@ import { getPhpGlobalScope } from '../_helpers/_phpRuntimeState.ts' import { isPhpAssocObject, type PhpAssoc, type PhpInput } from '../_helpers/_phpTypes.ts' type ParseObject = PhpAssoc<PhpInput>+const DANGEROUS_PARSE_STR_KEYS = new Set(['__proto__', 'constructor', 'prototype'])++function isDangerousParseStrKey(key: string): boolean {+ return DANGEROUS_PARSE_STR_KEYS.has(key)+} export function parse_str(str: string, array?: ParseObject): void { // discuss at: https://locutus.io/php/parse_str/@@ -71,10 +76,6 @@ export function parse_str(str: string, array?: ParseObject): void { key = _fixStr(tmp[0] ?? '') value = tmp.length < 2 ? '' : _fixStr(tmp[1] ?? '') - if (/__proto__|constructor|prototype/.test(key)) {- break- }- while (key.charAt(0) === ' ') { key = key.slice(1) }@@ -125,8 +126,22 @@ export function parse_str(str: string, array?: ParseObject): void { } keys[0] = primaryKey + let hasDangerousKey = false+ for (const rawKey of keys) {+ const normalizedKey = rawKey.replace(/^['"]/, '').replace(/['"]$/, '')+ if (isDangerousParseStrKey(normalizedKey)) {+ hasDangerousKey = true+ break+ }+ }++ if (hasDangerousKey) {+ continue+ }+ obj = target + let skipAssignment = false for (j = 0, keysLen = keys.length; j < keysLen; j++) { key = (keys[j] ?? '').replace(/^['"]/, '').replace(/['"]$/, '') lastObj = obj@@ -144,6 +159,11 @@ export function parse_str(str: string, array?: ParseObject): void { key = String(ct + 1) } + if (isDangerousParseStrKey(key)) {+ skipAssignment = true+ break+ }+ // if primitive value, replace with object const current = obj[key] if (!isPhpAssocObject(current)) {@@ -157,6 +177,10 @@ export function parse_str(str: string, array?: ParseObject): void { obj = next } + if (skipAssignment || isDangerousParseStrKey(key)) {+ continue+ }+ lastObj[key] = value } }AI-assisted change 2d86320ff9ac046f2928deec710952ea0655b5e63b159f2ea5731310bd456a69 · Fix 9314b664b01b2a5ca37a1d04c4c5b68839940a6b1c0cbe6a46b2541f4fd45b78