September 12, 2026
PHP Type Juggling: OTP Bypass to CRM Takeover
I sent โotpโ: false in a JSON payload.

By BlackOuT
2 min read
The server replied: "Password Updated !!"
The account that changed wasn't the one I specified in the request body.
That single discrepancy turned a one-line finding into a much longer investigation.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
THE SETUP
Authorized VAPT. CRM platform. Password reset flow.
Endpoint: POST /api/forget-pass Required: user_id, otp
OTP-protected. Reasonable on paper.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
FIRST TEST โ "otp": true
JSON boolean. Not a number. Not a string.
Server response: "Password Updated !!"
Why?
PHP loose comparison:
JSON true decodes to PHP bool(true). PHP's == coerces it against any non-zero integer. The stored OTP was an integer. The check passed.
One character. No brute force. No OTP interception.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
SECOND TEST โ "otp": false
Same endpoint. user_id: 2. otp: false.
Server: "Password Updated !!"
But the response body showed:
I targeted user_id: 2. The modified account was user_id: 1.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
TRACING IT
false == 0 is true in PHP.
The account at user_id: 1 had an OTP stored as 0 โ the database column's default value. No reset had ever been requested for it. false == 0 passed the check silently.
Meanwhile, the password write resolved from session context, not from the request body's user_id. The body was used for the OTP lookup. The actual write went to whichever user the session held.
Two separate problems. One request to expose both.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
THE IMPACT CHAIN
No brute force. No phishing. One JSON boolean.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
ROOT CAUSE
PHP's == silently coerces types before comparing:
The zero default is the silent enabler. Many ORM frameworks initialize unset OTP columns to 0 when there is no NOT NULL constraint and no prior value. That's your bypass surface.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
THREE FIXES โ in order of importance
- Strict comparison: === instead of ==
- Input validation: 'otp' => 'required|integer|digits:6'
- Database: NOT NULL on the OTP column with no default zero
A single validation rule would have blocked every variant I tested. Defense in depth means you don't rely on any one of these alone.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
THE LESSON
Scanner output: nothing unusual.
Finding this required: โ Knowing PHP's coercion rules cold โ Systematically iterating non-integer OTP types โ Noticing the response showed the wrong account โ Tracing session vs. request body resolution โ Connecting database column defaults to the bypass condition
No tool surfaces this. Reasoning does.
When a language has forty-seven loose-comparison edge cases, those edge cases will show up in security controls โ especially ones written quickly, under deadline, by developers focused on the happy path.
Type juggling isn't exotic. It's a regular consequence of mixing dynamic typing with integer-based validation, and it appears in production far more often than it should.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
What's your mental model for catching this in code review? Do you grep for === explicitly, or rely on input validation catching the type mismatch before it ever reaches the comparison?
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
GitHub: https://github.com/BlackOuTv2
LinkedIn: https://www.linkedin.com/in/black0ut/
Medium: https://medium.com/@black0uTv1
#APISecurity #WebApplicationSecurity #VAPT #BugBounty #CyberSecurity #AppSec
Originally published at https://www.linkedin.com.