PVD-20260515-9FE6: Header Injection via Unfiltered Carriage Return in GNATcoll.Email

Fixed by Vendor
← All Disclosures

Summary

The GNATCOLL.Email module of gnatcoll-core splices caller-supplied String values into MIME header bodies by direct concatenation. New_Message and Attach build Content-Type and Content-Disposition values out of the caller's MIME_Type, Charset, Description, Recommended_Filename, and Path with no escaping and no rejection of control characters.

The serialiser did strip newlines when flattening a header onto one line, but its loop tested for ASCII.LF only. A standalone ASCII.CR passed through untouched and reached the wire. RFC 5322 forbids a bare CR in a header, but lenient downstream Message Transfer Agents accept it as a header separator, so an attacker who controls any of those parameters can append headers of their own: a silent Bcc recipient, a Reply-To pivot, or a spliced MIME part (CWE-93, improper neutralization of CRLF sequences).

Exposure is anywhere a message is assembled from values the attacker influences and then sent: notification mail carrying user-supplied attachment filenames, bug-tracker and forum integrations that attach uploaded files, and any tool that pipes a gnatcoll-email message through sendmail or a smarthost.

Affected: gnatcoll-core-src < 26.2 (AdaCore); reported against gnatcoll-core-src 24.0 and reproduced by us on Alire crate gnatcoll 24.0.0 and upstream master commit 3f595a4.
Fixed: gnatcoll-core-src >= 26.2; upstream commit 46e3c46d, merged to master on 2026-05-28. AdaCore reports no workaround for affected versions.

Demonstration

The whole proof is one parameter. A caller passes a Charset containing a bare CR followed by a header of the attacker's choosing:

with Ada.Text_IO;
with Ada.Strings.Unbounded; use Ada.Strings.Unbounded;
with GNATCOLL.Email;

procedure Header_Injection is
   use GNATCOLL.Email;
   Msg     : Message;
   Out_Str : Unbounded_String;
begin
   Msg := New_Message
     (MIME_Type => "multipart/mixed",
      Charset   => "utf-8" & ASCII.CR & "X-Injected: yes");

   To_String (Msg, Out_Str, Filter => null);
   Ada.Text_IO.Put_Line (To_String (Out_Str));
end Header_Injection;

The serialised message carries the carriage return straight through into the Content-Type value:

Content-Type: multipart/mixed; charset="utf-8\rX-Injected: yes"

An MTA that treats the bare CR as a line break reads X-Injected: yes as a header of its own. Substituting Bcc: attacker@example.com for the marker turns the same primitive into a silent extra recipient; a value beginning \r--boundary splices an additional MIME part into a multipart body.

The same lever is reachable through Attach via MIME_Type, Description, and the attachment filename. Base_Name is applied to Path and Recommended_Filename, but it strips directory separators only; control characters survive it.

How Pragmatic Found It

Pragmatic's HeaderInjection checker (CWE-93) looks for message-header construction sites whose value is assembled by concatenation from an untrusted operand — a formal parameter of the enclosing subprogram, or a local initialised from a source routine — with no CR/LF/NUL rejection anywhere between the caller and the header. Pointed at the mail module, it reports every affected construction site:

$ ./bin/pragmatic -E=HeaderInjection -e=adb \
    gnatcoll-core/core/src/gnatcoll-email.adb

CWE,Checker,Filename,Line,Tool,Comments,...
93,HeaderInjection,gnatcoll-core/core/src/gnatcoll-email.adb,201,
Pragmatic Scanner 1.1.0,"Message header built by ""Create"" concatenates untrusted
input into the header value with no CR/LF/NUL rejection. A bare CR or LF terminates
the header for lenient MTAs and mail clients, letting an attacker append headers of
their own (Bcc, Reply-To) or splice an extra MIME part. Reject CR, LF and NUL in the
value at construction time rather than filtering during serialization; note that
Base_Name and similar helpers strip path separators, not control
characters.",vulnerability,medium,high,heuristic,0dde18748cc9bafc8910b2326694fe07

  ... and the same finding at lines 1739, 1756, 1763, 1773 and 1780.

Six sites, one in New_Message and five in Attach. Line 201 is representative:

Create ("Content-Type",
        MIME_Type & "; charset=""" & Charset & '"');

Two things make the finding precise rather than noisy. Create is an ambiguous name — it is also Ada.Text_IO.Create, Ada.Directories and GNATCOLL.VFS — so the checker treats it as a header sink only when its first argument names a header field, here the literal "Content-Type". And the four attachment-filename sites reach the header through Base_Name, which strips directory separators but not control characters; the checker propagates taint through helpers of that kind instead of accepting them as sanitizers.

Reading the six flagged sites answers the question the checker raises: nothing between the caller and the header value validates anything. That points at the serialiser, where the defect itself lives — the loop that flattens a header onto one line:

for J in Str'Range loop
   if Str (J) = ASCII.LF then
      Offset := Offset + 1;
   elsif Offset > 0 then
      Str (J - Offset) := Str (J);
   end if;
end loop;

The loop compacts the string in place, skipping ASCII.LF. ASCII.CR is not in the test, so it is copied like any ordinary byte. That single missing character is the vulnerability: LF is filtered, CR is not, and CR alone is enough for a lenient MTA.

The checker also tracks the remediation. It performs same-unit callee analysis, so once a constructor rejects CR, LF and NUL in one of its own formals, its call sites stop being reported. Run against the fixed tree, where Create calls Assert_No_Forbidden, it reports nothing — the vulnerable and remediated versions are distinguished even though the six call sites are textually identical in both.

The Fix

Commit 46e3c46d (“email: prevent header injection and filter evasion”, Vincent Jicquel, 2026-05-26, merged to master 2026-05-28) rejects the bad input at construction time rather than filtering it at serialisation time, and covers more than was reported:

NUL was added on AdaCore's own initiative and was not part of our report. It truncates strings in C-based MTAs, which makes it a spam-filter evasion primitive distinct from the header injection we described.

Acknowledgements

Our thanks to AdaCore, who acknowledged this report the day it was sent, committed a fix eleven days later, and merged it within two weeks. Particular thanks to Vincent Jicquel, whose fix went beyond the reported defect to close the NUL-truncation vector and the envelope-parser path, and who landed it with a dedicated security test suite rather than a spot patch; and to Thomas Serabian and Frédéric Léger, who authored and reviewed the public advisory.

Severity

AdaCore — CVSS 3.1 Base Score: 6.5 (Medium)

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N

A crafted email need only be transferred by a vulnerable MTA, with no user interaction once it is created (AV:N, UI:N). The impact is bounded: the message body cannot be rewritten, but the recipient list can be (I:L), and some restricted information is exposed to an attacker who does not control what they receive (C:L).

References

Disclosure Timeline