Your IP : 216.73.217.11


Current Path : /home/poneycluc/www/plugins/system/bfnetwork/bfnetwork/
Upload File :
Current File : /home/poneycluc/www/plugins/system/bfnetwork/bfnetwork/bfJce.php

<?php

/*
 * @package   bfNetwork
 * @copyright Copyright (C) 2011,2012,2013,2014,2015,2016,2017,2018,2019,2020,2021,2022,2023,2024,2025,2026 Blue Flame Digital Solutions Ltd. All rights reserved.
 * @license   GNU General Public License version 3 or later
 *
 * @see       https://mySites.guru/
 * @see       https://www.phil-taylor.com/
 *
 * @author    Phil Taylor / Blue Flame Digital Solutions Limited.
 *
 * bfNetwork is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * bfNetwork is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this package.  If not, see http://www.gnu.org/licenses/
 *
 * If you have any questions regarding this code, please contact phil@phil-taylor.com
 */

/*
 * Detection and remediation of the JCE editor compromise (the "Nx-ploited" attack / rogue JCE profiles).
 *
 * Attackers exploit old com_jce to import an editor profile that re-enables uploading .php files,
 * then drop a webshell through it. This helper finds the fingerprints of that attack:
 *   - rogue rows in the JCE profiles table (#__wf_profiles)
 *   - malicious profile-import files and dropped webshells in the known JCE drop locations
 * It does NOT scan the whole site (the Suspect Content audit already does the deep scan).
 *
 * If we have got here then we have already passed through decrypting the encrypted header and so
 * we are sure we are now secure and no one else can run the code below.
 */
final class bfJce
{
    /**
     * Content signatures for the dropped webshells seen in the wild.
     *
     * ---------------------------------------------------------------------------
     * WHY ARE THESE STORED AS NUMBERS INSTEAD OF READABLE STRINGS?
     * ---------------------------------------------------------------------------
     * This file is a malware *detector*. To find a webshell it has to know what a
     * webshell looks like - strings such as a shell-exec call on a $_POST
     * superglobal, or eval() wrapped around base64_decode(). Those exact strings
     * are also what server-side antivirus / WAF engines (ImunifyAV, Imunify360,
     * cPanel ClamAV signatures, mod_security, etc.) scan every PHP file for.
     *
     * If we wrote those signatures as plain text in this file, the scanner reads
     * THIS file, sees the malware strings sitting in it, and decides our own
     * detector is itself a webshell. It then quarantines or zeroes the file. When
     * that happens the connector breaks on the customer's site through no fault of
     * theirs. This is not hypothetical: ImunifyAV has zeroed other bfnetwork files
     * (bfEncrypt.php, bfZip.php, bfUpgradeConnector.php) on thousands of sites, and
     * on 2026-06-09 it quarantined this very file (bfJce.php) on a live customer
     * site, flagging the literal "shell_exec on a superglobal" string below.
     *
     * Real-world example of the quarantine we are avoiding:
     *   Definition: {CPG}.PHP.raw.POST.into.exec.20091214185618
     *   Action:     Quarantined
     *
     * ---------------------------------------------------------------------------
     * WHY NOT JUST SPLIT THE STRINGS, e.g. 'shell' . '_exec('?
     * ---------------------------------------------------------------------------
     * Because it does not work. Many AV engines normalise the source before
     * matching - they concatenate adjacent string literals, so 'shell' . '_exec('
     * is re-joined to the dangerous token and flagged anyway. Splitting buys us
     * nothing against a half-decent scanner.
     *
     * ---------------------------------------------------------------------------
     * THE APPROACH: store the needles as their ASCII codepoints (as a CSV string),
     * and rebuild the real strings only at runtime with chr(). The source then
     * contains nothing but harmless integers - there is no contiguous malware
     * substring anywhere in the file for a content scanner to match, and there is
     * no adjacent-literal concatenation for it to normalise. The decoded needles
     * are byte-for-byte identical to the patterns we match against file contents,
     * so detection behaviour is completely unchanged.
     *
     * To regenerate or decode a needle by hand:
     *   encode: take the target string, map ord() over each character, join with ','
     *   decode: implode('', array_map('chr', explode(',', '115,104,...')))
     *
     * DO NOT "tidy" this back into readable strings. Doing so will get the file
     * quarantined again and silently break the connector on AV-protected hosts.
     * ---------------------------------------------------------------------------
     */
    private function shellSignatures(): array
    {
        // Each entry is the CSV of ASCII codepoints for one webshell signature.
        // Decoded at runtime below; never stored as a readable malware string.
        $needles = [
            '101,118,97,108,40,103,122,105,110,102,108,97,116,101,40,98,97,115,101,54,52,95,100,101,99,111,100,101',
            '101,118,97,108,40,98,97,115,101,54,52,95,100,101,99,111,100,101',
            '101,118,97,108,40,115,116,114,95,114,111,116,49,51',
            '102,105,108,116,101,114,95,105,110,112,117,116,40,73,78,80,85,84,95,80,79,83,84',
            '115,104,101,108,108,95,101,120,101,99,40,36,95,71,69,84',
            '115,104,101,108,108,95,101,120,101,99,40,36,95,80,79,83,84',
            '115,104,101,108,108,95,101,120,101,99,40,36,95,82,69,81,85,69,83,84',
            '115,121,115,116,101,109,40,36,95,71,69,84',
            '115,121,115,116,101,109,40,36,95,80,79,83,84',
            '112,97,115,115,116,104,114,117,40',
            '96,36,95,71,69,84',
            '78,120,112,108,111,105,116,101,100',
            '97,115,115,101,114,116,40,36,95,80,79,83,84',
            '97,115,115,101,114,116,40,36,95,71,69,84',
            '112,114,101,103,95,114,101,112,108,97,99,101,40',
            '102,105,108,101,95,112,117,116,95,99,111,110,116,101,110,116,115',
            '60,33,45,45,32,106,98,32,45,45,62',
            '90,69,84,65,32,83,72,69,76,76',
            '108,105,118,101,104,97,99,107,46,108,105,110,107',
            '109,115,97,46,103,111,118,46,109,109',
            '112,104,112,111,98,115,46,99,111,109',
            '108,105,110,117,120,112,108,111,105,116,46,99,111,109',
            '52,48,51,87,101,98,115,104,101,108,108',
            '104,101,108,108,115,107,101,121',
            '84,105,110,121,32,70,105,108,101,32,77,97,110,97,103,101,114',
            '70,77,95,83,69,83,83,73,79,78,95,73,68',
            '116,105,110,121,102,105,108,101,109,97,110,97,103,101,114,46,103,105,116,104,117,98,46,105,111',
        ];

        // Rebuild each readable signature from its codepoints. The strings only
        // ever exist in memory at scan time, never on disk for an AV to flag.
        $signatures = [];
        foreach ($needles as $csv) {
            $signatures[] = implode('', array_map('chr', array_map('intval', explode(',', $csv))));
        }

        return $signatures;
    }

    /**
     * The two regexes that fingerprint a polyglot webshell hidden in a non-PHP file.
     *
     * Both regex bodies are stored as ASCII codepoints and rebuilt at runtime for the
     * exact same reason as shellSignatures() above: written out as readable patterns they
     * contain a PHP open-tag plus request-input tokens, which content scanners (ImunifyAV
     * etc) match as a webshell and quarantine this file. As integers there is nothing for
     * them to flag.
     *
     * [0] detects a genuine PHP open tag (long or short-echo form). [1] requires that open
     * tag to be followed, within a short window, by a token that reads request input -
     * the part a lone open tag (which occurs by chance in compressed image data) can never
     * satisfy, so legitimate photos are not flagged.
     *
     * @return array{0: string, 1: string}
     */
    private function polyglotPatterns(): array
    {
        $openTag = '60,92,63,40,112,104,112,92,98,124,61,41';
        $input   = '60,92,63,40,112,104,112,92,98,124,61,41,46,123,48,44,51,48,48,125,63,92,36,95,40,71,69,84,124,80,79,83,84,124,82,69,81,85,69,83,84,124,67,79,79,75,73,69,124,83,69,82,86,69,82,124,70,73,76,69,83,124,69,78,86,124,83,69,83,83,73,79,78,41,92,98';

        $build = static function ($csv, $flags) {
            $body = implode('', array_map('chr', array_map('intval', explode(',', $csv))));

            return '/' . $body . '/' . $flags;
        };

        return [$build($openTag, 'i'), $build($input, 'is')];
    }

    /**
     * @var object
     */
    private $db;

    public function __construct($db)
    {
        $this->db = $db;
    }

    /**
     * The known JCE-attack drop locations, relative to JPATH_BASE. We check only these,
     * not the whole webroot.
     */
    private function candidatePaths(): array
    {
        $base = rtrim(JPATH_BASE, '/');

        $paths = [$base . '/tmp/.system/.xml.php', $base . '/media/system/js/.xml.php', $base . '/libraries/joomla/.xml.php'];

        // Stray executable / marker files dropped in the upload landing zones.
        foreach (['/tmp', '/images'] as $dir) {
            $dirPath = $base . $dir;
            if (! is_dir($dirPath)) {
                continue;
            }
            foreach ((array) @scandir($dirPath) as $entry) {
                if ($entry === '.' || $entry === '..') {
                    continue;
                }
                $full = $dirPath . '/' . $entry;
                if (! is_file($full)) {
                    continue;
                }
                // /images/replace.php is a legitimate file (its content uses preg_replace /
                // file_put_contents, which match our webshell signatures). Never treat it as a
                // JCE dropper candidate.
                if ($dir === '/images' && strtolower($entry) === 'replace.php') {
                    continue;
                }
                // Any server-executable extension in tmp/images is never legitimate: .php and
                // its variants .phar / .phtml / .pht / .shtml (and .phpN). nx*.txt + x.xml are the known
                // markers. jce_poc_*.xml is the profile-import PoC this attack drops (e.g.
                // jce_poc_25552_20112.xml); fileIsMalicious() then confirms on content.
                // *.php.gif / *.phtml.jpg etc. is the upload-filter bypass the attack
                // renames its dropped shell to; without this it never becomes a candidate.
                // .htaccess in a writable upload dir that enables PHP execution is a
                // persistence mechanism left by attackers after initial access.
                // Plain image / text / xml files also become candidates so fileIsMalicious()
                // can content-check them: the attack drops GIF/PHP polyglot shells with
                // innocuous hex names (e.g. /images/6e0bda05.gif) that match no name pattern.
                // The content check only flags ones carrying an embedded PHP tag, so clean
                // images are read but never reported.
                if (preg_match('/\.(php[0-9]?|phar|phtml|pht|shtml)$/i', $entry)
                    || preg_match('/\.ph(p[0-9]?|tml)\.(gif|jpe?g|png)$/i', $entry)
                    || preg_match('/\.(gif|jpe?g|png|webp|bmp|xml|txt|log)$/i', $entry)
                    || preg_match('/^nx.*\.txt$/i', $entry)
                    || preg_match('/^jce_poc.*\.xml$/i', $entry)
                    || strtolower($entry) === 'x.xml'
                    || strtolower($entry) === '.htaccess') {
                    $paths[] = $full;
                }
            }
        }

        // The site root itself: the attack drops machine-generated hex_hex .shtml
        // webshells straight into the webroot (e.g. /169cf35373f85a_ac70d28f8406.shtml).
        // Those never live under /tmp or /images, so without scanning root here they
        // never become candidates and the dropperPatterns() .shtml rule never fires.
        // Anchor the scan to the hex_hex shape so legitimate error pages (/400.shtml,
        // /404.shtml, /500.shtml) are not even read - only true droppers are picked up.
        foreach ((array) @scandir($base) as $entry) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }
            if (preg_match('/^[a-f0-9]{8,}_[a-f0-9]{8,}\.shtml$/i', $entry)) {
                $full = $base . '/' . $entry;
                if (is_file($full)) {
                    $paths[] = $full;
                }
            }
        }

        return array_values(array_unique($paths));
    }

    /**
     * Known JCE-attack dropper filename shapes, kept byte-for-byte in sync with bfAuditor's
     * $dropperPatterns. One pattern per known dropper filename shape, anchored to the directory
     * it appears in and matched against the path relative to the webroot. bfJce only scans /tmp
     * and /images (plus three literal paths), so the shapes anchored elsewhere (/modules, root
     * .shtml, /images subdirs) simply never receive a candidate here - they are kept for parity
     * so the two lists stay identical and diff-clean.
     */
    private function dropperPatterns(): array
    {
        return [
            // /tmp/ xml droppers
            '#^/tmp/(profile|x|p|exploit|Default)\.xml$#i',       // literal-name xml
            '#^/tmp/cve\d+_[a-z0-9]{8}\.xml$#i',                  // cve<digits>_<8 alnum>
            '#^/tmp/x[a-z0-9]{6}\.xml$#i',                        // x<6 alnum>
            '#^/tmp/profile_\d+\.xml$#i',                         // profile_<digits>
            '#^/tmp/jce_poc_\d+_\d+\.xml$#i',                     // jce_poc_<digits>_<digits>
            // /tmp/ php and txt droppers
            '#^/tmp/(system|test|configs)\.php$#i',               // literal-name php
            '#^/tmp/nx\d+\.(php|phar|txt)$#i',                    // nx<digits>
            '#^/tmp/gd\d+\.php$#i',                               // gd<digits>
            '#^/tmp/lanciau[a-z0-9]{8}\.php$#i',                  // lanciau<8 alnum>
            '#^/tmp/[a-z]+_[a-z0-9]{6,8}\.(php|xml)(\.gif)?$#i',  // any word_<6-8 alnum> dropper (img_/thumb_/resize_ etc.)
            '#^/tmp/shell_[a-f0-9]{8}\.php$#i',                   // shell_<8 hex>
            // /images/ droppers (.php and optional .php.gif)
            '#^/images/shadow(\d+|xfriska_[a-z0-9]{6})\.php(\.gif)?$#i', // shadow<digits>/shadowxfriska_<6 alnum>
            '#^/images/[ns]\d+\.php(\.gif)?$#i',                  // s<digits>/n<digits>
            '#^/images/s\d+\.gif$#i',                            // s<digits>.gif (PHP webshell in a plain .gif, e.g. s1781087357.gif)
            '#^/images/\d+\.php(\.gif)?$#i',                      // <digits>
            '#^/images/[a-f0-9]{8}\.php(\.gif)?$#i',             // <8 hex>
            '#^/images/[a-f0-9]{16}\.php(\.gif)?$#i',            // <16 hex>
            '#^/images/(store|db)_helper_\d+\.php(\.gif)?$#i',    // store_helper_/db_helper_<digits>
            '#^/images/jce_[a-f0-9]{8}\.php(\.gif)?$#i',          // jce_<8 hex>
            '#^/images/sec-[a-f0-9]{8,16}\.php(\.gif)?$#i',       // sec-<8-16 hex>
            '#^/images/[a-z]+_\d+\.php(\.gif)?$#i',               // any word_<digits> (cmd_ etc.)
            '#^/images/.*/[a-z]+_(?=[a-z0-9]*\d)[a-z0-9]{6,8}\.php(\.gif)?$#i', // any /images/ subdir: word_<6-8 alnum w/ a digit> (cache_/media_/resize_ etc.)
            '#^/images/tred\.php(\.gif)?$#i',                     // literal tred
            // any executable phtml/pht under /images/
            '#^/images/.+\.(pht|phtml)$#i',
            // machine-generated hex_hex .shtml dropper in the site root (e.g.
            // /169cf35373f85a_ac70d28f8406.shtml). Anchored to the hex_hex shape so
            // legitimate error pages (/400.shtml, /404.shtml, /500.shtml) are not flagged.
            '#^/[a-f0-9]{8,}_[a-f0-9]{8,}\.shtml$#i',
            // MD5-named php remote-fetch dropper hidden in a module zip/ subfolder
            // (e.g. /modules/mod_languages/src/zip/<32 hex>.php)
            '#^/modules/.+/zip/[a-f0-9]{32}\.php(\.gif)?$#i',
        ];
    }

    /**
     * Does a file's content match a known malicious signature?
     */
    private function fileIsMalicious(string $path, string &$reason): bool
    {
        if (! is_file($path) || ! is_readable($path)) {
            return false;
        }

        $name = basename($path);

        // jce_editor_profile_<Y_m_d>.xml is the genuine filename JCE itself writes when a user
        // exports/backs up an editor profile. It legitimately contains <profiles>, php and
        // filetypes tokens, so the content check below would otherwise flag a real backup.
        // The attack's dropper is named jce_poc_*.xml, never jce_editor_profile_*.xml.
        if (preg_match('/^jce_editor_profile_\d{4}_\d{2}_\d{2}\.xml$/i', $name)) {
            return false;
        }

        // Known JCE-attack dropper filename shapes, kept in sync with bfAuditor's
        // $dropperPatterns. Matched against the path relative to the webroot, exactly as
        // bfAuditor matches them against filewithpath. A shape match is malicious by name
        // whatever the contents - these names are never legitimate.
        foreach ($this->dropperPatterns() as $dropperPattern) {
            if (preg_match($dropperPattern, $this->relativePath($path))) {
                $reason = 'Filename matches a known JCE-attack dropper shape';

                return true;
            }
        }

        // A hidden .xml.php in a legit dir is a known dropper name regardless of content.
        if (preg_match('/\.xml\.php$/i', $name)) {
            $reason = 'Hidden .xml.php backdoor dropper in a legitimate directory';

            return true;
        }

        // A .php in a drop zone named like a known JCE-dropped shell (configs.php,
        // shell_<hex>.php, jce_<hex>.php and close variants) is malicious by name,
        // whatever its contents - the attack uses these exact names.
        if (preg_match('/^(configs?|shell[_.-]?[0-9a-f]*|jce[_-][0-9a-f]{4,})\.php$/i', $name)) {
            $reason = 'Filename matches a known JCE-dropped webshell pattern';

            return true;
        }

        // An executable extension hidden behind an image extension (shell.php.gif,
        // x.phtml.jpg) is an upload-filter bypass artifact - the attack renames its
        // dropped shell to exactly this shape, so it is malicious by name alone.
        if (preg_match('/\.ph(p[0-9]?|tml)\.(gif|jpe?g|png)$/i', $name)) {
            $reason = 'Executable double extension (.php.gif etc.) - upload-filter bypass';

            return true;
        }

        // hellskey.php / hellskey.xml are known JCE exploit artifact filenames.
        if (preg_match('/^hellskey\.(php|xml)$/i', $name)) {
            $reason = 'Filename matches known hellskey JCE exploit artifact';

            return true;
        }

        // sNNNNNNNNNN.php.ext — s + 10 digits + .php.* is a known post-compromise
        // webshell naming pattern seen in the wild alongside JCE attacks.
        if (preg_match('/^s\d{10}\.php\./i', $name)) {
            $reason = 'Filename matches sNNNNNNNNNN.php.ext webshell pattern';

            return true;
        }

        // Blended-name fake index.php: e.g. lindex.php, cache055ab5index.php.
        // Legitimate Joomla files are never named like this; it is a webshell camouflage
        // technique that piggybacks on Joomla's ubiquitous index.php suppressor files.
        if (preg_match('/^[a-z][a-z0-9]{0,15}index\.php$/i', $name) && strtolower($name) !== 'index.php') {
            $reason = 'Blended-name webshell — fake index.php camouflage';

            return true;
        }

        $content = (string) @file_get_contents($path, false, null, 0, 65536);
        if ($content === '') {
            // An .xml import in tmp/ that re-enables php uploads is malicious even if short.
            $content = (string) @file_get_contents($path);
        }

        $isNonPhpType = (bool) preg_match('/\.(gif|jpe?g|png|webp|bmp|xml|txt|log)$/i', $name)
            || (bool) preg_match('/^GIF8[79]a/', $content);

        [$openTagPattern, $inputPattern] = $this->polyglotPatterns();

        if ($isNonPhpType && preg_match($openTagPattern, $content)) {
            $matched = (bool) preg_match($inputPattern, $content);

            if (! $matched) {
                foreach ($this->shellSignatures() as $sig) {
                    if (strpos($content, $sig) !== false) {
                        $matched = true;

                        break;
                    }
                }
            }

            if ($matched) {
                $reason = 'Polyglot webshell in a non-PHP file';

                return true;
            }
        }

        // .htaccess that enables PHP execution in a directory that should not have it.
        // Only flag directives that map a file to the PHP *engine* (x-httpd-php,
        // phpN-script) or prepend a PHP file - the canonical mechanisms attackers use to
        // make a dropped file executable after upload. Defensive snippets that route .php
        // to cgi-script and then -ExecCGI (the standard Joomla "secure directory" rule)
        // DISABLE execution and must never match.
        if (strtolower($name) === '.htaccess'
            && preg_match(
                '/auto_prepend_file|(?:AddType|AddHandler|SetHandler)\s+[^\r\n#]*?(?:x-httpd-php|php[0-9]*-script)|php_flag\s+engine\s+on/i',
                $content
            )) {
            $reason = '.htaccess enables PHP execution — likely post-compromise persistence mechanism';

            return true;
        }

        foreach ($this->shellSignatures() as $sig) {
            if (strpos($content, $sig) !== false) {
                $reason = 'Matches known webshell / marker signature';

                return true;
            }
        }

        // Goto-based obfuscation: PHP goto is never used in legitimate Joomla or
        // extension code. 403Webshell and similar obfuscators generate hundreds of
        // goto jumps; 15+ is a reliable threshold with no known false positives.
        if (preg_match('/\.(php[0-9]?|phar|phtml|pht|shtml)$/i', $name)) {
            $gotoCount = preg_match_all('/\bgoto\b/', $content);
            if ($gotoCount > 15) {
                $reason = 'Goto-obfuscated PHP (' . $gotoCount . ' goto statements) — automated obfuscator fingerprint';

                return true;
            }
        }

        // Malicious JCE profile import dropped as a file: re-enables dangerous uploads
        // through the editor. Two shapes are caught:
        //   1. it names php/phtml directly in a filetypes/extensions list, or
        //   2. it opens the floodgates with an unrestricted wildcard - JCE's
        //      "filetypes":"files=*" (or files=all, or a bare * value) allows EVERY
        //      extension, .php included, without ever spelling out "php". The genuine
        //      JCE profiles always ship a restricted, named extension list, never a
        //      blanket wildcard, so this is a reliable rogue signal on its own.
        if (preg_match('/\.xml$/i', $name)
            && preg_match('/<profiles?>/i', $content)
            && preg_match('/files|filetypes|extensions/i', $content)
            && (preg_match('/php|phtml/i', $content)
                || preg_match('/(?:files|filetypes|extensions)"?\s*[:=]\s*"?\s*(?:\*|all(?![a-z]))/i', $content))) {
            $reason = 'Malicious JCE profile import that enables dangerous uploads';

            return true;
        }

        return false;
    }

    /**
     * Full list of malicious files found in the known drop locations.
     *
     * @return array<int, array{path: string, type: string, reason: string, mtime: int|null}>
     */
    public function findMaliciousFiles(): array
    {
        $found  = [];
        $reason = '';

        foreach ($this->candidatePaths() as $path) {
            if ($this->fileIsMalicious($path, $reason)) {
                $mtime    = @filemtime($path);
                $found[]  = [
                    'path'     => $this->relativePath($path),
                    'fullpath' => $path,
                    'type'     => preg_match('/\.xml$/i', $path) ? 'JCE profile import' : 'Backdoor / marker',
                    'reason'   => $reason,
                    'mtime'    => $mtime === false ? null : $mtime,
                ];
            }
        }

        return $found;
    }

    private function relativePath(string $path): string
    {
        $base = rtrim(JPATH_BASE, '/') . '/';
        $rel  = strpos($path, $base) === 0 ? substr($path, \strlen($base)) : $path;

        return '/' . ltrim($rel, '/');
    }

    /**
     * Does the JCE profiles table exist?
     */
    private function profilesTableExists(): bool
    {
        try {
            $prefix = $this->db->getPrefix();
            $this->db->setQuery('SHOW TABLES LIKE ' . $this->db->quote($prefix . 'wf_profiles'));

            return (bool) $this->db->loadResult();
        } catch (\Throwable $e) {
            return false;
        }
    }

    /**
     * Full list of rogue JCE profiles in the database.
     *
     * @return array<int, array{id: int, name: string, description: string, reason: string}>
     */
    public function findRogueProfiles(): array
    {
        if (! $this->profilesTableExists()) {
            return [];
        }

        try {
            $this->db->setQuery('SELECT id, name, description, ordering, params FROM #__wf_profiles');
            $rows = (array) $this->db->loadObjectList();
        } catch (\Throwable $e) {
            return [];
        }

        $rogue = [];
        foreach ($rows as $row) {
            $reason = $this->profileRogueReason($row);
            if ($reason !== null) {
                $rogue[] = [
                    'id'          => (int) $row->id,
                    'name'        => (string) $row->name,
                    'description' => (string) $row->description,
                    'reason'      => $reason,
                ];
            }
        }

        return $rogue;
    }

    /**
     * Returns the reason a profile is rogue, or null if it looks legitimate.
     *
     * The load-bearing signal is an UPLOAD profile that allows php/phtml filetypes -
     * NOT allow_php alone (that governs PHP inside article content and is used by some
     * legitimate profiles).
     */
    private function profileRogueReason(object $row): ?string
    {
        $name = (string) $row->name;
        $desc = (string) $row->description;

        // Machine-generated attacker label: a short alpha prefix (J, Mal, X ...) glued
        // straight onto a long run of digits, e.g. J278661 or Mal1781334529. No legitimate
        // JCE profile is ever named this way (stock names are words: Default, Blogger,
        // Front End). 6+ digits is the floor that keeps human names like "Profile2" or a
        // "v2024" profile from matching.
        if (preg_match('/^[A-Za-z]{1,4}[0-9]{6,}$/', $name)) {
            return 'Machine-generated name matching the attacker label pattern (short prefix + long digit run)';
        }

        if (preg_match('/pwned|nxploited|rce via jce/i', $name . ' ' . $desc)) {
            return 'Name or description matches a known attacker label';
        }

        // CVE-named profiles: attackers name the profile after the CVE they exploited
        // (e.g. "cve89907", "cvefa3448f", "cve25000e4") or put "CVE-2026-48967" in the
        // description. No legitimate JCE profile is ever named or described this way.
        if (preg_match('/^cve[0-9a-f]/i', $name) || preg_match('/\bCVE-\d{4}-/i', $desc)) {
            return 'Name or description references a CVE identifier - attacker-planted profile';
        }

        // Forced to the top of the list to override the real profile.
        $ordering = (int) ($row->ordering ?? 0);

        $params                = (string) ($row->params ?? '');
        $allowsDangerousUpload = (bool) preg_match('/"(filetypes|extensions)"\s*:\s*"[^"]*(php|phtml)/i', $params)
            || (bool) preg_match('/files\s*=\s*[^;"\']*(php|phtml)/i', $params)
            // An unrestricted wildcard (files=*, files=all, or a bare * value) allows every
            // extension, .php included, without ever naming it - the genuine profiles always
            // ship a restricted, named extension list, never a blanket wildcard.
            || (bool) preg_match('/(?:files|filetypes|extensions)"?\s*[:=]\s*"?\s*(?:\*|all(?![a-z]))/i', $params);

        if ($allowsDangerousUpload) {
            return 'Profile allows uploading dangerous files (.php / wildcard) through the editor';
        }

        if ($ordering <= -1000) {
            return 'Profile forced to the top of the list with an extreme negative ordering';
        }

        return null;
    }

    public function countRogueProfiles(): int
    {
        return \count($this->findRogueProfiles());
    }

    public function countMaliciousFiles(): int
    {
        return \count($this->findMaliciousFiles());
    }

    /**
     * Delete the rogue profiles. Re-verifies each row still matches a rogue signature
     * server-side before deleting - never trusts an id blindly.
     *
     * @return array{removed: int, ids: array<int, int>}
     */
    public function removeRogueProfiles(): array
    {
        $removed = [];
        foreach ($this->findRogueProfiles() as $profile) {
            $id = (int) $profile['id'];
            try {
                $this->db->setQuery('DELETE FROM #__wf_profiles WHERE id = ' . $id);
                $this->db->execute();
                $removed[] = $id;
            } catch (\Throwable $e) {
                // skip rows we cannot delete
            }
        }

        return [
            'removed' => \count($removed),
            'ids'     => $removed,
        ];
    }

    /**
     * Delete the malicious files. Re-verifies each path is still malicious and inside the
     * webroot before unlinking.
     *
     * @return array{deleted: int, skipped: int, paths: array<int, string>}
     */
    public function removeMaliciousFiles(): array
    {
        $base    = rtrim(JPATH_BASE, '/') . '/';
        $deleted = [];
        $skipped = 0;
        $reason  = '';

        foreach ($this->findMaliciousFiles() as $file) {
            $full = $file['fullpath'];

            // Defence in depth: must be inside the webroot and still malicious.
            if (strpos($full, $base) !== 0 || ! $this->fileIsMalicious($full, $reason)) {
                $skipped++;

                continue;
            }

            if (@unlink($full)) {
                $deleted[] = $file['path'];
            } else {
                $skipped++;
            }
        }

        return [
            'deleted' => \count($deleted),
            'skipped' => $skipped,
            'paths'   => $deleted,
        ];
    }
}