These notes are public, opinionated, and evolving — read abdelkader.ma for the long-form posts.
PHP Securitystrpos() == false Bug

strpos() Logic Bug

strpos("admin", "admin") returns 0 — the match is at index 0. strpos("safe", "admin") returns false. Under loose comparison:

  • 0 == falsetrue
  • 0 === falsefalse

So if (strpos(...) == false) greenlights both “not found” and “found at position 0.” Filters and denylists using == (or the shorthand !strpos(...)) are bypassable by placing the payload at the very start of the haystack.

Why

// "no admin keyword allowed"
if (strpos($input, "admin") == false) {
  $db->save($input);   // ← also runs when input STARTS with "admin"
}

Same bug, terser:

if (!strpos($input, "admin")) {   // !0 == true
  $db->save($input);
}

Both let admin: do thing through.

The mirror image — if (strpos(...) != false) — has the inverse problem: “found at position 0” reads as false, so a payload at position 0 is silently classified as “not found.”

Search patterns

# Loose comparison against false
rg -n 'strpos\(|mb_strpos\(|stripos\(|mb_stripos\(' --type=php \
  | rg '(==|!=)\s*(false|FALSE)'
 
# Shorthand — even more common
rg -n '!\s*(strpos|stripos|mb_strpos|mb_stripos)\(' --type=php
 
# Sometimes wrapped in a truthy check
rg -n 'if\s*\(\s*(strpos|stripos)\(' --type=php | rg -v '===|!=='

Test inputs

The payload’s location matters more than its content. For each filter, test the payload at:

  • Offset 0 — the bypass position (admin, ../../etc/passwd, <script>)
  • Offset > 0 — control (should still trip the filter)

Examples for common filters:

Filter intentBypass input
Block admin substringadmin (no prefix at all)
Block ../ paths../../../etc/passwd
Block javascript: URIsjavascript:alert(1)
Block SQL keywordsOR 1=1--

Audit focus

For each strpos / mb_strpos / stripos:

  1. What is the comparison? === false is correct. == false, != false, and bare !strpos(…) are wrong.
  2. What is the security purpose? Denylist (block this string) or allowlist (require this string)? Both are vulnerable to the same flaw in inverse forms.
  3. Can the attacker control the start of the haystack? Usually yes — that’s the bypass.
  4. Is strpos the right tool at all? For “does X appear in Y”, str_contains (PHP 8+) returns a clean bool and removes the entire class. For exact match, ===. For prefix match, str_starts_with.

Fix

// Substring check — PHP 8+
if (str_contains($input, "admin")) { reject(); }
 
// PHP 7.x
if (strpos($input, "admin") !== false) { reject(); }

For the inverse intent (“must contain a token”):

if (!str_contains($input, $expected)) { reject(); }

In code review, even when the check is correct, a substring denylist is almost always the wrong design — escape the output instead of trying to enumerate dangerous input. The right fix for an XSS filter isn’t a better strpos, it’s htmlspecialchars.