Skip to content

Fix control characters passing unescaped into RFC 2047 encoded-words - #37

Open
sebastka wants to merge 1 commit into
pear:masterfrom
sebastka:fix/rfc2047-q-encoding-control-chars
Open

Fix control characters passing unescaped into RFC 2047 encoded-words#37
sebastka wants to merge 1 commit into
pear:masterfrom
sebastka:fix/rfc2047-q-encoding-control-chars

Conversation

@sebastka

@sebastka sebastka commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Hello again,

This is the last RFC 2047 violation found by the LLM: C0 controls and DEL were not being encoded.


The "Q" encoder does not escape control characters, so they end up in the header verbatim. RFC 2047 restricts encoded-text to printable ASCII, and a raw CR or LF in a header is a header injection vector.

Unlike the previous three findings, this one reproduces on a default build with ext/mbstring present.

How to reproduce

poc.php
#!/usr/bin/env -S php
<?php declare(strict_types=1);
require_once 'Mail/mime.php';

// RFC 2047: encoded-text = 1*<Any printable ASCII character other than "?" or SPACE>
//
// Everything below has to be escaped as =XX for the encoded-word to stay legal.
// encodeQP() covers all of it except the two rows marked "not escaped":
//     \x00-\x1F  NUL, TAB, LF, CR, ... (C0 controls)   <-- not escaped
//     \x22-\x29  " # $ % & ' ( )
//     \x2C \x2E  , .
//     \x3A-\x40  : ; < = > ? @
//     \x5B-\x60  [ \ ] ^ _ `
//     \x7B-\x7E  { | } ~
//     \x7F       DEL                                   <-- not escaped
//     \x80-\xFF  every 8-bit byte
//
// SPACE (\x20) is absent from the list on purpose: encodeQP() rewrites it as "_",
// which RFC 2047 allows in place of "=20".
//
// A C0 control or DEL is not printable ASCII, so leaving one unescaped puts a byte
// into the header that no encoded-word is allowed to contain.

// Each case is "a" + "ä" + one control character + "b". The "ä" (\xC3\xA4 in
// UTF-8) is there only to make the value non-ASCII: a pure ASCII subject is
// emitted verbatim and never reaches the RFC 2047 encoder at all.
$cases = [
    'TAB' => "a\xC3\xA4\tb",                  // \x09  horizontal tab
    'LF'  => "a\xC3\xA4\nX-Injected: yes",    // \x0A  line feed, followed by what a header injection would look like
    'CR'  => "a\xC3\xA4\rb",                  // \x0D  carriage return
    'NUL' => "a\xC3\xA4\x00b",                // \x00  null
    'DEL' => "a\xC3\xA4\x7Fb",                // \x7F  delete
];

// Show control bytes as \xNN so they are visible in the terminal
function visible(string $value): string
{
    return preg_replace_callback('/[^\x20-\x7E]/', fn (array $m) => sprintf('\x%02X', ord($m[0])), $value);
}

// Decode the encoded-words back. Adjacent encoded-words split by folding whitespace decode without that whitespace.
function decode(string $value): string
{
    $value = preg_replace('/\?=\r\n =\?/', '?==?', $value);

    return preg_replace_callback(
        '/=\?[^?]+\?Q\?([^?]*)\?=/',
        fn (array $m) => quoted_printable_decode(str_replace('_', ' ', $m[1])),
        $value
    );
}

// One format for the header and the rows, so the two cannot drift apart
$format = '%-3s | %-52s | %s' . PHP_EOL;
printf($format, 'CHR', 'PROBLEM', 'ENCODED SUBJECT HEADER');
printf($format, str_repeat('-', 3), str_repeat('-', 52), str_repeat('-', 22));

foreach ($cases as $label => $subject) {
    $mime = new Mail_mime([
        'eol'           => "\r\n",
        'head_charset'  => 'UTF-8',
        'head_encoding' => 'quoted-printable',
    ]);

    $mime->setTXTBody('body');
    $mime->setSubject($subject);
    $mime->get();

    $headers = $mime->headers();
    $encoded = $headers['Subject'];

    $problem = '';

    // A byte outside printable ASCII inside the encoded-text is not a legal encoded-word
    preg_match_all('/=\?[^?]+\?Q\?([^?]*)\?=/', $encoded, $matches);
    foreach ($matches[1] as $text)
        if (preg_match('/[^\x21-\x3E\x40-\x7E]/', $text, $m))
            $problem = sprintf('raw \x%02X inside encoded-text', ord($m[0]));

    // Only worth asking about a header that is otherwise well-formed: the LF is swallowed as
    // encodeMB()'s internal chunk separator, so the output looks perfectly legal but lost a byte
    if (!$problem && decode($encoded) !== $subject)
        $problem = 'legal encoded-words, but a byte was silently dropped';

    printf($format, $label, $problem, visible($encoded));
}

Before

CHR | PROBLEM                                              | ENCODED SUBJECT HEADER
--- | ---------------------------------------------------- | ----------------------
TAB | raw \x09 inside encoded-text                         | =?UTF-8?Q?a=C3=A4\x09b?=
LF  | legal encoded-words, but a byte was silently dropped | =?UTF-8?Q?a=C3=A4?=\x0D\x0A =?UTF-8?Q?X-Injected=3A_yes?=
CR  | raw \x0D inside encoded-text                         | =?UTF-8?Q?a=C3=A4\x0Db?=
NUL | raw \x00 inside encoded-text                         | =?UTF-8?Q?a=C3=A4\x00b?=
DEL | raw \x7F inside encoded-text                         | =?UTF-8?Q?a=C3=A4\x7Fb?=

After

CHR | PROBLEM                                              | ENCODED SUBJECT HEADER
--- | ---------------------------------------------------- | ----------------------
TAB |                                                      | =?UTF-8?Q?a=C3=A4=09b?=
LF  |                                                      | =?UTF-8?Q?a=C3=A4=0AX-Injected=3A_yes?=
CR  |                                                      | =?UTF-8?Q?a=C3=A4=0Db?=
NUL |                                                      | =?UTF-8?Q?a=C3=A4=00b?=
DEL |                                                      | =?UTF-8?Q?a=C3=A4=7Fb?=

Root cause

The "Q" character class covers printable punctuation and every 8-bit byte, but not \x00-\x1F or \x7F. It was duplicated verbatim in encodeQP() and in encodeMB(), the second copy carrying a // see encodeQP() comment to point at the first:

$regexp = '/([\x22-\x29\x2C\x2E\x3A-\x40\x5B-\x60\x7B-\x7E\x80-\xFF])/';

RFC 2047 §2 defines encoded-text as 1*<Any printable ASCII character other than "?" or SPACE>, and §4.2(3) permits leaving a byte unescaped only when it is printable ASCII other than =, ? and _. A C0 control or DEL is neither, so any of them makes the encoded-word invalid.

The LF case has a second failure mode. encodeMB() uses "\n" as its own chunk separator and expands it into a fold at the end:

$result = $prefix . str_replace("\n", $suffix . $eol . ' ' . $prefix, $result) . $suffix;

A literal LF in the value is therefore indistinguishable from a chunk boundary: "aä\nX-Injected: yes" becomes two encoded-words and the newline is silently dropped from the decoded value. The output looks perfectly legal, which is why the poc checks the round trip separately.

Nothing in Mail/mime.php strips CR or LF before this point, so on a build without ext/mbstring the same input emits a raw LF straight into the header.

Fix

Add the two missing ranges; \x7B-\x7E, \x7F and \x80-\xFF then collapse into \x7B-\xFF. Since the class was duplicated, it moves into a constant so the two encoders cannot drift apart, the same treatment MAX_CHARSET_LENGTH got in #34:

/**
 * Characters that must be escaped as =XX inside an RFC 2047 "Q" encoded-word.
 *
 * RFC 2047 restricts encoded-text to printable ASCII other than "?" and SPACE,
 * so this covers the C0 controls (\x00-\x1F), DEL and every 8-bit byte, along
 * with the printable characters that are not safe inside a phrase. SPACE is
 * handled separately: encodeQP() rewrites it as "_", which RFC 2047 permits in
 * place of "=20".
 *
 * @internal
 * @var string
 */
const QP_ESCAPE_REGEXP = '/([\x00-\x1F\x22-\x29\x2C\x2E\x3A-\x40\x5B-\x60\x7B-\xFF])/';

The docblock is worth the space: without it the absence of \x20 reads as a third oversight rather than a deliberate choice, and a future reader could "fix" SPACE into the class and break the _ substitution.

Scope

tests/headers_with_mbstring.phpt needs its expectations regenerated, and the diff is worth a look, because case [31] was pinning this defect as correct output. That case encodes a Japanese subject to ISO-2022-JP, whose charset-switching escapes are ESC (\x1B) — and one of the JIS X 0208 bytes for those characters is \x0D. Neither was escaped, so the expected output contained 10 raw ESC bytes and a bare CR inside a Subject header, invisible unless viewed with cat -v:

$ git show HEAD:tests/headers_with_mbstring.phpt | grep -n $'\r' | cat -v
152:[31] Subject: =?ISO-2022-JP?Q?^[=24B-j^[=28B^[=24B=3B3^[=28B^[=24Byu^[=28B?=^M

That is the header injection case arising from an ordinary Japanese subject rather than a crafted one. After the fix:

[31] Subject: =?ISO-2022-JP?Q?=1B=24B-j=1B=28B=1B=24B=3B3=1B=28B?=
 =?ISO-2022-JP?Q?=1B=24Byu=1B=28B=1B=24B9=29=1B=28B=1B=24B6H=1B=28B?=

test_Bug_21205.phpt and test_Bug_20226.phpt also cover ISO-2022-JP but are untouched: they encode with base64, where ESC is base64-encoded regardless.

test_Bug_20273.phpt ("encodeHeader() and TAB character") also passes unchanged. Its value is a pure ASCII References header, so it never reaches encodeQP(): the TAB is consumed by explodeQuotedString()'s separator handling.

Test

tests/rfc2047_control_chars.phpt sweeps all 32 C0 control characters plus DEL, across both encodings, asserting that the encoded-text holds only printable ASCII and that the byte survives a round trip. It reports 34 failures against the current code and none with the fix.

No --INI-- section this time: the defect reproduces with ext/mbstring present, so the test exercises it on every CI job as-is.

AI use disclosure

I used Anthropic's Claude LLM with Opus 5 to find this bug. It also suggested a fix, which I have reviewed and tested.

The "Q" character class escaped printable punctuation and every 8-bit byte but
neither \x00-\x1F nor \x7F, so C0 controls and DEL were written into the header
verbatim. RFC 2047 defines encoded-text as printable ASCII other than "?" and
SPACE, so any of them makes the encoded-word invalid, and a raw CR or LF in a
header is a header injection vector.

A literal LF failed in a second way with ext/mbstring. encodeMB() uses "\n" as
its own chunk separator and expands it into a fold when assembling the result,
so an LF in the value was indistinguishable from a chunk boundary: the value
was split into two encoded-words and the byte silently disappeared from the
decoded header.

Add the two missing ranges, which lets \x7B-\x7E, \x7F and \x80-\xFF collapse
into \x7B-\xFF. The class was duplicated verbatim in encodeQP() and encodeMB(),
the second copy carrying a "see encodeQP()" comment, so it moves into
Mail_mimePart::QP_ESCAPE_REGEXP as 4216044 (pear#34) did for MAX_CHARSET_LENGTH.

tests/headers_with_mbstring.phpt pinned the defect as expected output and is
regenerated. Case [31] encodes a Japanese subject to ISO-2022-JP, whose
charset-switching escapes are ESC, and one of the JIS X 0208 bytes involved is
\x0D: the expectations held ten raw ESC bytes and a bare carriage return inside
a Subject header, invisible unless viewed with cat -v.

Unlike pear#33, pear#34 and pear#35 this reproduces with ext/mbstring present, so
tests/rfc2047_control_chars.phpt needs no --INI-- section. It sweeps all 32 C0
control characters plus DEL across both encodings, checking that the
encoded-text holds only printable ASCII and that the byte survives a round
trip. It reports 34 failures against the previous code.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant