skip to content

A PHP checkout checks coupons with $input == $coupon->code and accepts codes that should fail; which loose-comparison rules cause it, and did PHP 8 fix them?

level: seniorimportance: should knowfreq 42%

answer

  1. the input is not always a string
  2. numeric strings compare as numbers
  3. JSON true equals any non-empty code
  4. 0 == 'CODE' fixed only in 8.0
  5. typed string parameter plus ===

basics

~20 s

Loose == accepts numeric look-alikes ("1e2" == "100"), a JSON true against any ordinary code, and null against an empty code. PHP 8.0 fixed only int 0 matching a non-numeric code; require a string and compare with ===.

solid answer

~40 s

Three loose rules let wrong input through. First, when **both strings are numeric** PHP compares them as numbers, so `"1e2" == "100"` and `"0012" == "12"` are true; PHP 8 did not change that. Second, a **JSON body** can decode the code as `true`, and a `bool` operand casts both sides to `bool`, so `true == "SPRING25"` is true on every version. Third, a missing field arrives as `null`, and `null == ""` is true if a record has an empty code. PHP 8.0 fixed only the case where an `int` `0` matched any non-numeric code. The fix is to accept the code only as a `string` (a `string` parameter under `declare(strict_types=1)` turns a JSON `true` into a `TypeError`), normalise it explicitly, and compare with `===`.

code

php · 7 lines
php
<?php
$code = 'SPRING25';
var_dump(true == $code);     // bool(true): both sides cast to bool
var_dump(0 == $code);        // bool(false) since PHP 8.0, true before
var_dump('1e2' == '100');    // bool(true): numeric strings compare as numbers
var_dump('0012' == '12');    // bool(true)
var_dump(null == '');        // bool(true): null treated as ""

go deeper

for a junior

Recognise that request input can arrive as bool, int or null, not only as a string, and that == will happily compare them.

for a middle

Explain the three rules behind the bypass: numeric strings compare numerically, a bool casts both sides, and null equals the empty string.

for a senior

Show which case PHP 8.0 fixed and which it kept, then fix the boundary with a typed string parameter under strict_types and a === comparison, plus attack-input tests.

for a principal

Treat it as a class of bug: push type validation to every request boundary and make strict comparison a lint rule rather than a per-review catch.

## The bug in one line A checkout endpoint reads a coupon code from the request and compares it with the stored one: ```php if ($input == $coupon->code) { applyDiscount($coupon); } ``` The author imagined two strings. In production `$input` can be a **string from a form**, a **scalar of any type from `json_decode()`**, or **`null`** when the field is missing. The `==` operator then applies PHP's loose-comparison rules, and several of them say "equal" for inputs a human would reject. ## Route 1: two numeric strings compare as numbers When **both** operands are numeric strings, `==` compares them as numbers, not as text. Some coupon schemes use digit-only or digit-plus-`e` codes, and then: - `"1e2" == "100"` is true (both are the number 100); - `"0012" == "12"` is true (leading zeros vanish in the numeric value); - `"0e1234" == "0e9876"` is true (both are zero in exponent notation). The last pattern is the famous one: two different strings that both *look like* `0e` followed by digits are equal under `==`. **PHP 8 did not change any of this**: the 8.0 reform left numeric-string comparison as it was. ## Route 2: a boolean from a JSON body API clients send JSON. If an attacker sends `{"code": true}`, `json_decode()` produces the `bool` `true`. When either operand of `==` is a `bool` (or `null`), PHP converts **both** sides to `bool`, and every non-empty string other than `"0"` is `true`. So `true == "SPRING25"` is true, and the attacker redeems any coupon without knowing its code. This rule is also **unchanged in PHP 8**. ## Route 3: null against an empty code If a code is stored as `""` (for example, an automatic promotion with no code) and the request omits the field, `$input` is `null`. `null` compared with a string is treated as `""`, so `null == ""` is true. ## What PHP 8.0 actually fixed The one route PHP 8.0 closed is an **integer** against a non-numeric code: | Input value | Stored code | PHP 7.x `==` | PHP 8.x `==` | |---|---|---|---| | `0` (int, from JSON) | `"SPRING25"` | true | **false** | | `true` (bool, from JSON) | `"SPRING25"` | true | true | | `"1e2"` | `"100"` | true | true | | `null` | `""` | true | true | So an upgrade to PHP 8 closes the `0` hole and leaves the others open. A team that believes "PHP 8 fixed loose comparison" ships the bug. ## The fix Fix the type first, then compare strictly: 1. **Accept only a string.** Type the parameter as `string` in a file with `declare(strict_types=1)`, or check `is_string()` at the boundary and reject anything else. Without `strict_types`, a `string` parameter would *coerce* `true` to `"1"` instead of rejecting it. 2. **Normalise explicitly.** If codes are case-insensitive or may carry spaces, apply `trim()` and `strtoupper()` yourself, so the rule is visible in code. 3. **Compare with `===`.** Identity compares the two strings byte for byte, so `"1e2" === "100"` is false. 4. **Hunt siblings.** The same loose rules run in `switch` cases and in lookups that compare loosely by default, so search the code base for them too. ## How to explain it in a review - The operator was not wrong on its own terms; the **input type was unconstrained**, and `==` trusts whatever type arrives. - The PHP 8.0 change is a partial fix; list the rules it kept. - A regression test should send the attack inputs (`true`, `"1e2"`, `null`) through the real decoding path, not only well-formed strings. ## A test matrix for the fix For a stored code such as `"100"` or `"SPRING25"`, the fixed endpoint should behave like this: | Request value | Expected outcome | |---|---| | `"SPRING25"` | accepted | | `" spring25 "` | accepted only if the normalisation rule says so | | `"1e2"` against `"100"` | rejected | | `true`, `0`, `null` | rejected at the boundary, before any comparison | Writing the matrix down turns an operator subtlety into a checkable contract, and it keeps the next refactor from quietly reintroducing `==`.

  • Without strict_types, what happens when the JSON true reaches a string parameter?
    In coercive mode PHP converts the `bool` to a `string`, so `true` becomes `"1"`, and the function runs with that value. The `===` comparison then fails against a real code, so the bypass closes, but the bad input is silently accepted instead of rejected. Under `declare(strict_types=1)` in the calling file the same call throws `TypeError`.
  • Why is "0e1234" == "0e9876" true, and why does it matter beyond coupons?
    Both strings are numeric: exponent notation with a zero mantissa, so each equals zero and `==` compares them as numbers. The same pattern appears when hex digests are compared with `==`: two different hashes that happen to start with `0e` and continue with digits compare equal, which has caused real authentication bypasses. Compare digests as strings, never with `==`.

A doorman who checks guest-list names by how they sound will admit "Jon" for "John". Strict comparison is checking the spelling on an ID card: the input must first be an ID card at all, then match letter for letter.

saying these in an interview costs you the question

  • Says upgrading to PHP 8 removes every loose-comparison bypass.
  • Believes a JSON true cannot match a string code because the types differ.
  • Thinks two different strings are always unequal under ==.
  • Assumes a string parameter rejects true even without strict_types.
  • Fixes it by casting the input with (string) and keeping ==