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.
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.
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.
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:
Forbidden_Character exception is declared in gnatcoll-email.ads, raised whenever CR, LF, or NUL appears in a user-supplied header value or address.Assert_No_Forbidden guard is called from every construction entry point: both Set_Envelope_From overloads, Create (Name, Value : String), Append (H, Value : String), and Set_Param. Bad values now fail loudly at the call rather than silently reaching the wire.Strip_Forbidden replaces the hand-rolled LF-only compaction loop inside To_String, removing all three characters. It is retained as a safety net for content that arrived through the parser and so never passed the assertion.RTrim_CR to every header value but had missed the envelope From line, which could carry a trailing CR under CRLF line endings.testsuite/core/tests/email/email_security/, covers LF, CR, CRLF, and NUL across envelope addresses, raw envelope lines, header values via all three construction paths, and full message serialisation.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.
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.
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).
46e3c46d, merged to master 2026-05-28.HeaderInjection (CWE-93), available in Pragmatic 1.1.0 and later.master (3f595a48).46e3c46d), adding the Forbidden_Character exception and the Assert_No_Forbidden guard.master; AdaCore wavefront builds fixed from this date.CAN-2026-2032501.