Research/cakephp-smtp-crlf
0-dayNo CVEHighPublic

CakePHP 5.4.2, SmtpTransport lone LF leftover of GHSA-2qh5

GHSA-2qh5 deleted the two-byte CRLF pair from custom mail headers. A lone LF or CR is left in place. SmtpTransport SMTP DATA treats it as a new Bcc line. Labbed 5.4.2. No CVE yet.

Name
CakePHP 5.4.2, SmtpTransport lone LF leftover of GHSA-2qh5
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
CakePHP
Affected
CakePHP 5.4.2 (887b641) SmtpTransport / MailTransport. Same preconditions as GHSA-2qh5: app copies user data into Message::setHeaders. Stock cakephp/app does not do that by itself.
Published
12 Oct 2026
Updated
12 Oct 2026
Tags
0-day, cakephp, crlf, header-injection, cwe-93, smtp

The pair-only strip was the leftover

I am @abraxas_null. The proof of concept is on GitHub: abraxas/cakephp-smtp-crlf (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, send.php, smtp_sink.py, composer.json. Authorized lab only. It talks to loopback.

This is CakePHP 5.4.2 (887b641de9f7aba06071f98f17edcbc573cbcf63). No CVE yet. CWE-93. 7.5 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N). Remote mail header injection. Same preconditions as GHSA-2qh5.

GHSA-2qh5 / CVE-2026-77634 added str_replace("\r\n", '', $val) on SmtpTransport and MailTransport. That callback is a no-op for a bare LF or CR. Header names are still concatenated unsanitized. PHP mail() and the SMTP DATA we captured treat a bare LF as a new header line.

The transport tests only send "line one\r\nline two" and expect line oneline two. The announced fix holds for the pair. It does not hold for the byte.

Stock cakephp/app does not put request data into custom headers by itself. This is a framework leftover. Same victim model as the GHSA.

Why CakePHP

I had just finished unpublished Crit/High hunts of larger trees. CakePHP 5.4.2 is a framework pin after a 2026 GHSA wave: FunctionsBuilder SQLi, jsonValue SQLi, SmtpTransport CRLF, View element path, Paginator XSS, Query limit/offset, RateLimit ipHeader, multipart filename=. That is a good day to sit on the tag anyway and look for pair-only strips.

I only wanted Critical or High. Local is in-scope. Medium stays on the floor unless it chains. This is a framework: each finding needs an app that uses the default or documented API. Skip "pass user SQL into query()".

Pinning 5.4.2

Latest calendar tag. 887b641. VERSION.txt = 5.4.2. Composer cakephp/cakephp:5.4.2 in PHP 8.3 for this lab.

Intake is security@cakephp.org. SECURITY.md on the tag.

The skip list is half the hunt

On this pin the interesting ones were already closed:

  • GHSA-vjqc / CVE-2026-79752 FunctionsBuilder cast/extract/datePart/dateAdd SQLi. ensureSimpleString().
  • GHSA-fxf7 / CVE-2026-77635 jsonValue() Postgres SQLi. Path bound, not interpolated.
  • GHSA-2qh5 / CVE-2026-77634 SmtpTransport CRLF in custom headers. Incomplete. Pair only. This leftover.
  • GHSA-wpvj / CVE-2026-48820 View::element() path. _checkFilePath().
  • GHSA-qh8m / CVE-2026-23643 PaginatorHelper::limitControl() XSS. h($fieldName).
  • GHSA-6g8q Query limit/offset SQLi. Typed int.
  • 5.4.1 bakery RateLimitMiddleware default ipHeader x-forwarded-for. Default is now remote_addr.
  • 5.4.2 notes multipart Content-Disposition filename= injection. FormDataPart::_headerParameterToString strips "/\r/\n.

cakephp/authentication GHSA-hhpq (open redirect) is a different repo. Core Routing has no matching backslash bypass.

Hunts that returned NONE in this bar: CSRF / FormProtection / _method override; View LFI siblings; BodyParser XXE (XML parser off by default, loadEntities false); mass assignment (documented $_accessible = ['*' => true]); helper XSS leftovers; clientIp() HTTP_CLIENT_IP without trustProxy (LegalHackers, already gated).

I did not re-file any of that.

Ten surfaces, then the pair

I split the tree. Unserialize/cache/cookies, CSRF/FormProtection, View/LFI/files, remaining SQLi, mail CRLF siblings, IP/Host/proxy/SSRF, XXE/BodyParser, mass-assignment/crypto, XSS/helpers/routing, Http Client/cookie jar.

No unpublished unauthenticated RCE on a stock cakephp/app skeleton. The leftovers that survived need an app that uses the documented API.

This pack is leftover 1: Smtp/Mail transport CRLF strip is pair-only. Sibling packs on the same pin: cookie-suffix, trusted-proxies. Attachment Content-Type / Content-ID CRLF is Medium leftover 6 after the filename= fix. Cache unserialize is a different class.

SmtpTransport 5.4.2:

PHP
$headers = $message->getHeadersString(
    [...],
    "\r\n",
    function (string $val): string {
        return str_replace("\r\n", '', $val);
    },
);

MailTransport uses the same callback. Message::getHeadersString maps the callback over values, then does $key . ': ' . $val.

If an app copies user data into a custom header, an attacker can add Bcc, rewrite Subject, or break into the body by sending LF instead of CRLF.

What I almost filed instead

Treating GHSA-2qh5 as closed and walking away. The tests cover the pair. The leftover is the byte.

Calling PHP mail() in the lab. The oracle is SMTP DATA plus getHeadersString. MailTransport is proven on the header string, not on a real sendmail.

Inflating this to unauth RCE on a skeleton app. Stock app does not put request data into custom headers.

Multipart filename= as this leftover. 5.4.2 already strips that. Attachment Content-Type / Content-ID is leftover 6, Medium, a different pack.

Cake's RCPT TO using only setTo / setCc / setBcc on the Message object. Injected Bcc inside DATA is inferred extra envelope on some MTAs, not a change to Cake's envelope.

The lab

Composer cakephp/cakephp:5.4.2 in PHP 8.3. Loopback SMTP sink at 127.0.0.1:19410.

  1. Control: X-Meta = ok\r\nBcc: lab@localhost.invalid\r\nX-Oracle: CAKEPHP-SMTP-CRLF-WITNESS. Result: one header X-Meta: okBcc: lab@localhost.invalidX-Oracle: CAKEPHP-SMTP-CRLF-WITNESS.
  2. Case LF: same payload with \n only. Result: new Bcc: and X-Oracle: lines in SMTP DATA, Smtp getHeadersString, and Mail getHeadersString.
  3. Case CR: same with \r only. Same extra lines.

Replay:

Plain text
git clone https://github.com/abraxas/cakephp-smtp-crlf
cd cakephp-smtp-crlf/lab
./run.sh

Bind it to loopback. The loopback client is lab/run.sh with a banner on it.

Plain text
SUCCESS cakephp-smtp-crlf pin=5.4.2 control-crlf-stripped=yes lf-bcc=yes cr-leftover=yes how=smtp-data-bcc-line CAKEPHP-SMTP-CRLF-WITNESS

Wrong turns, in the order I made them

Pair-only tests as coverage. "line one\r\nline two" is the GHSA. Lone LF is the leftover.

mail() as the oracle. SMTP DATA is enough. MailTransport getHeadersString is the sibling.

Skeleton app as the victim. Framework leftover. Same as GHSA-2qh5.

A reverse shell. Theatre. The oracle is a new Bcc: line.

What an attacker can do

Needs an application that puts request data into custom mail headers.

Privilege Method / sink Lab-proven or inferred
Inject an extra Bcc: (or Cc: / To:) line into the SMTP DATA header block setHeaders(['X-Meta' => "ok\nBcc: lab@localhost.invalid"]) then SmtpTransport::send Lab-proven. SMTP DATA and getHeadersString both show a new Bcc: line. Witness X-Oracle: CAKEPHP-SMTP-CRLF-WITNESS
Same injection through MailTransport Same callback in MailTransport::send Lab-proven on getHeadersString. This lab does not call PHP mail()
Lone CR as well as lone LF ok\rBcc: ... Lab-proven. Pair-only strip leaves \r
Rewrite Subject or break into the body Value contains \nSubject: or \n\n Inferred from the same concatenation. Lab injected Bcc / X-Oracle, not Subject
Extra envelope recipient Injected Bcc inside DATA Inferred. Some MTAs re-parse DATA. Cake's RCPT TO still uses the Message object
Two-byte \r\n pair GHSA-2qh5 strip Lab-proven closed. Control concatenates okBcc:...
Unauthenticated RCE on a skeleton app not this leftover Closed
Multipart filename= CRLF 5.4.2 already strips "/\r/\n Closed

The fix

Strip or reject any \r or \n in header names and values (same as the 5.4.2 filename= sanitizer). Cover lone LF and lone CR in the transport tests, not only the \r\n pair.

Until then, do not copy request data into Message::setHeaders / addHeaders.

I am not going to print a header value you can paste at someone else's contact form. The missing byte strip is the useful part. If you own the app, run the proof of concept against loopback.

References