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:
- A new
Forbidden_Characterexception is declared ingnatcoll-email.ads, raised whenever CR, LF, or NUL appears in a user-supplied header value or address. - A new
Assert_No_Forbiddenguard is called from every construction entry point: bothSet_Envelope_Fromoverloads,Create (Name, Value : String),Append (H, Value : String), andSet_Param. Bad values now fail loudly at the call rather than silently reaching the wire. Strip_Forbiddenreplaces the hand-rolled LF-only compaction loop insideTo_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.- The parser is corrected in parallel: it already applied
RTrim_CRto every header value but had missed the envelopeFromline, which could carry a trailing CR under CRLF line endings. - A new test suite,
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.
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
- CAN-2026-2032501 — temporary identifier assigned by AdaCore. A CVE has been requested; the permanent CVE ID is pending assignment by MITRE.
- AdaCore Security Advisory SEC.GNATCOLL-CORE-0164, version 1 (2026-06-30) — Single carriage returns are not filtered out by gnatcoll emails (PDF). Issued under embargo to AdaCore customers until 2026-08-13.
- Upstream fix — gnatcoll-core commit
46e3c46d, merged tomaster2026-05-28. - Pragmatic checker —
HeaderInjection(CWE-93), available in Pragmatic 1.1.0 and later.
Disclosure Timeline
- 2026-05-14: Issue discovered by Pragmatic static-analysis scanner and triaged by hand.
- 2026-05-14: PoC reproduced and the cited pattern verified present on upstream
master(3f595a48). - 2026-05-15: PVD ID reserved and report sent to AdaCore product security.
- 2026-05-15: AdaCore acknowledged the report.
- 2026-05-26: Fix committed upstream (
46e3c46d), adding theForbidden_Characterexception and theAssert_No_Forbiddenguard. - 2026-05-28: Fix merged to
master; AdaCore wavefront builds fixed from this date. - 2026-06-30: AdaCore issued advisory SEC.GNATCOLL-CORE-0164 v1 to customers under embargo, assigning
CAN-2026-2032501. - 2026-08-13: AdaCore embargo lifted; advisory publicly available.