strpos() Logic Bug
strpos("admin", "admin") returns 0 — the match is at index 0.
strpos("safe", "admin") returns false. Under loose comparison:
0 == false→true0 === false→false
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 intent | Bypass input |
|---|---|
Block admin substring | admin (no prefix at all) |
Block ../ paths | ../../../etc/passwd |
Block javascript: URIs | javascript:alert(1) |
| Block SQL keywords | OR 1=1-- |
Audit focus
For each strpos / mb_strpos / stripos:
- What is the comparison?
=== falseis correct.== false,!= false, and bare!strpos(…)are wrong. - What is the security purpose? Denylist (block this string) or allowlist (require this string)? Both are vulnerable to the same flaw in inverse forms.
- Can the attacker control the start of the haystack? Usually yes — that’s the bypass.
- Is
strposthe right tool at all? For “does X appear in Y”,str_contains(PHP 8+) returns a cleanbooland 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.
Related
- Type Juggling
- Path Traversal —
strpos($p, "..") === falseis the canonical wrong filter