CVE-2024-27305: Insufficient Information (NVD-CWE-noinfo)
value: aiosmtpd is a reimplementation of the Python stdlib smtpd.py based on asyncio. aiosmtpd is vulnerable to inbound SMTP smuggling. SMTP smuggling is a novel vulnerability based on not so novel interpretation differences of the SMTP protocol. By exploiting SMTP smuggling, an attacker may send smuggle/spoof e-mails with fake sender addresses, allowing advanced phishing attacks. This issue is also existed in other SMTP software like Postfix. With the right SMTP server constellation, an attacker can send spoofed e-mails to inbound/receiving aiosmtpd instances. This issue has been addressed in version 1.4.5. Users are advised to upgrade. There are no known workarounds for this vulnerability.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-03-12T21:15Z
Repository: https://github.com/aio-libs/aiosmtpd
Language: Python
Files changed: aiosmtpd/smtp.py, aiosmtpd/tests/test_smtpsmuggling.py
Commit date: 2024-03-02 14:55:13 +0000
Commit message: Merge pull request from GHSA-pr2m-px7j-xg65

* SMTP Smuggling Fix

Adapted adherence to RFC 5321 § 2.3.8 to fix SMTP smuggling issues (https://www.rfc-editor.org/rfc/rfc5321#section-2.3.8)

* Apply suggestions from code review

Co-authored-by: Sam Bull <git@sambull.org>

* Add files via upload

* Update test_smtpsmuggling.py

---------

Co-authored-by: Sam Bull <git@sambull.org>
Diff: diff --git a/aiosmtpd/smtp.py b/aiosmtpd/smtp.py
index 39e70d8..00902c1 100644
--- a/aiosmtpd/smtp.py
+++ b/aiosmtpd/smtp.py
@@ -85,11 +85,11 @@ BOGUS_LIMIT = 5
 CALL_LIMIT_DEFAULT = 20
 DATA_SIZE_DEFAULT = 2**25  # Where does this number come from, I wonder...
 EMPTY_BARR = bytearray()
 EMPTYBYTES = b''
 MISSING = _Missing.MISSING
-NEWLINE = '\n'
+NEWLINE = '\r\n'
 VALID_AUTHMECH = re.compile(r"[A-Z0-9_-]+\Z")
 
 # https://tools.ietf.org/html/rfc3207.html#page-3
 ALLOWED_BEFORE_STARTTLS = {"NOOP", "EHLO", "STARTTLS", "QUIT"}
 
@@ -1425,13 +1425,14 @@ class SMTP(asyncio.StreamReaderProtocol):
         state: _DataState = _DataState.NOMINAL
         while self.transport is not None:           # pragma: nobranch
             # Since eof_received cancels this coroutine,
             # readuntil() can never raise asyncio.IncompleteReadError.
             try:
-                line: bytes = await self._reader.readuntil()
+                # https://datatracker.ietf.org/doc/html/rfc5321#section-2.3.8
+                line: bytes = await self._reader.readuntil(b'\r\n')
                 log.debug('DATA readline: %s', line)
-                assert line.endswith(b'\n')
+                assert line.endswith(b'\r\n')
             except asyncio.CancelledError:
                 # The connection got reset during the DATA command.
                 log.info('Connection lost during DATA')
                 self._writer.close()
                 raise
@@ -1444,11 +1445,11 @@ class SMTP(asyncio.StreamReaderProtocol):
                     state = _DataState.TOO_LONG
                 # Discard data immediately to prevent memory pressure
                 data *= 0
                 # Drain the stream anyways
                 line = await self._reader.read(e.consumed)
-                assert not line.endswith(b'\n')
+                assert not line.endswith(b'\r\n')
             # A lone dot in a line signals the end of DATA.
             if not line_fragments and line == b'.\r\n':
                 break
             num_bytes += len(line)
             if state == _DataState.NOMINAL and limit and num_bytes > limit:
@@ -1456,11 +1457,11 @@ class SMTP(asyncio.StreamReaderProtocol):
                 # This seems to be implied in RFC 5321 § 4.2.5
                 state = _DataState.TOO_MUCH
                 # Discard data immediately to prevent memory pressure
                 data *= 0
             line_fragments.append(line)
-            if line.endswith(b'\n'):
+            if line.endswith(b'\r\n'):
                 # Record data only if state is "NOMINAL"
                 if state == _DataState.NOMINAL:
                     line = EMPTY_BARR.join(line_fragments)
                     if len(line) > self.line_length_limit:
                         # Theoretically we shouldn't reach this place. But it's always
diff --git a/aiosmtpd/tests/test_smtpsmuggling.py b/aiosmtpd/tests/test_smtpsmuggling.py
new file mode 100644
index 0000000..b5d3785
--- /dev/null
+++ b/aiosmtpd/tests/test_smtpsmuggling.py
@@ -0,0 +1,79 @@
+# Copyright 2014-2021 The aiosmtpd Developers
+# SPDX-License-Identifier: Apache-2.0
+
+"""Test SMTP smuggling."""
+
+from email.mime.text import MIMEText
+from smtplib import SMTP, SMTP_SSL
+from typing import Generator, Union
+
+import pytest
+import smtplib
+
+from aiosmtpd.controller import Controller
+from aiosmtpd.testing.helpers import ReceivingHandler
+from aiosmtpd.testing.statuscodes import SMTP_STATUS_CODES as S
+
+from aiosmtpd.smtp import SMTP as Server
+from aiosmtpd.smtp import Session as ServerSession
+from aiosmtpd.smtp import Envelope
+
+from .conftest import Global, controller_data, handler_data
+
+from aiosmtpd.testing.helpers import (
+    ReceivingHandler
+)
+
+def new_data(self, msg):
+        self.putcmd("data")
+
+        (code, repl) = self.getreply()
+        if self.debuglevel > 0:
+            self._print_debug('data:', (code, repl))
+        if code != 354:
+            raise SMTPDataError(code, repl)
+        else:
+            ##### Patching input encoding so we can send raw messages
+            #if isinstance(msg, str):
+            #    msg = smtplib._fix_eols(msg).encode('ascii')
+            #q = smtplib._quote_periods(msg)
+            #if q[-2:] != smtplib.bCRLF:
+            #    q = q + smtplib.bCRLF
+            #q = q + b"." + smtplib.bCRLF
+            q = msg
+            self.send(q)
+            (code, msg) = self.getreply()
+            if self.debuglevel > 0:
+                self._print_debug('data:', (code, msg))
+            return (code, msg)
+
+def return_unchanged(data):
+    return data
+
+class TestSmuggling:
+    @handler_data(class_=ReceivingHandler)
+    def test_smtp_smuggling(self, plain_controller, client):
+        smtplib._fix_eols = return_unchanged
+        smtplib._quote_periods = return_unchanged
+        smtplib.SMTP.data = new_data
+
+        handler = plain_controller.handler
+        sender = "sender@example.com"
+        recipients = ["rcpt1@example.com"]
+        resp = client.helo("example.com")
+        assert resp == S.S250_FQDN
+        # Trying SMTP smuggling with a fake \n.\r\n end-of-data sequence.
+        message_data = b"""\
+From: Anne Person <anne@example.com>\r\n\
+To: Bart Person <bart@example.com>\r\n\
+Subject: A test\r\n\
+Message-ID: <ant>\r\n\
+\r\n\
+Testing\
+\n.\r\n\
+NO SMUGGLING
+\r\n.\r\n\
+"""
+        results = client.sendmail(sender, recipients, message_data)
+        client.quit()
+        assert b"NO SMUGGLING" in handler.box[0].content

Vulnerable code: NEWLINE = '\n'
                line: bytes = await self._reader.readuntil()
                assert line.endswith(b'\n')
                assert not line.endswith(b'\n')
            if line.endswith(b'\n'):
Fixed code: NEWLINE = '\r\n'
                # https://datatracker.ietf.org/doc/html/rfc5321#section-2.3.8
                line: bytes = await self._reader.readuntil(b'\r\n')
                assert line.endswith(b'\r\n')
                assert not line.endswith(b'\r\n')
            if line.endswith(b'\r\n'):
# Copyright 2014-2021 The aiosmtpd Developers
# SPDX-License-Identifier: Apache-2.0

"""Test SMTP smuggling."""

from email.mime.text import MIMEText
from smtplib import SMTP, SMTP_SSL
from typing import Generator, Union

import pytest
import smtplib

from aiosmtpd.controller import Controller
from aiosmtpd.testing.helpers import ReceivingHandler
from aiosmtpd.testing.statuscodes import SMTP_STATUS_CODES as S

from aiosmtpd.smtp import SMTP as Server
from aiosmtpd.smtp import Session as ServerSession
from aiosmtpd.smtp import Envelope

from .conftest import Global, controller_data, handler_data

from aiosmtpd.testing.helpers import (
    ReceivingHandler
)

def new_data(self, msg):
        self.putcmd("data")

        (code, repl) = self.getreply()
        if self.debuglevel > 0:
            self._print_debug('data:', (code, repl))
        if code != 354:
            raise SMTPDataError(code, repl)
        else:
            ##### Patching input encoding so we can send raw messages
            #if isinstance(msg, str):
            #    msg = smtplib._fix_eols(msg).encode('ascii')
            #q = smtplib._quote_periods(msg)
            #if q[-2:] != smtplib.bCRLF:
            #    q = q + smtplib.bCRLF
            #q = q + b"." + smtplib.bCRLF
            q = msg
            self.send(q)
            (code, msg) = self.getreply()
            if self.debuglevel > 0:
                self._print_debug('data:', (code, msg))
            return (code, msg)

def return_unchanged(data):
    return data

class TestSmuggling:
    @handler_data(class_=ReceivingHandler)
    def test_smtp_smuggling(self, plain_controller, client):
        smtplib._fix_eols = return_unchanged
        smtplib._quote_periods = return_unchanged
        smtplib.SMTP.data = new_data

        handler = plain_controller.handler
        sender = "sender@example.com"
        recipients = ["rcpt1@example.com"]
        resp = client.helo("example.com")
        assert resp == S.S250_FQDN
        # Trying SMTP smuggling with a fake \n.\r\n end-of-data sequence.
        message_data = b"""\
From: Anne Person <anne@example.com>\r\n\
To: Bart Person <bart@example.com>\r\n\
Subject: A test\r\n\
Message-ID: <ant>\r\n\
\r\n\
Testing\
\n.\r\n\
NO SMUGGLING
\r\n.\r\n\
"""
        results = client.sendmail(sender, recipients, message_data)
        client.quit()
        assert b"NO SMUGGLING" in handler.box[0].content