Skip to content

CWE-135: Incorrect Calculation of Multi-Byte String Length - PHP

Overview

PHP's strlen(), substr(), strpos(), and related string functions operate on raw bytes, not characters - a single UTF-8 character outside the ASCII range can occupy 2-4 bytes. PHP does not corrupt memory the way C does when this is miscounted (strings are managed, not fixed-size buffers), but the same byte/character confusion still causes real bugs: a limit written as "N characters" and enforced with strlen() silently rejects non-ASCII text well short of N, the reverse pairing sends up to four times the bytes a byte-sized store expected, substr() truncation can split a multi-byte sequence and produce invalid UTF-8, and code that assumes one byte equals one character can miscompute offsets used for validation or redaction. Use the mb_* (multibyte) extension functions, which are character-aware, for any string that may contain non-ASCII input.

Common Vulnerable Patterns

Using strlen()/substr() on user-supplied UTF-8 text

<?php
$user_input = $_POST['bio'];  // e.g. "Hello世界" - 7 characters, 11 bytes in UTF-8

// VULNERABLE - strlen() counts bytes, not characters
if (strlen($user_input) <= 10) {
    // never reached for this input: strlen() is 11, so a limit written as
    // "10 characters" throws out a 7-character bio
    save_profile_bio($user_input);
}

// VULNERABLE - substr() cuts at a byte offset, which can split a multi-byte character
$preview = substr($user_input, 0, 7);
// byte 7 is the middle of 世 (E4 B8 96), so $preview ends "\xE4\xB8" - not valid UTF-8

Why this is vulnerable: strlen() returns the byte length of the string, so a field intended to cap input at "10 characters" actually caps it at 10 bytes. Note which way that fails, because the intuitive reading is backwards: in UTF-8 the byte count is never smaller than the character count, so this check is stricter than intended, never looser - the 7-character bio above is rejected, not let through. That is what keeps the bug alive. It surfaces as users reporting that their name or bio is "too long" when it plainly is not, which gets triaged as an encoding complaint rather than as the length check being wrong. The direction that actually overflows something is the mirror image - a character count checked against a limit that is really in bytes, where 100 emoji satisfy a "100" test and deliver 400 bytes to a store sized for 100.

substr() slices by byte offset; if that offset falls inside a multi-byte sequence, the result contains a truncated, invalid UTF-8 fragment that can render as replacement characters or break downstream parsing. The offset has to land inside a sequence for this to happen - substr($user_input, 0, 8) on the same string happens to cut cleanly after , which is why a single hand-picked test case is poor evidence that a byte-offset slice is safe.

Secure Patterns

Use the mbstring extension for character-aware operations

<?php
$user_input = $_POST['bio'];

// Correct: mb_strlen() counts characters (code points), respecting the declared encoding
$char_count = mb_strlen($user_input, 'UTF-8');
$byte_count = strlen($user_input);  // still useful for storage/column-size limits

if ($char_count > 100) {
    http_response_code(400);
    exit('Input exceeds 100 characters');
}

if ($byte_count > 1000) {
    http_response_code(400);
    exit('Input exceeds storage limit');
}

// Correct: mb_substr() never splits a multi-byte character
$preview = mb_substr($user_input, 0, 8, 'UTF-8');

Why this works: mb_strlen() decodes the string according to the specified encoding and counts characters, not bytes, so a length limit expressed as "N characters" is enforced correctly regardless of script or emoji content. mb_substr() truncates on a character boundary, so the result is always valid UTF-8 - it can never end mid-sequence. Keeping the byte count (strlen()) alongside the character count lets you enforce separate storage-size and user-facing-length limits without conflating the two.

Set the internal encoding explicitly instead of relying on defaults

<?php
// Set once, early (e.g. in bootstrap/config) rather than passing 'UTF-8' to every call
mb_internal_encoding('UTF-8');

function truncate_for_display(string $text, int $max_chars): string
{
    if (mb_strlen($text) <= $max_chars) {
        return $text;
    }
    return mb_substr($text, 0, $max_chars - 1) . "\u{2026}"; // ellipsis, character-safe
}

Why this works: mb_* functions fall back to mb_internal_encoding() when no encoding argument is given, so setting it explicitly and consistently avoids a mismatch between what the function assumes and what the input actually is. An unverified default is what produces the miscount when the input turns out to arrive in some other encoding.

Considerations

A strlen() hit can be correct code. The finding is about using a byte count where a character count is meant, so an operation that genuinely needs bytes - checking a value against a VARCHAR byte limit, sizing a buffer, computing a Content-Length - is right to use the byte-counting functions and should be closed as a false positive. Say so in a comment next to the call: the risk is not only the original bug but a later edit "fixing" the correct byte-oriented call into a character-oriented one.

Audit the whole mb_* family, not just strlen. The byte/character split runs through substr, strpos, str_split and wordwrap as well, and each has an mb_ counterpart - mb_substr, mb_strpos, mb_str_split. Code that converts the length check and leaves the substring operation on bytes still cuts multi-byte characters in half.

Testing

  • Test length limits with strings that are exactly at the character limit but over the byte limit (e.g. 100 emoji, each 4 bytes = 400 bytes) and confirm the limit that's supposed to apply (character or byte) is the one enforced.
  • Test truncation (mb_substr) with input where a multi-byte character sits exactly at the cut point and confirm the output is valid UTF-8, not a split sequence.
  • Test with combining characters and right-to-left scripts (Arabic, Hebrew), and state which count the limit is in before asserting anything. mb_strlen() counts code points, not what a reader would call characters: measured on PHP 8.5.8, cafe followed by a combining acute is 6 bytes, 5 code points and 4 grapheme clusters, and a ZWJ emoji family is 18 bytes, 5 code points and 1 cluster. Where the limit exists so text fits a field or a screen, count clusters with grapheme_strlen() and cut with grapheme_substr() from intl, the same distinction the C page makes with ICU's ubrk_open(UBRK_CHARACTER, ...).

Common Pitfalls

  • Calling mb_strlen() without specifying (or globally setting) the encoding: relying on PHP's default assumed encoding still produces a wrong count if the actual input arrives in a different encoding (e.g. Latin-1 from a legacy upstream system) - always know and declare the encoding of the data you're counting.
  • Fixing the display-facing substr() call but leaving a matching byte-oriented check elsewhere unexamined: a form might switch its preview truncation to mb_substr() while a separate validation rule still calls strlen() against the same "100 characters" limit - the two checks then disagree, and the byte-based one is still wrong.
  • Assuming ASCII-only test data proves multi-byte handling works: a test suite that only exercises English input passes regardless of whether strlen() or mb_strlen() is used, since they return the same value for pure ASCII - the bug only appears with real multi-byte input.

Additional Resources