//nefariousplan

CVE-2026-41472: The Commit CyberPanel Cited as the Fix Does Not Touch the AI Scanner

patterns

cve

proof of concept

The CVE record for CVE-2026-41472 names commit 0a099b1b193946555fbdd387a28486b1521f9961 as the version that is unaffected. The commit is a merge dated December 18, 2025. It modifies two files: plogical/acl.py and websiteFunctions/website.py. Neither file is involved in the bug. The bug lives in aiScanner/api.py, aiScanner/templates/aiScanner/scanner.html, and CyberCP/secMiddleware.py. The current stable branch is 2.4.5. None of those three files has been modified since the bug was disclosed. The commit landed in 2.4.4. The fix did not.

The advisory says the fix is in 2.4.4

CVE-2026-41472 was published on April 24, 2026. The bug, paraphrased from the published description:

CyberPanel versions prior to 2.4.4 contain a stored cross-site scripting vulnerability in the AI Scanner dashboard. The endpoint POST /api/ai-scanner/callback is unauthenticated. An unauthenticated request overwrites the findings_json field of a ScanHistory record with arbitrary JavaScript. When the administrator opens the AI Scanner dashboard, the payload executes in their authenticated session, issues a same-origin request to /websites/addNewCron, plants a cron job, and reaches remote code execution on the server.

The cve.org affected block points downstream consumers at a specific commit:

versionType: semver
version: 0
lessThan:  2.4.4
status:    affected

version:     0a099b1b193946555fbdd387a28486b1521f9961
versionType: git
status:      unaffected

Vulnerability tooling that ingests this record concludes that any install at or after 0a099b1b is patched. The CVSS metadata, the description, and the cited unaffected commit are the structured statement that 2.4.4 ships the fix. CyberPanel, the cve.org record, the NVD, and VulnCheck's advisory all repeat the same hash.

What commit 0a099b1b actually contains

commit 0a099b1b193946555fbdd387a28486b1521f9961
Merge: 94bf2bde a2f9cf99
Author: usmannasir <[email protected]>
Date:   Thu Dec 18 13:10:48 2025 +0500

    Merge branch 'v2.4.4' of github.com:usmannasir/cyberpanel into v2.4.4

--- a/plogical/acl.py
+++ b/plogical/acl.py
@@ -761,6 +761,8 @@ class ACLManager:
             else:
                 if childDomain.master.admin.owner == admin.pk:
                     return 1
+                else:
+                    return 0

         except:
             domainName = Websites.objects.get(domain=domain)

--- a/websiteFunctions/website.py
+++ b/websiteFunctions/website.py
@@ -2519,11 +2519,12 @@ Require valid-user
             for web in websites:
-                for child in web.childdomains_set.filter(alais=0):
-                    if child.domain == f'mail.{web.domain}':
-                        pass
-                    else:
-                        childDomains.append(child)
+                for child in web.childdomains_set.all():
+                    if child.alais == 0:
+                        if child.domain == f'mail.{web.domain}':
+                            pass
+                        else:
+                            childDomains.append(child)

Two changes. The first adds a missing return 0 to an ACL ownership check that previously fell through silently. The second restructures a child-domain iteration so the filter happens in Python rather than at the ORM. Both fix an ACL bug for non-admin users managing child domains, tracked under a separate report. Neither is the AI Scanner.

The merge brings in one further commit on the parent line, labeled security fixes:

commit eca0c3cbeb35af8eaae9fafb094e8ef3cd923643
Author: usmannasir <[email protected]>
Date:   Thu Dec 18 12:18:32 2025 +0500

    security fixes

 backup/backupManager.py  | 95 +++++++++++++++++++++++++++++++++++++++++-------
 filemanager/views.py     | 49 +++++++++++++++++++++++++
 plogical/remoteBackup.py | 34 +++++++++++++++--

Backup manager. File manager. Remote backup. None of them is the AI Scanner.

Traversing both parents of the cited merge, no commit in 2.4.4's lineage modifies aiScanner/api.py, aiScanner/views.py, aiScanner/status_api.py, aiScanner/templates/aiScanner/scanner.html, or CyberCP/secMiddleware.py. The cited fix is a fix for a different bug.

What the actual bug is

/api/ai-scanner/callback is wired in api/urls.py:

re_path(r'^ai-scanner/callback$', views.aiScannerCallback, name='aiScannerCallbackAPI'),

aiScannerCallback delegates to aiScanner/api.py:scan_callback:

@csrf_exempt
@require_http_methods(['POST'])
def scan_callback(request):
    """
    Receive scan completion callbacks from AI Scanner platform

    POST /api/ai-scanner/callback
    """
    try:
        data = json.loads(request.body)

        scan_id  = data.get('scan_id')
        status   = data.get('status')
        summary  = data.get('summary', {})
        findings = data.get('findings', [])
        ...

        scan_record = ScanHistory.objects.get(scan_id=scan_id)
        scan_record.status = status
        ...
        scan_record.set_findings(findings)
        ...
        scan_record.save()

There is no session check. There is no API key check. There is no HMAC over the body. There is no source-IP gate. The handler reads attacker-controlled JSON and writes it into a ScanHistory row keyed by an attacker-supplied scan_id.

set_findings serialises the input straight to the database column the dashboard reads:

def set_findings(self, findings_list):
    """Set findings from list/dict"""
    self.findings_json = json.dumps(findings_list)

When an administrator next opens the AI Scanner dashboard's scan-details view (which is gated by session and ACL), the front-end fetches the stored findings and renders them via JavaScript template literal:

scan.findings.forEach(finding => {
    const severityClass = finding.severity === 'high'   ? 'danger'
                        : finding.severity === 'medium' ? 'warning'
                        : 'info';

    findingsHtml += `
        <div ...>
            <h6 ...>
                <span class="status-label ${severityClass}" ...>${finding.severity}</span>
                ${finding.title}
            </h6>
            <p ...>${finding.description}</p>
            ${finding.file ? `<small ...>File: ${finding.file}</small>` : ''}
        </div>
    `;
});
...
document.getElementById('completedSection').innerHTML = `... ${findingsHtml} ...`;

finding.title, finding.description, and finding.file interpolate directly into HTML, and the result is assigned to .innerHTML. The CVE description calls this stored XSS. The exit from the dashboard into the host is the same-origin POST to /websites/addNewCron under the admin's session: cron jobs run as the website user, and the payload writes its own.

Why the callback is unauthenticated by structure

CyberCP/secMiddleware.py opens with an unconditional bypass:

if pathActual == "/backup/localInitiate" or pathActual == '/' or pathActual == '/verifyLogin' or pathActual == '/logout' or pathActual.startswith('/api')\
        or webhook_pattern.match(pathActual) or pathActual.startswith('/cloudAPI'):
    pass
else:
    try:
        val = request.session['userID']
    except:
        ...

Any URL whose path starts with /api skips the session check at the middleware layer. The line has been there since the initial public commit on August 1, 2025 (6dd7114f). Every handler under /api/* is responsible for its own authentication. The AI Scanner's callback handlers do not perform any.

This is the structural reason the bug has the shape it has. The author wrote @csrf_exempt because the external scanner platform calling in will not carry a Django CSRF token, and assumed the same exemption covered the same threat model the CSRF protection covers. It does not. CSRF protects authenticated state-changing actions from cross-origin abuse; authentication is what protects state-changing actions from arbitrary unauthenticated callers. Removing the first did not establish the second.

The sibling endpoint /api/ai-scanner/status-webhook lands in aiScanner/status_api.py:receive_status_update with the same shape:

@csrf_exempt
@require_http_methods(['POST'])
def receive_status_update(request):
    ...
    status_update, created = ScanStatusUpdate.objects.update_or_create(
        scan_id=scan_id,
        defaults={
            'phase':                data.get('phase', ''),
            ...
            'current_file':         data.get('current_file', ''),
            'activity_description': data.get('activity_description', ''),
            ...
        }
    )

current_file and activity_description are attacker-controlled strings that render into the live-progress dashboard. Same primitive, different table. The update_or_create accepts any scan_id; the attacker no longer needs to know an existing one to seed an XSS payload that the admin's progress view will render.

The current stable still ships the bug

CyberPanel's stable branch as of March 6, 2026 is at version 2.4.5. version.txt:

{"version":"2.4","build":5}

The git log for each vulnerable file on stable:

$ git log --oneline -- aiScanner/api.py | head -5
37cc3e42 Increase rate limits for bulk fix operations from platform
2cd361a8 Fix backup file operation failures for API key authentication
af35aadb Fix CyberPanel API key validation for platform callbacks
cfeac425 Add X-API-Key header support for AI Scanner file operations
a60c4827 Add debug logging for API key validation
$ git log --oneline -- aiScanner/status_api.py
6dd7114f Initial commit for v2.4.3
$ git log --oneline -- aiScanner/templates/aiScanner/scanner.html
1b045eb2 enable dark mode
6dd7114f Initial commit for v2.4.3
$ git log --oneline -- CyberCP/secMiddleware.py | head -3
e65109fe Fix security middleware to allow file content in API endpoints
6dd7114f Initial commit for v2.4.3

aiScanner/status_api.py has not been modified since it was first committed. aiScanner/templates/aiScanner/scanner.html has been touched once, for dark mode. CyberCP/secMiddleware.py's most recent change, on October 26, 2025, expanded the /api/* bypass list to additionally skip command-injection validation on fields named content and fileContent. The /api/* line at the top of the middleware is untouched.

The X-API-Key support in aiScanner/api.py (commit cfeac425, October 27) added authentication to file-operation endpoints like /api/scanner/replace-file. It did not add authentication to /api/ai-scanner/callback. A follow-up commit the same day (af35aadb, "Fix CyberPanel API key validation for platform callbacks") then relaxed the new check, with the commit message stating the intent in the open:

Get the scan, don't require it to belong to the same admin (platform may be using any valid CyberPanel API key for file operations)

Between December 16, 2025 (the date the bug was reported to the vendor) and May 16, 2026, the number of commits on the public stable branch that touch aiScanner/api.py, aiScanner/status_api.py, aiScanner/views.py, aiScanner/templates/aiScanner/scanner.html, or CyberCP/secMiddleware.py to add authentication to the callback path or to escape attacker input in the dashboard is zero.

Two researcher attributions, one timeline that does not add up

VulnCheck's advisory credits "Djibril Mounkoro." A separate technical writeup at itsrez.re/post/cyberpanel-rce describes the same chain and credits "itsrez (in collaboration with Mcsam)." The itsrez writeup includes a coordinated disclosure timeline: reported December 16, 2025; acknowledged December 17; fixes committed December 19; user notification January 1, 2026; public disclosure January 18.

The cited commit 0a099b1b is dated December 18, between the reported date and the writeup's claimed fix-committed date. The git log on the public stable branch for that week shows three commits: eca0c3c (security fixes, touching backup and file manager), 94bf2bd (a merge), and 0a099b1b (the cited merge). No commit on December 19 touches the AI Scanner. The itsrez writeup describes the patch as "added explicit HTML escaping using Django's |escape filter on all user-controlled scan result fields" and "implemented API key validation against database records." That code is not in the public tree.

Two researcher attributions on the same bug. One published writeup that describes a patch that does not exist on the public branch. A CVE record whose cited unaffected commit modifies different files. A vendor stable branch that still ships the bug, five months after the writeup said it had been fixed.

The pattern, named twice

This is denial-by-pedantry in the CVE record itself. The advisory's cited fix commit exists; the version named as unaffected is released; the customer's natural reading of "upgrade to 2.4.4 and the bug is closed" is not what the cited evidence supports when the cited evidence is read. The structured unaffected field is the part of the CVE record that downstream tools rely on most, and it is the part that resists being checked because checking it requires reading a diff against a separate bug. The advisory is true within its narrow scope. The bug ships intact. This is the same shape as the Oracle Cloud breach Oracle technically didn't deny and SmartScreen's narrow scope of "downloaded from the internet", encoded in the structured metadata that vulnerability scanners read instead of in the press statement that humans read.

It is also the-detector-is-the-target. CyberPanel's AI Scanner is the security tool. It accepts website scan submissions, dispatches them to the remote scanner platform, and renders findings to administrators so they know what was found on their sites. The trust boundary between the platform and CyberPanel, the callback endpoint where the findings arrive, was built without authentication. The administrator's view of "what malware did" is the channel through which the unauthenticated caller does what they want.

The CVE record points downstream tooling at a commit hash. The hash matches in 2.4.4. The match is real. The fix is not.

PoC: @secdb on infosec.exchange. Writeup: itsrez.re/post/cyberpanel-rce.

The hash matches in 2.4.4. The match is real. The fix is not.