shell bypass 403
<?php
/**
* @package admintools
* @copyright Copyright (c)2010-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license GNU General Public License version 3, or later
*/
namespace Akeeba\Plugin\System\AdminTools\Feature;
defined('_JEXEC') || die;
use Akeeba\Plugin\System\AdminTools\Feature\Mixin\SuperUsersTrait;
use Akeeba\Plugin\System\AdminTools\Utility\RescueUrl;
use Exception;
use Joomla\CMS\Access\Access;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\User\User;
/**
* Keep track of Super Users on the site and send an email when users are added. Optionally automatically block these
* new Super Users.
*/
class SuperUsersList extends Base
{
use SuperUsersTrait;
/**
* Cache of user group IDs with Super User privileges
*
* @var array
* @since 5.6.1
*/
protected $superUserGroups = [];
/**
* Returns a list of safe Super User IDs. These are the IDs of the Super Users being saved by another Super User in
* the backend of the site through com_users.
*
* @return array
*/
public function getSafeIDs()
{
if (!$this->isBackendSuperUser())
{
return [];
}
// Get the option and task parameters
$option = $this->app->getInput()->getCmd('option', 'com_foobar');
$task = $this->app->getInput()->getCmd('task');
// Not com_users?
if ($option != 'com_users')
{
return [];
}
// Special case: unblock with one click. There's no jform here, the ID is passed in the 'cid' query string parameter
if ($task == 'users.unblock')
{
$cid = $this->app->getInput()->get('cid', [], 'array');
if (empty($cid))
{
return [];
}
if (!is_array($cid))
{
$cid = [$cid];
}
return $cid;
}
// Note Save or Save & Close?
if (!in_array($task, ['user.apply', 'user.save']))
{
return [];
}
// Get the user IDs from the form
$jForm = $this->app->getInput()->get('jform', [], 'array');
if (!is_array($jForm) || empty($jForm))
{
return [];
}
// No user ID or group information?
if (!isset($jForm['groups']) || !isset($jForm['id']))
{
return [];
}
// Is it a Super User?
$superUserGroups = $this->getSuperUserGroups();
$groups = $jForm['groups'];
$isSuperUser = false;
if (empty($groups))
{
return [];
}
foreach ($groups as $group)
{
if (in_array($group, $superUserGroups))
{
$isSuperUser = true;
break;
}
}
if (!$isSuperUser)
{
return [];
}
// Get the user ID being saved and return it
$id = $jForm['id'];
if (empty($id))
{
return [];
}
return [$id];
}
/**
* Is this feature enabled?
*
* @return bool
*/
public function isEnabled()
{
/**
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* A Short History Of How This Feature Ended Up Disabled By Default
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
*
* Despite this feature working just fine, we found out that it's a constant source of support request for
* reasons unrelated to its performance or reliability. It boils down to:
*
* - Badly written third party software, some of it running outside Joomla!, will create from scratch or afresh
* Super User accounts silently. This is EXACTLY the problem this feature is supposed to catch and it does.
* However users don't perceive that ugly and dangerous third party hack as a problem and instead believe that
* it's Admin Tools fault for warning them when they have not been subjectively hacked (in fact, the machine
* has no way to determine that what just happened is not malicious BECAUSE THAT'S EXACTLY WHAT AN EVIL HACKER
* WOULD DO TO PWN YOUR SITE).
*
* - People forget that they have disabled Admin Tools when they are creating a Super User, therefore making it
* impossible for AT to know if the new Super User is legit or an evil implant. AT warns them, as they should,
* but they again think it's a bug - despite the feature doing EXACTLY what it is asked to do, i.e. warn for
* any user account created outside the Users editor and / or outside its watch.
*
* - People use third party extensions either by themselves (obvious) or one which override the backend Users
* page of Joomla! (absolutely not obvious). In this case the created Super User is indeed created outside the
* backend Users page of Joomla! so Admin Tools correctly warns them. Once more, people perceive it as a bug
* in Admin Tools.
*/
return ($this->wafParams->getValue('superuserslist', 0) == 1);
}
public function onAfterRender(): void
{
// Only run if the current user is a Super User AND we haven't already set a flag
$currentUser = $this->app->getIdentity();
if ($currentUser->guest)
{
return;
}
if (!$currentUser->authorise('core.admin'))
{
return;
}
$flag = $this->app->getSession()->get('com_admintools.allowedsuperuser', null);
if ($flag === true)
{
return;
}
// Get temporary session variables
$safeIDs = $this->app->getSession()->get('com_admintools.superuserslist.safeids', []);
$isUserSaveOrApply = $this->app->getSession()->get('com_admintools.superuserslist.createnew', null);
$this->app->getSession()->set('com_admintools.superuserslist.safeids', null);
$this->app->getSession()->set('com_admintools.superuserslist.createnew', null);
// Normalize
if (empty($safeIDs))
{
$safeIDs = [];
}
if (empty($isUserSaveOrApply))
{
$isUserSaveOrApply = false;
}
// If it's not a backend Super User we are going to ignore session variables (they are forged!)
if (!$this->isBackendSuperUser())
{
$safeIDs = [];
$isUserSaveOrApply = false;
}
// Get the Super User IDs
$savedSuperUserIDs = $this->load();
$superUserGroups = $this->getSuperUserGroups();
$currentSuperUserIDs = $this->getUsersInGroups($superUserGroups);
// Oh, we never had a list of Super Users. Let's fix that.
if (empty($savedSuperUserIDs))
{
$this->save($currentSuperUserIDs);
return;
}
// Do we have new Super Users?
$newSuperUsers = array_diff($currentSuperUserIDs, $savedSuperUserIDs);
// Do NOT remove this variable! It catches the case were Super Users are added BUT THEN REMOVED FROM $newSuperUsers WITH array_diff. WE MUST SAVE IN THIS CASE!
$hasNewSuperUsers = !empty($newSuperUsers);
$newSuperUsers = array_diff($newSuperUsers, $safeIDs);
$removedSuperUsers = array_diff($savedSuperUserIDs, $currentSuperUserIDs);
// Detect the case where we have to simply save the list of Super Users and quit (no new or removed SUs)
$saveListAndQuit = empty($newSuperUsers) && empty($removedSuperUsers);
/**
* Special case: Super User logged in backend creates a new user account that is also a Super User.
*
* In this case we do not have any safeIDs because the JForm is being submitted with user ID 0. This is normal
* since we are creating a new user record, therefore we do not have a user ID yet. We can distinguish this
* case from the generic "third party backend extension creates a new user account" by checking the option and
* task parameters. If the option is com_users (the Joomla! user management core component) and the task
* indicates applying or saving a user we have the special case we need to avoid blocking.
*/
if ($this->isBackendSuperUser() && empty($safeIDs) && $isUserSaveOrApply)
{
$saveListAndQuit = true;
}
if ($saveListAndQuit)
{
// In case Super Users ARE added BUT are in the safe IDs list THEN we MUST save the new list!
if ($hasNewSuperUsers)
{
$this->save($currentSuperUserIDs);
}
return;
}
// If we're here a new Super User was added through means unknown. Notify the admins and block the user.
$this->sendEmail($newSuperUsers);
$flag = true;
foreach ($newSuperUsers as $id)
{
$user = self::getUserById($id);
$user->block = 1;
$user->save();
if ($currentUser->id == $id)
{
$flag = false;
}
}
$this->app->getSession()->set('com_admintools.allowedsuperuser', $flag);
$currentSuperUserIDs = array_diff($currentSuperUserIDs, $newSuperUsers);
$newSuperUsers = [];
if (!empty($newSuperUsers) || !empty($removedSuperUsers))
{
$this->save($currentSuperUserIDs);
}
// Is the current user one of the new, bad admins? If so, try to log the out
if ($flag === false)
{
// Try being nice about it
if (!$this->app->logout())
{
// If being nice about logging you out doesn't work I'm gonna terminate you, with extreme prejudice.
$this->app->getSession()->set('user', null);
$this->app->getSession()->destroy();
}
}
}
/**
* Checks if a backend Super User is saving another Super User account. We have to run this check onAfterRoute since
* com_users will perform an immediate redirect upon saving, without hitting onAfterRender. For the same reason the
* detected ID of the Super User being saved has to be saved in the session to persist the successive page loads.
*/
public function onAfterRoute(): void
{
if (!$this->isBackendSuperUser())
{
return;
}
// Do I already have session data?
$safeIDs = $this->app->getSession()->get('com_admintools.superuserslist.safeids', []);
$isUserSaveOrApply = $this->app->getSession()->get('com_admintools.superuserslist.createnew', null);
if (!is_null($isUserSaveOrApply))
{
// Yeah. Let's not overwrite the session data. We shall do that onAfterRender.
return;
}
$safeIDs = $this->getSafeIDs();
// Get the option and task parameters
$option = $this->input->getCmd('option', 'com_foobar');
$task = $this->input->getCmd('task');
$isUserSaveOrApply = false;
// Are we using com_user to Save or Save & Close a user?
if ($option == 'com_users')
{
if (in_array($task, ['user.apply', 'user.save']))
{
$isUserSaveOrApply = true;
}
}
$this->app->getSession()->set('com_admintools.superuserslist.safeids', $safeIDs);
$this->app->getSession()->set('com_admintools.superuserslist.createnew', $isUserSaveOrApply);
}
/**
* Returns all Joomla! user groups
*
* @return array
*
* @since 5.3.0
*/
protected function getSuperUserGroups()
{
if (empty($this->superUserGroups))
{
// Get all groups
$db = $this->db;
$query = (method_exists($db, 'createQuery') ? $db->createQuery() : $db->getQuery(true))
->select([$db->qn('id')])
->from($db->qn('#__usergroups'));
$this->superUserGroups = $db->setQuery($query)->loadColumn(0);
// This should never happen (unless your site is very dead, in which case I feel terribly sorry for you...)
if (empty($this->superUserGroups))
{
$this->superUserGroups = [];
}
$this->superUserGroups = array_filter(
$this->superUserGroups, function ($group) {
return Access::checkGroup($group, 'core.admin');
}
);
}
return $this->superUserGroups;
}
/**
* Get the IDs of users who are members of one or more groups in the $groups list
*
* @param array $groups The users must be a member of at least one of these groups
*
* @return array
*/
private function getUsersInGroups(array $groups)
{
$db = $this->db;
$ret = [];
$groups = array_map([$db, 'q'], $groups);
try
{
$query = (method_exists($db, 'createQuery') ? $db->createQuery() : $db->getQuery(true))
->select($db->qn('user_id'))
->from($db->qn('#__user_usergroup_map') . ' AS ' . $db->qn('m'))
->innerJoin(
$db->qn('#__users') . ' AS ' . $db->qn('u') . 'ON(' .
$db->qn('u.id') . ' = ' . $db->qn('m.user_id')
. ')'
)
->where($db->qn('group_id') . ' IN(' . implode(',', $groups) . ')')
->where($db->qn('block') . ' = ' . $db->q('0'))
// Don't look only for empty string. Joomla! considers '' and '0' identical and will let you log in!
->where(
'(' .
'(' . $db->qn('activation') . ' = ' . $db->q('0') . ') OR ' .
'(' . $db->qn('activation') . ' = ' . $db->q('') . ')' .
')'
);
$db->setQuery($query);
$rawUserIDs = $db->loadColumn(0);
}
catch (Exception $exc)
{
return $ret;
}
if (empty($rawUserIDs))
{
return $ret;
}
return array_unique($rawUserIDs);
}
/**
* Are we currently in the backend, with a logged in Super User?
*
* @return bool
*/
private function isBackendSuperUser()
{
// Not a valid application object?
if (!is_object($this->app))
{
return false;
}
$isCMSApp = $this->app instanceof CMSApplication;
if (!$isCMSApp)
{
return false;
}
// Are we in the backend?
$isAdmin = $this->app->isClient('administrator');
if (!$isAdmin)
{
return false;
}
// Not a Super User?
if (!$this->app->getIdentity()->authorise('core.admin'))
{
return false;
}
return true;
}
/**
* Load the saved list of Super User IDs from the database
*
* @return array
*/
private function load()
{
$db = $this->db;
$query = (method_exists($db, 'createQuery') ? $db->createQuery() : $db->getQuery(true))
->select($db->quoteName('value'))
->from($db->quoteName('#__admintools_storage'))
->where($db->quoteName('key') . ' = ' . $db->quote('superuserslist'));
$db->setQuery($query);
$error = 0;
try
{
$jsonData = $db->loadResult();
}
catch (Exception $e)
{
$error = $e->getCode();
}
if (method_exists($db, 'getErrorNum') && $db->getErrorNum())
{
$error = $db->getErrorNum();
}
if ($error)
{
$jsonData = null;
}
if (empty($jsonData))
{
return [];
}
return json_decode($jsonData, true);
}
/**
* Save the list of users to the database
*
* @param array $userList The list of User IDs
*
* @return void
*/
private function save(array $userList)
{
$db = $this->db;
$data = json_encode($userList);
$query = (method_exists($db, 'createQuery') ? $db->createQuery() : $db->getQuery(true))
->delete($db->quoteName('#__admintools_storage'))
->where($db->quoteName('key') . ' = ' . $db->quote('superuserslist'));
$db->setQuery($query);
$db->execute();
$object = (object) [
'key' => 'superuserslist',
'value' => $data,
];
$db->insertObject('#__admintools_storage', $object);
}
/**
* Sends a warning email to the addresses set up to receive security exception emails
*
* @param array $superUsers The IDs of Super Users added
*
* @return void
*/
private function sendEmail(array $superUsers)
{
if (empty($superUsers))
{
// What are you doing here?
return;
}
// Load the component's administrator translation files
$jlang = $this->app->getLanguage();
$jlang->load('com_admintools', JPATH_ADMINISTRATOR, 'en-GB', true);
$jlang->load('com_admintools', JPATH_ADMINISTRATOR, $jlang->getDefault(), true);
$jlang->load('com_admintools', JPATH_ADMINISTRATOR, null, true);
$infoHtml = "<ol>" . implode(
"\n", array_map(
function ($id) {
$user = self::getUserById($id);
return "<li>#$id – <b>{$user->username}</b> – {$user->name} <{$user->email}></li>";
}, $superUsers
)
) . "</ol>";
$infoText = implode(
"\n", array_map(
function ($id) {
$user = self::getUserById($id);
return "* #$id – {$user->username} – {$user->name} <{$user->email}>";
}, $superUsers
)
);
// Construct the replacement table
$substitutions = [
'INFO' => $infoText,
'INFO_HTML' => $infoHtml,
];
// Let's get the most suitable email template
try
{
/**
* The email recipients are taken from one of the following sources:
*
* - Email this address on Super Users change (`superuserslist_email`).
* - Email this address after an automatic IP ban (`emailafteripautoban`).
* - Super Users which are not Blocked, and have Receive Email enabled.
*
* The first source to return non-zero items wins.
*/
$recipients = $this->userListFromConfiguredEmailList('superuserslist_email')
?: $this->userListFromConfiguredEmailList('emailafteripautoban')
?: array_filter($this->getSuperUserObjects(), fn(User $user) => $user->sendEmail);
foreach ($recipients as $recipient)
{
if (empty($recipient) || !$recipient instanceof User)
{
continue;
}
$data = array_merge(RescueUrl::getRescueInformation($recipient->email), $substitutions);
$this->exceptionsHandler->sendEmail('com_admintools.superuserslist', $recipient, $data);
}
}
catch (Exception $e)
{
// Joomla! 3.5 and later throw an exception when crap happens instead of suppressing it and returning false
}
}
}