| Current Path : /home/poneycluc/www/plugins/system/bfnetwork/bfnetwork/ |
| Current File : /home/poneycluc/www/plugins/system/bfnetwork/bfnetwork/bfRogueAdmin.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 rogue Super Admin accounts dropped by the SP Page Builder
* compromise (the June 2026 com_sppagebuilder asset.uploadCustomIcon arbitrary-file-upload RCE).
*
* After the webshell lands, the attacker creates a fresh Super User to keep persistent admin
* access. Those planted accounts share an unmistakable fingerprint: they sit in the Super Users
* group and carry a throwaway email on the @secure.local domain (usernames such as webeditor48).
* No legitimate Joomla Super User is ever issued a @secure.local address, so the pair of
* "Super Admin" + "@secure.local email" is a reliable rogue signal on its own.
*
* This helper only inspects the user tables; it does not scan the filesystem (the webshell the
* attack drops is the job of the Suspect Content audit and the bfAuditor file 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 bfRogueAdmin
{
/**
* The throwaway email domain the SP Page Builder attack stamps on every account it creates.
* Matched on the email suffix, case-insensitively.
*/
private const ROGUE_EMAIL_DOMAIN = '@secure.local';
/**
* @var object
*/
private $db;
public function __construct($db)
{
$this->db = $db;
}
/**
* The group ids that hold Super User (core.admin) access, resolved by ACL rather than by
* group title or a hard-coded id, so a renamed/localised group or a custom admin group is
* still matched. The grant lives in the root asset's rules JSON (#__assets), e.g.
* "core.admin":{"8":1} - the usergroups.rules column was dropped in modern Joomla. We
* collect EVERY group granted core.admin (a site can have more than one). Falls back to the
* stock Super Users id 8 if the rules cannot be read or name nothing.
*
* @return array
*/
private function superUsersGroupIds(): array
{
$rules = '';
try {
// The root asset (parent_id = 0, usually id 1) carries the global ACL rules.
$this->db->setQuery('SELECT rules FROM #__assets WHERE parent_id = 0 ORDER BY id ASC', 0, 1);
$rules = (string) $this->db->loadResult();
} catch (\Throwable $e) {
return [8];
}
$ids = [];
// Pull the core.admin object, then read every "<groupId>":1|true inside it.
if (preg_match('/"core\.admin"\s*:\s*\{([^}]*)\}/i', $rules, $block)) {
if (preg_match_all('/"(\d+)"\s*:\s*(?:1|true)/i', $block[1], $matches)) {
foreach ($matches[1] as $gid) {
$ids[] = (int) $gid;
}
}
}
$ids = array_values(array_unique(array_filter($ids, static function ($id) {
return $id > 0;
})));
return $ids === [] ? [8] : $ids;
}
/**
* Full list of rogue Super Admin accounts: Super Users carrying a @secure.local email.
*/
public function findRogueAdmins(): array
{
$groupIds = $this->superUsersGroupIds();
if ($groupIds === []) {
return [];
}
try {
$this->db->setQuery(
'SELECT DISTINCT u.id, u.name, u.username, u.email, u.registerDate, u.lastvisitDate'
. ' FROM #__users AS u'
. ' INNER JOIN #__user_usergroup_map AS m ON m.user_id = u.id'
. ' WHERE m.group_id IN (' . implode(',', $groupIds) . ')'
. ' AND LOWER(u.email) LIKE ' . $this->db->quote('%' . self::ROGUE_EMAIL_DOMAIN)
);
$rows = (array) $this->db->loadObjectList();
} catch (\Throwable $e) {
return [];
}
$rogue = [];
foreach ($rows as $row) {
$rogue[] = [
'id' => (int) $row->id,
'name' => (string) $row->name,
'username' => (string) $row->username,
'email' => (string) $row->email,
'registerDate' => (string) $row->registerDate,
'lastvisitDate' => $row->lastvisitDate !== null ? (string) $row->lastvisitDate : null,
'reason' => 'Super Admin account with a ' . self::ROGUE_EMAIL_DOMAIN . ' email - planted by the SP Page Builder compromise',
];
}
return $rogue;
}
public function countRogueAdmins(): int
{
return \count($this->findRogueAdmins());
}
/**
* Is this user still a rogue Super Admin right now? Re-checked server-side before any delete,
* so a posted id can never be used to delete an arbitrary (legitimate) account.
*/
private function isStillRogue(int $userId): bool
{
if ($userId <= 0) {
return false;
}
$groupIds = $this->superUsersGroupIds();
if ($groupIds === []) {
return false;
}
try {
$this->db->setQuery(
'SELECT COUNT(*) FROM #__users AS u'
. ' INNER JOIN #__user_usergroup_map AS m ON m.user_id = u.id'
. ' WHERE u.id = ' . $userId
. ' AND m.group_id IN (' . implode(',', $groupIds) . ')'
. ' AND LOWER(u.email) LIKE ' . $this->db->quote('%' . self::ROGUE_EMAIL_DOMAIN)
);
return (int) $this->db->loadResult() > 0;
} catch (\Throwable $e) {
return false;
}
}
/**
* Delete the rogue Super Admin accounts. Re-verifies each row still matches the rogue
* signature server-side before deleting, then removes the user and its dependent rows
* (group map, profile fields, remember-me keys) - mirroring Joomla's own user delete.
*/
public function removeRogueAdmins(): array
{
$deleted = [];
$skipped = 0;
foreach ($this->findRogueAdmins() as $admin) {
$id = (int) $admin['id'];
if (! $this->isStillRogue($id)) {
$skipped++;
continue;
}
try {
$this->db->setQuery('DELETE FROM #__user_usergroup_map WHERE user_id = ' . $id);
$this->db->execute();
$this->db->setQuery('DELETE FROM #__user_profiles WHERE user_id = ' . $id);
$this->db->execute();
$this->db->setQuery('DELETE FROM #__user_keys WHERE user_id = ' . $id);
$this->db->execute();
$this->db->setQuery('DELETE FROM #__users WHERE id = ' . $id);
$this->db->execute();
$deleted[] = $id;
} catch (\Throwable $e) {
$skipped++;
}
}
return [
'deleted' => \count($deleted),
'skipped' => $skipped,
'ids' => $deleted,
];
}
}