| Current Path : /home/poneycluc/www/plugins/system/bfnetwork/bfnetwork/ |
| 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,
];
}
}