intval() / (int) Bypass
intval("123abc") returns 123. (int)"123abc" returns 123. The
non-numeric tail is dropped without a warning. When code uses intval to
“sanitise” an ID for one query but uses the raw string for a second query,
display, or ownership check, the two views diverge and access control
breaks.
Why
The classic shape:
$raw = $_GET['id'];
$id = intval($raw);
$row = $db->find($id); // intval'd — points at row 1
display("Editing item: $raw"); // raw — shows "1abc"
audit_log("user edited $raw"); // raw — corrupts logs
if ($row->owner_id == $_SESSION['user_id']) {
$db->update($raw, $_POST); // raw again — depending on driver,
} // may hit a different row or fail123abc and 123 are simultaneously “the same row” and “different
strings” depending on which line you’re reading.
A second flavour: when the integer cast is used as a boolean (“non-zero
means logged in”), strings starting with non-digits become 0:
$logged_in = intval($_COOKIE['session']); // attacker sets cookie to "abc"
if ($logged_in) { … } // 0 — fine, but…Inverse cases are worse — when 0 is “guest” but 0 also means “valid”,
the application leaks state.
Search patterns
rg -n 'intval\(|\(int\)|\(integer\)' --type=php
rg -n 'settype\(\s*\$\w+,\s*[''"]integer[''"]\s*\)' --type=php
# floatval too — same family
rg -n 'floatval\(|\(float\)|\(double\)' --type=phpTest inputs
| Input | intval result | Note |
|---|---|---|
123abc | 123 | trailing garbage dropped |
0abc | 0 | starts with 0 |
admin123 | 0 | non-digit prefix → 0 |
1 OR 1=1 | 1 | SQLi survives if $raw is the one used in query |
1' OR 1=1-- | 1 | same |
1e5 | 1 | not 100000 — intval doesn’t read scientific |
" 123" | 123 | leading whitespace trimmed |
-1 | -1 | may unlock unsigned-int rows / wrap downstream |
99999999999999999999 | PHP_INT_MAX | clamped on 64-bit |
0x1A | 0 | hex not parsed by default |
010 | 10 | octal not parsed (changed in PHP 7) |
Audit focus
For each intval / (int) hit:
- What is the cast result used for? Authorization decision, payment validation, object ownership, array index?
- Is the original string also used later? Display, second query, log line, redirect, email? Two views of the same parameter is the canonical bug shape.
- Is there a strict validation paired with the cast?
ctype_digit,filter_var(..., FILTER_VALIDATE_INT), regex — anything that rejects on failure rather than silently zeroing. - Negative numbers — does
-1(orPHP_INT_MIN) unlock unexpected rows or wrap to large unsigned values downstream? - Range — does the integer feed an array index that could be very large or negative?
Fix
Validate, don’t sanitise. Reject inputs that aren’t already valid:
$id = filter_var($_GET['id'], FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1, 'max_range' => PHP_INT_MAX],
]);
if ($id === false) {
http_response_code(400);
exit;
}Then use $id everywhere — never the raw string.
When a framework’s router converts {id} segments via type-cast, you may
still get intval-like behaviour at the controller boundary. Check the
router config — Laravel’s where('id', '[0-9]+') constraint is the right
default for numeric routes.