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/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,
        ];
    }
}