| Current Path : /home/poneycluc/www/plugins/system/bfnetwork/bfnetwork/ |
| Current File : /home/poneycluc/www/plugins/system/bfnetwork/bfnetwork/bfAuditor.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
*/
use Joomla\CMS\Factory;
use Joomla\CMS\Version;
final class bfAudit
{
public $_timer;
public $_steps;
private $db;
private $_encryptedAndSuspectIds = [];
private $_needsCompatIds = [];
private $_encryptedIds = [];
private $_suspectIds = [];
private $_uploaderIds = [];
private $_hackedIds = [];
private $_mailerIds = [];
private $_notencryptedAndSuspectIds = [];
private $alreadyAddedRootDirs = false;
private $foundDirs;
private $foundFiles;
private $suspectfiles;
private $hackedfiles;
private $noMoreFoldersToScan = false;
private $noMoreFilesToScan = false;
private $deepscancomplete = false;
/**
* @var bool
*/
private $deepscanqueued = false;
private $tickOver = 0;
private $startTime;
private $endTime;
private $version;
private $platform;
private $scancomplete;
private $foundRecentlyModifiedFilesTotal;
private $hashfailedcount;
private $step;
private $connectorversion;
private $files_777;
private $hacked;
private $zerobytes;
private $folders_777;
private $hidden_folders;
private $hidden_files;
private $renamedtohidefiles;
private $nestedinstalls;
private $error_logs_seen;
private $encrypted_files;
private $large_files;
private $has_robots_modified;
private $user_hasdefaultuserids;
private $archive_files;
private $htaccess_files;
private $phpiniseen;
private $uploader;
private $mailer;
private $max_allowed_packet;
private $phpinwrongplace;
private $notcorefiles;
private $missingcorefiles;
private $modifiedfilessincelastaudit;
private $tmp_install_folders;
private $sqlfilesseen;
private $admintoolbreaches;
private $dotunderscorefilesseen;
private $skipped;
private $extensionsjson;
private $needscompat;
/**
* Set up the audit, reading from cached state if needed Also handles the uploading of the scanner config.
*
* @param stdClass $request The decrypted request
*/
public function __construct($request)
{
$this->_cleanUpStuff();
bfLog::log(_BF_SPEED);
if (_BF_API_DEBUG === true) {
error_reporting(\E_ALL);
ini_set('display_errors', 1);
}
// Check that the permissions are set correctly before proceeding
$this->_checkOurPerms();
// Connect to the database
$this->initDb();
// get Joomla version
$this->version = (new Version())->getShortVersion();
/*
* Should we abandon/clear the current audit and restart
*
* If this is the first time we are running then also reset
*/
if ((property_exists($request, 'forceRestart') && @$request->forceRestart) || (file_exists(
'./FIRSTRUN'
) && true === _BF_CONFIG_RESET_STATE_ON_UPGRADE)) {
$this->resetState();
}
// remove the trigger for the first run
if (file_exists('./FIRSTRUN')) {
@unlink('./FIRSTRUN');
}
// If there is a non encrypted md5's file then import it to the db
if (property_exists($request, 'NOTENCRYPTED') && array_key_exists('md5s', $request->NOTENCRYPTED)) {
// clean up first
$this->db->setQuery('TRUNCATE bf_core_hashes');
$this->db->execute();
$url = base64_decode($request->NOTENCRYPTED['md5s']);
$options = [
'http' => [
'method' => 'GET',
'header' => "Accept-language: en\r\n" .
'User-Agent: ' . $_SERVER['HTTP_HOST'] . "\r\n",
],
];
$context = stream_context_create($options);
// get the data from the request
// @ error supressor to hid errors when https:// wrapper is disabled in the server configuration by allow_url_fopen=0 in php.ini
$data = @file_get_contents($url, false, $context);
// F.M.L - I hate crap servers!
if (! $data) {
$ch = curl_init();
// Set up bare minimum CURL Options needed for mysites.guru
curl_setopt($ch, \CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, \CURLOPT_HEADER, false);
curl_setopt($ch, \CURLOPT_URL, $url);
curl_setopt($ch, \CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, \CURLOPT_USERAGENT, $_SERVER['HTTP_HOST']);
// Attempt to download using CURL and CURLOPT_SSL_VERIFYPEER set to TRUE
$data = curl_exec($ch);
// Did we succeed in getting something?????
if (! $data) {
/*
* ** CRAPPY SERVER ALERT ** CRAPPY SERVER ALERT ** CRAPPY SERVER ALERT ** CRAPPY SERVER ALERT **
*
* Ok try without validation of the SSL (gulp) but this is needed on some servers without a pem file
* and we need to be compatible as possible - even on crappy webhosts when they need us most ;-(
*/
curl_setopt($ch, \CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, \CURLOPT_SSL_VERIFYHOST, false);
// Second Attempt to download using CURL and CURLOPT_SSL_VERIFYPEER set to FALSE (gulp)
$data = curl_exec($ch);
}
if (PHP_VERSION_ID < 80000) {
curl_close($ch);
}
}
if ($data) {
if (! function_exists('gzinflate')) {
bfEncrypt::reply(
bfReply::ERROR,
'Your server doesnt meet the minimum requirements of Joomla - it has no gzinflate function in PHP!'
);
}
if (! gzinflate($data)) {
bfEncrypt::reply(
bfReply::ERROR,
'We could not download and inflate a required file from the CDN (Something wrong with the downloaded data or gzinflate of that data) - seek assistance from phil@phil-taylor.com'
);
}
if (! preg_match('/\<\!DOCTYPE\shtml\>/', gzinflate($data))) {
$dataLines = explode("\n", gzinflate($data));
// Import the md5s to the database - easier to query a db than a
// single file
$sql = 'INSERT INTO bf_core_hashes (filewithpath, hash) VALUES ';
$values = [];
foreach ($dataLines as $line) {
$parts = explode("\t", $line);
// Do it this way for speed, 1 query instead of 4000+ queries!
if (array_key_exists(1, $parts)) {
$values[] = sprintf('("/%s", "%s")', @$parts[0], @$parts[1]);
}
}
// import now!
$this->db->setQuery($sql . implode(' , ', $values));
$this->db->execute();
// memory cleanup
unset($parts);
unset($dataLines);
unset($data);
}
}
}
// get the base bfnetwork folder
$base = __DIR__;
// Save our patterns to a file
if (property_exists($request, 'NOTENCRYPTED') && array_key_exists('pattern', $request->NOTENCRYPTED)) {
bfLog::log('Saving audit pattern config');
$url = base64_decode($request->NOTENCRYPTED['pattern']);
$options = [
'http' => [
'method' => 'GET',
'header' => "Accept-language: en\r\n" .
'User-Agent: ' . $_SERVER['HTTP_HOST'] . "\r\n",
],
];
$context = stream_context_create($options);
// get the data from the request
// @ error supressor to hid errors when https:// wrapper is disabled in the server configuration by allow_url_fopen=0 in php.ini
$patterns = @file_get_contents($url, false, $context);
// F.M.L - I hate crap servers!
if (! $patterns) {
$ch = curl_init();
// Set up bare minimum CURL Options needed for mysites.guru
curl_setopt($ch, \CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, \CURLOPT_HEADER, false);
curl_setopt($ch, \CURLOPT_URL, $url);
curl_setopt($ch, \CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, \CURLOPT_USERAGENT, $_SERVER['HTTP_HOST']);
// Attempt to download using CURL and CURLOPT_SSL_VERIFYPEER set to TRUE
$patterns = curl_exec($ch);
// Did we succeed in getting something?????
if (! $patterns) {
/*
* ** CRAPPY SERVER ALERT ** CRAPPY SERVER ALERT ** CRAPPY SERVER ALERT ** CRAPPY SERVER ALERT **
*
* Ok try without validation of the SSL (gulp) but this is needed on some servers without a pem file
* and we need to be compatible as possible - even on crappy webhosts when they need us most ;-(
*/
curl_setopt($ch, \CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, \CURLOPT_SSL_VERIFYHOST, false);
// Second Attempt to download using CURL and CURLOPT_SSL_VERIFYPEER set to FALSE (gulp)
$patterns = curl_exec($ch);
}
if (PHP_VERSION_ID < 80000) {
curl_close($ch);
}
}
if (! $patterns) {
bfEncrypt::reply(
bfReply::ERROR,
'We could not download a required file from the CDN (Nothing downloaded!) - seek assistance from phil@phil-taylor.com'
);
}
if (false === file_put_contents($base . '/tmp/tmp.pattern', $patterns)) {
bfEncrypt::reply(bfReply::ERROR, 'Could not save audit patterns to ' . $base . '/tmp/tmp.pattern');
}
// finally as a last ditch attempt - ensure its worth running an audit!
if (! filesize($base . '/tmp/tmp.pattern')) {
bfEncrypt::reply(bfReply::ERROR, 'We have no audit config to run with - this is fatal - seek assistance!');
}
}
if (property_exists($request, 'NOTENCRYPTED') && array_key_exists('config', $request->NOTENCRYPTED)) {
// just in case
if (! is_writable($base . '/bfConfig.php')) {
// @ error supressor to hid errors when crappy servers dont allow chmod from php
@chmod($base . '/bfConfig.php', 0777);
}
// write config to file
file_put_contents($base . '/bfConfig.php', gzinflate(base64_decode($request->NOTENCRYPTED['config'])));
// reset permissions to be more secure
// @ error supressor to hid errors when crappy servers dont allow chmod from php
@chmod($base . '/bfConfig.php', 0644);
}
// reset permissions - just to be sure!
// @ error supressor to hid errors when crappy servers dont allow chmod from php
@chmod($base . '/tmp/', 0755);
// remove all the request
unset($request);
bfLog::log('Waking the lab rats from their sleep...');
// Get the current status from the database
$this->wakeUp();
// init the timer
bfLog::log('Priming the lab rats with a timer...');
$this->_timer = bfTimer::getInstance();
// init the steps
bfLog::log('Teaching the lab rats to dance...');
$this->_steps = new STEP($this->step);
// belt and braces - check we have a step
if (! $this->step) {
$this->step = STEP::TESTCONNECTION;
}
}
/**
* Remove all the fluff that we need to, Including old crap that we used to have installed and we dont need any
* longer.
*/
private function _cleanUpStuff()
{
}
/**
* Checks and sets permissions on files/folders as tight as we can be
* depending on the environment this script is running in
* - I dont want 0777 but sometimes its required on some stupid environments
* :-(.
*/
private function _checkOurPerms()
{
// attempt to ensure our tmp folder is writable
if (! is_writable(__DIR__ . '/tmp')) {
@chmod(__DIR__ . '/tmp', 0755);
}
// Argh!
if (! is_writable(__DIR__ . '/tmp')) {
@chmod(__DIR__ . '/tmp', 0777);
}
// Give Up!
if (! is_writable(__DIR__ . '/tmp')) {
bfEncrypt::reply(bfReply::ERROR, 'Our ' . __DIR__ . '/tmp folder on your site is not writable!');
}
// attempt to ensure our folder is writable
if (! is_writable(__DIR__)) {
@chmod(__DIR__, 0755);
}
// Argh!
if (! is_writable(__DIR__)) {
@chmod(__DIR__, 0777);
}
// Give Up!
if (! is_writable(__DIR__)) {
bfEncrypt::reply(bfReply::ERROR, __DIR__ . '/ folder not writeable');
}
}
/**
* Init the Joomla db connection.
*
* @todo Rip this out and use our own database connection
*/
private function initDb()
{
bfLog::log('init database connection...');
// require all we need to access Joomla API
if (! defined('BF_JOOMLA_INIT_DONE')) {
require_once 'bfInitJoomla.php';
}
$this->db = Factory::getContainer()->get('DatabaseDriver');
}
/**
* Reset the state of our audit, cleaning files and database.
*/
private function resetState()
{
bfLog::log('Creating our database tables');
$this->db->setQuery('SHOW TABLES LIKE "bf_files_last"');
if ($this->db->loadResult()) {
$this->db->setQuery('DROP TABLE IF EXISTS `bf_files_last`');
$this->db->execute();
}
$this->db->setQuery('SHOW TABLES LIKE "bf_files"');
if ($this->db->loadResult()) {
$this->db->setQuery('RENAME TABLE `bf_files` TO `bf_files_last`');
$this->db->execute();
$this->db->setQuery('ANALYZE TABLE `bf_files_last`');
$this->db->execute();
}
// Drop and recreate our database tables
$sql = file_get_contents('./db/blank.sql');
$sqls = explode(';', $sql);
foreach ($sqls as $sql) {
if ('' != trim($sql)) {
$this->db->setQuery($sql);
$this->db->execute();
}
}
// remove any tmp files we might have created
@unlink(__DIR__ . '/tmp/tmp.md5s');
@unlink(__DIR__ . '/tmp/tmp.pattern');
@unlink(__DIR__ . '/tmp/tmp.pattern.unenc');
@unlink(__DIR__ . '/tmp/tmp.false');
@unlink(__DIR__ . '/tmp/tmp.log');
@unlink(__DIR__ . '/tmp/tmp.ob');
@unlink(__DIR__ . '/tmp/large.sql');
@unlink(__DIR__ . '/tmp/large1.sql');
@unlink(__DIR__ . '/tmp/large2.sql');
@unlink(__DIR__ . '/tmp/large3.sql');
@unlink(__DIR__ . '/tmp/large4.sql');
@unlink(__DIR__ . '/tmp/large5.sql');
@unlink(__DIR__ . '/tmp/large6.sql');
@unlink(__DIR__ . '/tmp/speedup.sql');
@unlink(__DIR__ . '/tmp/STATE');
@unlink(__DIR__ . '/tmp/STATE.php');
@unlink(__DIR__ . '/tmp/Folders');
@unlink(__DIR__ . '/tmp/Files');
bfLog::truncate();
$this->logStartAudit();
}
private function logfinishAudit()
{
bfActivitylog::getInstance()->log(
'bfNetwork',
'',
'Audit Finished',
'onAuditFinished',
'bfNetwork',
null,
null,
'',
bfEvents::onAuditFinished,
'onAuditFinished',
bfEvents::onAuditFinished
);
}
private function logStartAudit()
{
bfActivitylog::getInstance()->log(
'bfNetwork',
'',
'Audit Started',
'onAuditStarted',
'bfNetwork',
null,
null,
'',
bfEvents::onAuditStarted,
'onAuditStarted',
bfEvents::onAuditStarted
);
}
/**
* Wake up the audit from the state files.
*/
public function wakeUp()
{
if (! file_exists('tmp/STATE.php')) {
return false;
}
// load state
$result = unserialize(str_replace(['<?php die();?>', '<? die();?>'], '', file_get_contents('tmp/STATE.php')));
// Doh!
if (! $result) {
return;
}
// populate state into worker
foreach ($result as $k => $v) {
$this->$k = $v;
}
}
/**
* Tick over.
*/
public function tick()
{
if (1 != $this->scancomplete) {
// init the start of the timer to prevent max time overruns
if (! $this->startTime) {
$this->startTime = time();
}
// increment the ticker, just shows how many ticks we have had
++$this->tickOver;
// Run the correct stepAction method
$function = $this->_steps->getStepFunction($this->step);
bfLog::log('Running method ' . $function);
$this->$function();
// sleep and die
bfLog::log('Sleeping in tick');
$this->saveState(false, __LINE__);
} else {
// Scan is already complete!
bfLog::log('Sleeping as scan already complete');
$this->saveState(true, __LINE__);
}
}
/**
* ZZZzzz...... Sleep state to the database to provide session persistance We need a few seconds to run this :-(.
*
* @param bool $alreadyComplete
*/
public function saveState($alreadyComplete = false, $line = 0)
{
// bfLog::log('Sleeping audit status to persistent db store');
// When did we complete this step/audit
$this->endTime = time();
// make sure we cache the connectorversion
$this->connectorversion = file_get_contents('./VERSION');
// Inject the state to the database
$obj = new stdClass();
foreach ($this as $k => $v) {
// Dont save private/system objects
if ('db' == $k || '_steps' == $k || '_timer' == $k || '_' == substr($k, 0, 1)) {
continue;
}
// convert objects and arrays to strings
if (is_object($v) || is_array($v)) {
$v = json_encode($v);
}
// inject to the object we will return
$obj->$k = $v;
}
// Save state
file_put_contents('tmp/STATE.php', '<?php die();?>' . serialize($obj));
// save the step we are on
$obj->step = (string) $this->_steps;
// report back to service with json object;
$obj->maxPHPMemoryUsed = round((memory_get_peak_usage(true) / 1048576), 2);
$obj->queuecount = $this->_getQueueCount('files');
// legacy
$obj->filestoscan = $obj->queuecount;
// Log tail for debugging online
// $obj->logtail = bfLog::getTail();
// close db
unset($this->db);
unset($this->_timer);
unset($this->_steps);
// go to sleep, but first tell the service we are dreaming...
bfEncrypt::reply(bfReply::SUCCESS, $obj);
}
/**
* See whats left in the queue.
*
* @return int The number of rows
*/
private function _getQueueCount($tbl)
{
$this->db->setQuery('SELECT count(*) FROM bf_' . $tbl . ' WHERE queued = 1');
return $this->db->loadResult();
}
/**
* This simply adds the JPATH_BASE / folders to the scan queue.
*/
public function scanningrootdirsAction()
{
// Before walking the tree, undo the JCE attack's hide-the-payload trick: a
// hashed-name folder dropped under api/language/ with unreadable perms. Left
// alone it both hides the dropped files and crashes the audit. Restore 0755 so
// the folder is scanned like any other.
$this->restoreApiLanguagePermissions();
// Add the root folder to the scan quque
$this->addDirToScanQueue($this->getFolders(JPATH_BASE));
// mark scan
$this->alreadyAddedRootDirs = true;
// move to the next scan step
$this->nextStepPlease();
}
/**
* The JCE "Nx-ploited" attack drops a payload folder with a hashed name (32 hex
* chars, e.g. api/language/5e0c51f89ac3d327199ac86803d52f4c) and chmods it so the
* web user cannot read it. That both hides the dropped files from the audit and
* throws DirectoryIterator, aborting the whole scan. Restore 0755 on any such folder
* (and the api/language parent if it too has been locked) so the audit can see in.
*/
private function restoreApiLanguagePermissions()
{
$apiLanguage = JPATH_BASE . '/api/language';
if (! is_dir($apiLanguage)) {
return;
}
// The parent itself may have been locked - reopen it so we can list inside.
if (! is_readable($apiLanguage)) {
@chmod($apiLanguage, 0755);
}
foreach ((array) @scandir($apiLanguage) as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $apiLanguage . '/' . $entry;
// Only the attack's hashed-name folders (32 hex chars), never real language
// packs such as en-GB. Only act when it is actually unreadable.
if (is_dir($path) && preg_match('/^[a-f0-9]{32}$/i', $entry) && ! is_readable($path)) {
@chmod($path, 0755);
bfLog::log('Restored 0755 on hidden JCE payload folder: ' . $path);
}
}
}
/**
* Add a folder to the scan queue.
*
* @param array $arr Array of folders to add to the queue
* @param int $queued
*
* @return array
*/
private function addDirToScanQueue($arr, $queued = 1)
{
$insertFolderToDb = [];
if (! count($arr)) {
return [];
}
// Update stats
$this->foundDirs = $this->foundDirs + count($arr);
bfLog::log('Adding ' . count($arr) . ' folders To the audit queue');
// skip if no folders in the array
if (false === $arr) {
return;
}
$parts = [];
foreach ($arr as $folder) {
// clean up
$folder = $this->wp_normalize_path($folder);
// Dont allow duplicates - ffs
$this->db->setQuery(sprintf('SELECT count(*) from bf_folders where folderwithpath = %s', $this->db->quote($folder)));
if ($this->db->loadResult()) {
bfLog::log(sprintf('WARNING: Skipping adding %s to db as its already there', $folder));
continue;
}
// Dont allow blank or invalid folders
if (! is_dir($this->wp_normalize_path(JPATH_BASE . \DIRECTORY_SEPARATOR . $folder))
|| ! is_dir($this->wp_normalize_path(JPATH_BASE . \DIRECTORY_SEPARATOR . $folder . \DIRECTORY_SEPARATOR))
|| is_link($this->wp_normalize_path(JPATH_BASE . \DIRECTORY_SEPARATOR . $folder . \DIRECTORY_SEPARATOR))
|| is_link($this->wp_normalize_path(JPATH_BASE . \DIRECTORY_SEPARATOR . $folder)) || ! $folder
) {
continue;
}
$perms = $this->_getFolderPerms($folder);
$insertFolderToDb[] = ' ( ' . $this->db->quote($folder) . ", '" . $perms . "', " . $queued . ')';
}
if ($insertFolderToDb && is_array($insertFolderToDb) && count($insertFolderToDb)) {
$sqlprefix = 'INSERT INTO bf_folders ( folderwithpath, folderinfo, queued) VALUES ';
$sqlToRun = $sqlprefix . implode(', ', $insertFolderToDb);
if (strlen($sqlToRun) > 1048576) {
$insertFolderToDb = $this->array_split($insertFolderToDb, 4);
$sqlToRun1 = $sqlprefix . implode(', ', $insertFolderToDb[0]);
bfLog::log('sql size 1= ' . strlen($sqlToRun1));
$this->db->setQuery($sqlToRun1);
$this->db->execute();
$sqlToRun2 = $sqlprefix . implode(', ', $insertFolderToDb[1]);
bfLog::log('sql size 2= ' . strlen($sqlToRun2));
$this->db->setQuery($sqlToRun2);
$this->db->execute();
$sqlToRun3 = $sqlprefix . implode(', ', $insertFolderToDb[2]);
bfLog::log('sql size 3= ' . strlen($sqlToRun3));
$this->db->setQuery($sqlToRun3);
$this->db->execute();
$sqlToRun4 = $sqlprefix . implode(', ', $insertFolderToDb[3]);
bfLog::log('sql size 4= ' . strlen($sqlToRun4));
$this->db->setQuery($sqlToRun4);
$this->db->execute();
} else {
$this->db->setQuery($sqlToRun);
$this->db->execute();
}
}
return [];
}
/**
* THANK YOU WORDPRESS !!! I love you xxx.
*
* @return mixed|string|string[]|null
*/
private function wp_normalize_path($path)
{
$path = str_replace('\\', '/', $path);
$path = preg_replace('|(?<=.)/+|', '/', $path);
if (':' === substr($path, 1, 1)) {
$path = ucfirst($path);
}
// Mine, removes // from the start of a path
if ('/' === substr($path, 0, 1) && '/' === substr($path, 1, 1)) {
$path = substr($path, 1, strlen($path) - 1);
}
return $path;
}
/**
* Clean up a string, a path name. Wrapper to wp_normalize_path which does a better job than we did.
*
* @param string $str
*
* @return string
*/
private function _cleanupFileFolderName($str)
{
return $this->wp_normalize_path($str);
}
/**
* Clean up the folder name and then get the right perms.
*
* @return string
*/
private function _getFolderPerms($folder)
{
$folder = $this->ensureRooted($this->_cleanupFileFolderName($folder));
$perms = substr(decoct(fileperms($folder)), 2);
return $perms;
}
/**
* Ensure that we are rooted to the JPATH_BASE.
*
* @param string $folder A filewithpath
*
* @return string
*/
private function ensureRooted($folder)
{
if (JPATH_BASE === '/' && '/' === substr($folder, 0, 1)) {
return $folder;
}
$str = $this->wp_normalize_path(
JPATH_BASE . str_replace($this->wp_normalize_path(JPATH_BASE), '', $this->wp_normalize_path(
$this->_cleanupFileFolderName($folder)
))
);
return $str;
}
private function removeJPATHBASE($str)
{
if (JPATH_BASE === '/' && '/' === substr($str, 0, 1)) {
return $str;
}
return str_replace($this->wp_normalize_path(JPATH_BASE), '', $this->wp_normalize_path($this->ensureRooted($str)));
}
/**
* Spilt an array.
*
* @param int $pieces
*
* @return array
*/
private function array_split($array, $pieces = 2)
{
if ($pieces < 2) {
return [$array];
}
$newCount = ceil(count($array) / $pieces);
$a = array_slice($array, 0, $newCount);
$b = $this->array_split(array_slice($array, $newCount), $pieces - 1);
return array_merge([$a], $b);
}
private function getFolders($folder)
{
// Initialize variables
$arr = [];
$folder = trim($folder);
if (! is_dir($folder) && ! is_dir($folder . \DIRECTORY_SEPARATOR) || is_link($folder . \DIRECTORY_SEPARATOR) || is_link(
$folder
) || ! $folder) {
return [];
}
if (@file_exists($folder . \DIRECTORY_SEPARATOR . '.myjoomla.ignore.folder')) {
return [];
}
// A folder the web user cannot read (chmod 000 / wrong owner) throws from
// DirectoryIterator and, left unhandled, aborts the entire audit. Attackers
// exploit this to hide a dropped payload folder (e.g. a hashed name under
// api/language). Try to restore readable perms first; only skip if that fails,
// so the audit completes and the files inside still get scanned.
if (! is_readable($folder)) {
@chmod($folder, 0755);
if (! is_readable($folder)) {
return $arr;
}
}
try {
$dir = new \DirectoryIterator($folder);
foreach ($dir as $fileinfo) {
if (! $fileinfo->isDot() && $fileinfo->isDir()) {
$arr[] = $this->removeJPATHBASE($folder . \DIRECTORY_SEPARATOR . $fileinfo->getFilename());
}
}
} catch (\Exception $e) {
return $arr;
}
unset($dir);
unset($fileinfo);
unset($folder);
return $arr;
}
/**
* Function taken from Akeeba filesystem.php.
*
* @copyright Copyright (c)2009 Nicholas K. Dionysopoulos
* @license GNU GPL version 3 or, at your option, any later version
*
* @version Id: scanner.php 158 2010-06-10 08:46:49Z nikosdion
*/
private function getFoldersOLD($folder)
{
// Initialize variables
$arr = [];
$false = false;
$folder = trim($folder);
if (! is_dir($folder) && ! is_dir($folder . \DIRECTORY_SEPARATOR) || is_link($folder . \DIRECTORY_SEPARATOR) || is_link(
$folder
) || ! $folder) {
return $false;
}
if (@file_exists($folder . \DIRECTORY_SEPARATOR . '.myjoomla.ignore.folder')) {
return [];
}
$handle = @opendir($folder);
if (false === $handle) {
$handle = @opendir($folder . \DIRECTORY_SEPARATOR);
}
// If directory is not accessible, just return FALSE
if (false === $handle) {
return $false;
}
while ((false !== ($file = @readdir($handle)))) {
if (('.' != $file) && ('..' != $file) && (null != trim($file))) {
$ds = ('' == $folder) || (\DIRECTORY_SEPARATOR == $folder) || (\DIRECTORY_SEPARATOR == @substr(
$folder,
-1
)) || (\DIRECTORY_SEPARATOR == @substr($folder, -1)) ? '' : \DIRECTORY_SEPARATOR;
$dir = trim($folder . $ds . $file);
$isDir = @is_dir($dir);
if ($isDir) {
$arr[] = $this->removeJPATHBASE($folder . \DIRECTORY_SEPARATOR . $file);
}
}
}
@closedir($handle);
return $arr;
}
/**
* Set pointer to the next step.
*/
private function nextStepPlease($alsoSleep = false)
{
bfLog::log('Ticking over to the next step');
$this->step = $this->_steps->nextStepPlease();
if (true === $alsoSleep) {
$this->saveState(false, __LINE__);
}
}
/**
* @see http://davidwalsh.name/php-file-extension
*
* @return string
*/
public function get_file_extension($file_name)
{
return substr(strrchr($file_name, '.'), 1);
}
/**
* dummy method.
*/
private function requestscannerconfigAction()
{
$this->nextStepPlease();
}
/**
* I never get here unless all is done :).
*/
private function completeAction()
{
// Mark the audit as complete
$this->scancomplete = 1;
// cleanup
@unlink('tmp/tmp.md5s');
@unlink('tmp/tmp.pattern');
@unlink('tmp/tmp.false');
@unlink('tmp/Folders');
@unlink('tmp/Files');
bfLog::log('===== AUDIT COMPLETE =====');
$this->logfinishAudit();
}
/**
* @deprecated
*
* Get information about the datbaase
*/
private function dbinfoAction()
{
// move onto the next step
$this->nextStepPlease();
}
/**
* Do we have any backup tables.
*
* @return string
*/
private function _hasBakTables()
{
$config = Factory::getApplication()->getConfig();
$dbname = $config->get('db', '');
$this->db->setQuery("SHOW TABLES WHERE `Tables_in_{$dbname}` like 'bak_%'");
return $this->db->loadResult() ? 'TRUE' : 'FALSE';
}
private function testconnectionAction()
{
if (isset($_SERVER['HTTP_HOST']) && (strpos($_SERVER['HTTP_HOST'], 'sg') !== false||strpos(
$_SERVER['HTTP_HOST'],
'siteground'
) !== false)) {
$this->nextStepPlease(true);
}
// Ask Joomla API for some settings
$config = Factory::getApplication()->getConfig();
try {
// Send an email to see if we received it... Tests if the Joomla Global Config mailer settings are correct.
$mailer = Factory::getMailer();
$sender = [$config->get('mailfrom'), $config->get('fromname')];
$mailer->setSender($sender);
$mailer->addRecipient(
'AuditMailerTest@myjoomla.io'
); // This is not a real mailbox, its a service that reads the body of the email, and lets the mysites.guru service know the domain name.
$mailer->setSubject('Audit Mailer Test');
// We do it this way for a reason, catching badly configured proxies and servers
$s = '';
if (! empty($_SERVER['HTTPS']) && 'on' == $_SERVER['HTTPS']) {
$s = 's';
}
$protocol = substr(strtolower($_SERVER['SERVER_PROTOCOL']), 0, strpos(strtolower($_SERVER['SERVER_PROTOCOL']), '/')) . $s;
$port = ('80' == $_SERVER['SERVER_PORT']) ? '' : (':' . $_SERVER['SERVER_PORT']);
$uri = $protocol . '://' . $_SERVER['SERVER_NAME'] . $port . $_SERVER['REQUEST_URI'];
$segments = explode('?', $uri, 2);
$url = $segments[0];
$url = str_replace(['plugins/system/bfnetwork/bfAudit.php', 'plugins/system/bfnetwork/bfnetwork/bfAudit.php'], '', $url);
$mailer->setBody($url); // ONLY THE URL OF THE SITE IS SENT - NO OTHER DATA
$mailer->Send();
} catch (Exception $e) {
}
// move onto the next step
$this->nextStepPlease();
}
/**
* @deprecated to snapshot
*/
private function compileextensionsAction()
{
$this->nextStepPlease();
}
private function verifyextensionsAction()
{
require 'bfExtensions.php';
$ext = new bfExtensions();
$this->extensionsjson = $ext->getExtensions();
$this->nextStepPlease();
}
/**
* Report on the last 3 days worth of modified files, excluding ours.
*/
private function lookingupmodifiedfilesAction()
{
$time = strtotime('-3 days', time());
$sql = "SELECT COUNT(*) FROM bf_files WHERE filemtime > '%s'
AND filewithpath NOT LIKE '/plugins/system/bfnetwork%%'";
$this->db->setQuery(sprintf($sql, $time));
$this->foundRecentlyModifiedFilesTotal = $this->db->LoadResult();
// move onto the next step
$this->nextStepPlease();
}
/**
* Scan folders and save the files that we find in the database If we have saved large sql files that contain
* queries then run those and tick over.
*/
private function initialscanningfilesAction()
{
if (file_exists('tmp/large.sql')) {
bfLog::log('Running a cached LARGE SQL insert');
$sql = file_get_contents('tmp/large.sql');
if (trim($sql)) {
$this->db->setQuery($sql);
try {
$this->db->execute();
} catch (\Exception $exception) {
bfLog::log('====== SQL ERROR ======');
bfLog::log($exception->getMessage());
bfLog::log($this->db->getQuery());
}
}
unlink('tmp/large.sql');
$this->saveState(false, __LINE__);
}
if (file_exists('tmp/large1.sql')) {
bfLog::log('Running a cached LARGE1 SQL insert');
$sql = file_get_contents('tmp/large1.sql');
if (trim($sql)) {
$this->db->setQuery($sql);
try {
$this->db->execute();
} catch (\Exception $exception) {
bfLog::log('====== SQL ERROR ======');
bfLog::log($exception->getMessage());
bfLog::log($this->db->getQuery());
}
}
unlink('tmp/large1.sql');
$this->saveState(false, __LINE__);
}
if (file_exists('tmp/large2.sql')) {
bfLog::log('Running a cached LARGE2 SQL insert');
$sql = file_get_contents('tmp/large2.sql');
if (trim($sql)) {
$this->db->setQuery($sql);
try {
$this->db->execute();
} catch (\Exception $exception) {
bfLog::log('====== SQL ERROR ======');
bfLog::log($exception->getMessage());
bfLog::log($this->db->getQuery());
}
}
unlink('tmp/large2.sql');
$this->saveState(false, __LINE__);
}
if (file_exists('tmp/large3.sql')) {
bfLog::log('Running a cached LARGE3 SQL insert');
$sql = file_get_contents('tmp/large3.sql');
if (trim($sql)) {
$this->db->setQuery($sql);
try {
$this->db->execute();
} catch (\Exception $exception) {
bfLog::log('====== SQL ERROR ======');
bfLog::log($exception->getMessage());
bfLog::log($this->db->getQuery());
}
}
unlink('tmp/large3.sql');
$this->saveState(false, __LINE__);
}
if (file_exists('tmp/large4.sql')) {
bfLog::log('Running a cached LARGE4 SQL insert');
$sql = file_get_contents('tmp/large4.sql');
if (trim($sql)) {
$this->db->setQuery($sql);
try {
$this->db->execute();
} catch (\Exception $exception) {
bfLog::log('====== SQL ERROR ======');
bfLog::log($exception->getMessage());
bfLog::log($this->db->getQuery());
}
}
unlink('tmp/large4.sql');
$this->saveState(false, __LINE__);
}
if (file_exists('tmp/large5.sql')) {
bfLog::log('Running a cached LARGE5 SQL insert');
$sql = file_get_contents('tmp/large5.sql');
if (trim($sql)) {
$this->db->setQuery($sql);
try {
$this->db->execute();
} catch (\Exception $exception) {
bfLog::log('====== SQL ERROR ======');
bfLog::log($exception->getMessage());
bfLog::log($this->db->getQuery());
}
}
unlink('tmp/large5.sql');
$this->saveState(false, __LINE__);
}
if (file_exists('tmp/large6.sql')) {
bfLog::log('Running a cached LARGE6 SQL insert');
$sql = file_get_contents('tmp/large6.sql');
if (trim($sql)) {
$this->db->setQuery($sql);
try {
$this->db->execute();
} catch (\Exception $exception) {
bfLog::log('====== SQL ERROR ======');
bfLog::log($exception->getMessage());
bfLog::log($this->db->getQuery());
}
}
unlink('tmp/large6.sql');
$this->saveState(false, __LINE__);
}
// See how much is left
$this->db->setQuery('SELECT COUNT(*) FROM bf_folders WHERE queued = 1');
$totalLeft = $this->db->loadResult();
// re-set the sql because mysqlpdo in Joomla borks when trying to run loadResult twice, with 0 - 00000, , :-(
// Time wasted: days and days and days...
$this->db->setQuery('SELECT COUNT(*) FROM bf_folders WHERE queued = 1');
// Nothing left so die
if (! $totalLeft) {
// Get all the files with core hash changes :-(
$sql = 'SELECT f.id FROM bf_files AS f
LEFT JOIN bf_core_hashes AS ch ON ch.filewithpath = f.filewithpath
WHERE ch.hash != f.currenthash
AND
f.currenthash != "Unable To Calc Hash"
AND
f.currenthash != "Too Big To Hash"
';
$this->db->setQuery($sql);
if (method_exists($this->db, 'loadColumn')) {
$ids = $this->db->loadColumn();
} else {
$ids = $this->db->loadResultArray();
}
if ($ids && count($ids)) {
bfLog::log('Found ' . count($ids) . ' Core file hashes failed');
$sql = 'UPDATE bf_files SET hashfailed = 1 WHERE id IN (' . implode(', ', $ids) . ')';
file_put_contents('tmp/hashfailed.sql', $sql);
}
// set all the core file flags
$sql = 'SELECT f.id FROM bf_files AS f
WHERE filewithpath IN(
SELECT filewithpath FROM bf_core_hashes
)';
$this->db->setQuery($sql);
if (method_exists($this->db, 'loadColumn')) {
$ids = $this->db->loadColumn();
} else {
$ids = $this->db->loadResultArray();
}
if ($ids && count($ids)) {
bfLog::log('Matched ' . count($ids) . ' Core files');
$sql = 'UPDATE bf_files SET iscorefile = 1 WHERE id IN (' . implode(', ', $ids) . ')';
file_put_contents('tmp/corefiles.sql', $sql);
}
$this->noMoreFilesToScan = true;
$this->nextStepPlease(true);
}
$removeFoldersFromQueueIds = [];
// yes run the query again, allows for the while loop nicely, also only
// loop while we have time
while ($this->db->loadResult() > 0 && $this->_timer->getTimeLeft() > _BF_CONFIG_FILES_TIMER_ONE) {
// ok so we have a load of folders...
if ($removeFoldersFromQueueIds && count($removeFoldersFromQueueIds)) {
$this->db->setQuery(
'SELECT id, folderwithpath FROM bf_folders WHERE queued = 1 AND id NOT IN (' . implode(
', ',
$removeFoldersFromQueueIds
) . ') ORDER BY id ASC LIMIT ' . _BF_CONFIG_FILES_COUNT_ONE
);
} else {
$this->db->setQuery(
'SELECT id, folderwithpath FROM bf_folders WHERE queued = 1 ORDER BY id ASC LIMIT ' . _BF_CONFIG_FILES_COUNT_ONE
);
}
$dirs_to_scan = $this->db->loadObjectList();
while (count($dirs_to_scan) && $this->_timer->getTimeLeft() > _BF_CONFIG_FILES_TIMER_ONE) {
// sql values to imploe to the insert
$sqlvalues = [];
// get a diretory object to scan
$dirToScanObj = array_pop($dirs_to_scan);
// extract the folder
$dirToScan = $dirToScanObj->folderwithpath;
$dirToScan = str_replace('////', '/', $dirToScan);
// remove this current dir form the scan queue - quickly incase we get into indefinite loop;
//$this->removeFromQueue('folders', array($dirToScanObj->id));
$removeFoldersFromQueueIds[] = $dirToScanObj->id;
// Make sure we have a absolute path to the folder
$dirToScanWithPath = $this->ensureRooted($dirToScan);
$filesInThisFolder = $this->getFiles($dirToScanWithPath);
bfLog::log(
'Found ' . (is_array($filesInThisFolder) || $filesInThisFolder instanceof \Countable ? count(
$filesInThisFolder
) : 0) . ' files in ' . $this->removeJPATHBASE($dirToScan)
);
// If there are any files, and we have time left
if ($filesInThisFolder && count($filesInThisFolder) && $this->_timer->getTimeLeft() > _BF_CONFIG_FILES_TIMER_TWO) {
// for each file then get the info
foreach ($filesInThisFolder as $file) {
// ok are we getting short of time yet?
if ($this->_timer->getTimeLeft() <= _BF_CONFIG_FILES_TIMER_TWO) {
$this->db->setQuery('/*6*/ INSERT INTO bf_files
(filewithpath, fileperms, filemtime, currenthash, size) VALUES '
. implode(', ', $sqlvalues));
$this->db->execute();
$this->removeFromQueue('folders', $removeFoldersFromQueueIds);
$this->saveState(false, __LINE__);
}
// with full path
$fileBase = $this->_cleanupFileFolderName($this->ensureRooted($dirToScanWithPath) . \DIRECTORY_SEPARATOR . $file);
// Get the file Info...
$fileInfo = $this->_getFileInfo($fileBase);
// with no JPATH_BASE
$fileBase = $this->removeJPATHBASE($fileBase);
$fileBase = $this->db->quote((string) $fileBase);
// create the insert
$sqlinsert = " (%s, '%s', '%s', '%s', '%s') ";
// cache the insert so that we can insert many rows for performance
$sqlvalues[] = sprintf(
$sqlinsert,
$fileBase,
$fileInfo['perms'],
$fileInfo['mtime'],
$fileInfo['currenthash'],
$fileInfo['size']
);
// count
++$this->foundFiles;
}
if (count($filesInThisFolder) > 200) {
bfLog::log('Sleeping as we had more than 200 files in this folder... we are saving: ' . count($filesInThisFolder));
$sqlvaluesParts = $this->array_split($sqlvalues, 6);
file_put_contents(
'tmp/large1.sql',
'/*1*/ INSERT INTO bf_files (filewithpath, fileperms, filemtime, currenthash, size)
VALUES ' . implode(', ', $sqlvaluesParts[0])
);
file_put_contents(
'tmp/large2.sql',
'/*2*/ INSERT INTO bf_files (filewithpath, fileperms, filemtime, currenthash, size)
VALUES ' . implode(', ', $sqlvaluesParts[1])
);
file_put_contents(
'tmp/large3.sql',
'/*3*/ INSERT INTO bf_files (filewithpath, fileperms, filemtime, currenthash, size)
VALUES ' . implode(', ', $sqlvaluesParts[2])
);
file_put_contents(
'tmp/large4.sql',
'/*4*/ INSERT INTO bf_files (filewithpath, fileperms, filemtime, currenthash, size)
VALUES ' . implode(', ', $sqlvaluesParts[3])
);
file_put_contents(
'tmp/large5.sql',
'/*5*/ INSERT INTO bf_files (filewithpath, fileperms, filemtime, currenthash, size)
VALUES ' . implode(', ', $sqlvaluesParts[4])
);
file_put_contents(
'tmp/large6.sql',
'/*6*/ INSERT INTO bf_files (filewithpath, fileperms, filemtime, currenthash, size)
VALUES ' . implode(', ', $sqlvaluesParts[5])
);
bfLog::log('Large SQL files stored for processing...');
$this->removeFromQueue('folders', $removeFoldersFromQueueIds);
$this->saveState(false, __LINE__);
}
// Save to the database when we get short of time
if ($this->_timer->getTimeLeft() <= _BF_CONFIG_FILES_TIMER_TWO) {
try {
$this->db->setQuery('/*5*/INSERT INTO bf_files
(filewithpath, fileperms, filemtime, currenthash, size) VALUES '
. implode(', ', $sqlvalues));
$this->db->execute();
} catch (\Exception $exception) {
bfLog::log('====== SQL ERROR ======');
bfLog::log($exception->getMessage());
bfLog::log($this->db->getQuery());
}
$sqlvalues = [];
$this->removeFromQueue('folders', $removeFoldersFromQueueIds);
$this->saveState(false, __LINE__);
}
}
// Save to the database
if (is_array($sqlvalues) && count($sqlvalues)) {
try {
$this->db->setQuery('/*7*/INSERT INTO bf_files
(filewithpath, fileperms, filemtime, currenthash, size) VALUES '
. implode(', ', $sqlvalues));
$this->db->execute();
} catch (\Exception $exception) {
bfLog::log('====== SQL ERROR ======');
bfLog::log($exception->getMessage());
bfLog::log($this->db->getQuery());
}
$sqlvalues = [];
}
// set up for the while loop again
if (count($removeFoldersFromQueueIds)) {
$this->db->setQuery(
'SELECT count(*) FROM bf_folders WHERE queued = 1 AND id NOT IN (' . implode(', ', $removeFoldersFromQueueIds) . ')'
);
} else {
$this->db->setQuery('SELECT COUNT(*) FROM bf_folders WHERE queued = 1');
}
// are we nearly there yet?
if ($this->_timer->getTimeLeft() <= _BF_CONFIG_FILES_TIMER_TWO) {
$this->removeFromQueue('folders', $removeFoldersFromQueueIds);
$this->saveState(false, __LINE__);
}
}
}
$this->removeFromQueue('folders', $removeFoldersFromQueueIds);
}
private function getFiles($folder)
{
// Initialize variables
$arr = [];
$folder = trim($folder);
if (! is_dir($folder) && ! is_dir($folder . \DIRECTORY_SEPARATOR) || is_link($folder . \DIRECTORY_SEPARATOR) || is_link(
$folder
) || ! $folder) {
return $arr;
}
if (@file_exists($folder . \DIRECTORY_SEPARATOR . '.myjoomla.ignore.files')) {
return $arr;
}
// See getFolders(): an unreadable folder throws and would abort the audit.
// Try to restore readable perms first; only skip if that still fails.
if (! is_readable($folder)) {
@chmod($folder, 0755);
if (! is_readable($folder)) {
return $arr;
}
}
try {
$dir = new \DirectoryIterator($folder);
foreach ($dir as $fileinfo) {
if (! $fileinfo->isDot() && $fileinfo->isFile()) {
$arr[] = $this->_cleanupFileFolderName($fileinfo->getFilename());
}
}
} catch (\Exception $e) {
return $arr;
}
unset($dir);
unset($fileinfo);
unset($folder);
return $arr;
}
/**
* @param array $updateIds
*
* @return array
*/
private function removeFromQueue($tbl, $updateIds = [])
{
if (count($updateIds)) {
bfLog::log('Removing ' . count($updateIds) . ' ' . $tbl . ' from the queue');
$sql = 'UPDATE bf_' . $tbl . ' SET queued = 0 WHERE id IN (' . implode(', ', $updateIds) . ')';
$this->db->setQuery($sql);
$this->db->execute();
}
return [];
}
/**
* There are a lot of error supression @'s in this method, mainly to handle fringe cases where we dont have
* permissions or some other fringe error happens.
*
* @param string $file
*
* @return array
*/
private function _getFileInfo($file)
{
// clean up
$file = stripslashes($this->_cleanupFileFolderName($file));
$fileInfo = [];
// Get the File Permissions - if we are allowed (Hence the @)
$fileInfo['perms'] = @substr(@decoct(@fileperms($file)), 2);
// Get the File Modification Time - if we are allowed (Hence the @)
$fileInfo['mtime'] = @filemtime($file);
// Get the File Size - if we are allowed (Hence the @)
$size = @filesize($file);
$fileInfo['size'] = $size;
if (! $fileInfo['size']) {
$fileInfo['size'] = '0';
}
// Out of range value for column 'size' when finding a huge file like a backup
if ($fileInfo['size'] > 2147483640) {
$fileInfo['size'] = 2147483640;
}
// only hash small files
if ($size < 2097152) { // 2 megabyte = 2,097,152 bytes
// We need a @ incase of "failed to
// open stream: Permission denied"
$hash = @md5_file($file);
// something went wrong
if (! $hash) {
$hash = 'Unable To Calc Hash';
}
} else {
$hash = 'Too Big To Hash';
}
// save the has
$fileInfo['currenthash'] = $hash;
return $fileInfo;
}
/**
* Scan folders and find more files in them.
*/
private function initialscanningfoldersAction()
{
$deleteFoldersIds = [];
$addToScanQueue = [];
$break = false;
$count = 0;
// See if we have any folders to scan
$this->db->setQuery('SELECT COUNT(*) FROM bf_folders WHERE queued = 1');
$totalLeft = $this->db->loadResult();
// re-set the sql because mysqlpdo in Joomla borks when trying to run loadResult twice, with 0 - 00000, , :-(
// Time wasted: days and days and days...
$this->db->setQuery('SELECT COUNT(*) FROM bf_folders WHERE queued = 1');
if (! $totalLeft) {
$addToScanQueue = $this->addDirToScanQueue(['/'], 0);
$this->toggleQueued('folders', 1);
// move on, and die
$this->noMoreFoldersToScan = true;
$this->nextStepPlease(true);
}
// We have some folders to look into and we have some time left
while ($this->db->loadResult() > 0 && $this->_timer->getTimeLeft() > _BF_CONFIG_FOLDERS_TIMER_ONE) {
// ok so we have a load of folders...
if (count($deleteFoldersIds)) {
$this->db->setQuery(
'SELECT id, folderwithpath FROM bf_folders WHERE queued = 1 AND id NOT IN (' . implode(
',',
$deleteFoldersIds
) . ') ORDER BY id ASC LIMIT ' . _BF_CONFIG_FOLDERS_COUNT_ONE
);
} else {
$this->db->setQuery(
'SELECT id, folderwithpath FROM bf_folders WHERE queued = 1 ORDER BY id ASC LIMIT ' . _BF_CONFIG_FOLDERS_COUNT_ONE
);
}
$dirs_to_scan = $this->db->loadObjectList();
while (count($dirs_to_scan) && $this->_timer->getTimeLeft() > _BF_CONFIG_FOLDERS_TIMER_ONE) {
$dirToScanObj = array_pop($dirs_to_scan);
$dirToScan = $this->wp_normalize_path($dirToScanObj->folderwithpath);
// Redundant?
if ($this->_timer->getTimeLeft() <= _BF_CONFIG_FOLDERS_TIMER_TWO) {
$addToScanQueue = $this->addDirToScanQueue($addToScanQueue);
$deleteFoldersIds = $this->removeFromQueue('folders', $deleteFoldersIds);
$this->saveState(false, __LINE__); // Exits with reply
}
// Get the subdirectories in this folder and add to the list of folders to scan enforce only from base root...
$dirToScanWithPath = $this->ensureRooted($dirToScan);
// but if our$dirToScanWithPath is now blank it means a path of /var/www/var/www !
if (JPATH_BASE == $dirToScanWithPath) {
// need this else we loop when /home/public_html/home/public_html is found!!
$dirToScanWithPath = $this->wp_normalize_path(JPATH_BASE . $dirToScan);
}
$subDirectorys = $this->getFolders($dirToScanWithPath);
bfLog::log(
'Found ' . (is_array($subDirectorys) || $subDirectorys instanceof \Countable ? count(
$subDirectorys
) : 0) . ' subfolders in ' . $this->removeJPATHBASE($dirToScan)
);
if ((is_array($subDirectorys) || $subDirectorys instanceof \Countable) && count($subDirectorys)) {
foreach ($subDirectorys as $folder) {
$folder = $this->wp_normalize_path($folder);
$addToScanQueue[] = $folder;
}
} else {
bfLog::log('Found NO subfolders in ' . $this->removeJPATHBASE($dirToScan));
}
$deleteFoldersIds[] = $dirToScanObj->id;
if ((count($deleteFoldersIds) > 1000) || (count(
$addToScanQueue
) > 1000) || $this->_timer->getTimeLeft() <= _BF_CONFIG_FOLDERS_TIMER_TWO) {
// remove this current dir form the scan queue;
sleep(2);
$addToScanQueue = $this->addDirToScanQueue($addToScanQueue);
$deleteFoldersIds = $this->removeFromQueue('folders', $deleteFoldersIds);
$this->saveState(false, __LINE__); // Exits with reply
}
}
$this->db->setQuery(
'SELECT count(*) FROM bf_folders WHERE queued = 1 AND id NOT IN (' . implode(',', $deleteFoldersIds) . ')'
);
}
$this->addDirToScanQueue($addToScanQueue);
$this->removeFromQueue('folders', $deleteFoldersIds);
$this->saveState(false, __LINE__); // Exits with reply
}
private function toggleQueued($tblSuffix, $queued)
{
$sql = 'UPDATE bf_' . $tblSuffix . ' SET queued = ' . $queued;
$this->db->setQuery($sql);
$this->db->execute();
}
/**
* Deep scan.
*/
private function deepscanAction()
{
$doSpeedup = null;
if (file_exists('tmp/corefiles.sql')) {
bfLog::log('Found core files - marking them as such');
$this->db->setQuery(file_get_contents('tmp/corefiles.sql'));
$this->db->execute();
unlink('tmp/corefiles.sql');
$this->saveState(false, __LINE__);
}
if (file_exists('tmp/hashfailed.sql')) {
bfLog::log('Found modified core files - adding to deepscan');
$this->db->setQuery(file_get_contents('tmp/hashfailed.sql'));
$this->db->execute();
unlink('tmp/hashfailed.sql');
$this->saveState(false, __LINE__);
}
if (file_exists(__DIR__ . '/tmp/currentlyAuditingFile.php')) {
bfLog::log('Found currentlyAuditingFile - setting it skipped');
$file = explode('||', file_get_contents(__DIR__ . '/tmp/currentlyAuditingFile.php'))[1];
$this->db->setQuery('UPDATE bf_files SET queued = 0, skipped = 1 WHERE filewithpath = "' . $file . '"');
$this->db->execute();
$this->skipped = $this->skipped + 1;
unlink(__DIR__ . '/tmp/currentlyAuditingFile.php');
}
try {
// if we are not complete
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE queued = 1');
$queueCount = $this->db->loadResult();
if (0 == $queueCount && true == $this->deepscancomplete) {
// left here in case needed again.
}
if (! $queueCount && ! $this->deepscancomplete && ! $this->deepscanqueued) {
bfLog::log('Adding files to the scan queue');
/**
* Yes I know that this sql gives places for hackers to hide, however its based on extensive, active,
* experience and is often changed to reflect current trends.
*/
$sql = "UPdATE bf_files SET queued = 1
WHERE
(
SIZE < 1000000 -- 1mb Limit
AND
SIZE > 0 -- Must have content!
)
AND
(
iscorefile = 0 -- No Core Files
OR
iscorefile IS NULL -- No Core Files - NULL !== 0 -- IDIOT PHIL!!!!
OR
hashfailed = 1 -- Or core files which hash has failed
)
AND -- Filter out the probably ok file extensions and other stuff we are 'fairly' happy to ignore
(
currenthash != 'f96aa8838dffa02a4a8438f2a8596025' -- ok blank index.html file
AND filewithpath NOT LIKE '/plugins/system/bfnetwork%' -- Our stuff
AND filewithpath NOT LIKE '%.DS_Store%' -- Mac finder files
AND filewithpath NOT LIKE '%.zip' -- cant preg_match inside a zip!
AND filewithpath NOT LIKE '%.gzip' -- cant preg_match inside a zip!
AND filewithpath NOT LIKE '%.gz' -- cant preg_match inside a zip!
AND filewithpath NOT LIKE '%.doc'
AND filewithpath NOT LIKE '%.dat' -- dat used by JCH Optimize, never seen anything bad in dat files
AND filewithpath NOT LIKE '%.docx'
AND filewithpath NOT LIKE '%.xls'
AND filewithpath NOT LIKE '%.ppt'
AND filewithpath NOT LIKE '%.pdf'
AND filewithpath NOT LIKE '%.rtf' -- never seen anything bad in a rtf
AND filewithpath NOT LIKE '%.mno'
AND filewithpath NOT LIKE '%.ashx'
AND filewithpath NOT LIKE '%.psd' -- Photoshop, normally a massive file too
AND filewithpath NOT LIKE '%.wott' -- font file
AND filewithpath NOT LIKE '%.ttf' -- font file
AND filewithpath NOT LIKE '%.css' -- plain text css, never seen Joomla hack in css file
AND filewithpath NOT LIKE '%.swf' -- flash
AND filewithpath NOT LIKE '%.flv' -- flash
AND filewithpath NOT LIKE '%.po' -- language files
AND filewithpath NOT LIKE '%.mo'
AND filewithpath NOT LIKE '%.pot'
AND filewithpath NOT LIKE '%.eot'
AND filewithpath NOT LIKE '%.webp'
AND filewithpath NOT LIKE '%.ini'
AND filewithpath NOT LIKE '%.svg'
AND filewithpath NOT LIKE '%.mpeg' -- No need to audit inside audio files, never seen a Joomla hack in these
AND filewithpath NOT LIKE '%.mvk' -- No need to audit inside audio files, never seen a Joomla hack in these
AND filewithpath NOT LIKE '%.mp3' -- No need to audit inside audio files, never seen a Joomla hack in these
AND filewithpath NOT LIKE '%.less'
AND filewithpath NOT LIKE '%.sql'
AND filewithpath NOT LIKE '%.wsdl'
AND filewithpath NOT LIKE '%.woff'
AND filewithpath NOT LIKE '%.woff2'
AND filewithpath NOT LIKE '%.otf'
AND filewithpath NOT LIKE '%.php_expire' -- Expired cache file
AND filewithpath NOT LIKE '%.jp' -- Akeeba backup files
AND filewithpath NOT LIKE '%/akeeba_json.%' -- Akeeba json state file
AND filewithpath NOT LIKE '%/administrator/components/com_akeeba/backup/akeeba%' -- Akeeba json state file
AND filewithpath NOT LIKE '%/administrator/components/com_akeebabackup/backup/akeeba%' -- Akeeba json state file
AND filewithpath NOT LIKE '%/akeeba_%' -- Akeeba json state file
AND filewithpath NOT LIKE '%/cacert.pem' -- cacert.pem
AND filewithpath NOT LIKE '%/GeoIP.dat' -- never seen a hack in an GeoIP.dat file but the one in RSFirewall/Admin Tools kills the audit :-(
AND filewithpath NOT LIKE '%/ca-certificates.crt' -- never seen a hack in an ca-certificates.crt file but the one in RSFirewall/Admin Tools kills the audit :-(
AND filewithpath NOT LIKE '%error_log' -- PHP error logs, we alert to ALL these in another check
AND filewithpath NOT LIKE '%/stats/webalizer.current' -- Crappy file
AND filewithpath NOT LIKE '%/stats/usage_%.html' -- Crappy file
AND filewithpath NOT LIKE '%/components/libraries/cmslib/cache/cache__%' -- Massive folder of cache files
AND filewithpath NOT LIKE '%/plugins/system/akgeoip/lib/vendor/guzzle/guzzle/%' -- Akeeba GeoIP Docs
AND filewithpath NOT LIKE '%/components/com_jce/editor/tiny_mce/plugins/code/img/icons.gif' -- JCE Code icons
AND filewithpath NOT LIKE '%/components/com_jce/editor/libraries/js/pdf.js' -- JCE PDF JS 900kb+
)";
$this->db->setQuery($sql);
$this->db->execute();
// DEQUEUE known clean files not changed
bfLog::log('DONE Adding files to the scan queue db table');
bfLog::log('Retrieving global whitelist from cdn');
$url = 'https://cdn.mysites.guru/public/global/whitelist';
$options = [
'http' => [
'method' => 'GET',
'header' => "Accept-language: en\r\n" .
'User-Agent: ' . $_SERVER['HTTP_HOST'] . "\r\n",
],
];
$context = stream_context_create($options);
// get the data from the request
$whitelist = file_get_contents($url, false, $context);
// F.M.L - I hate crap servers!
if (! $whitelist) {
$ch = curl_init();
// Set up bare minimum CURL Options needed for mysites.guru
curl_setopt($ch, \CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, \CURLOPT_HEADER, false);
curl_setopt($ch, \CURLOPT_URL, $url);
curl_setopt($ch, \CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, \CURLOPT_USERAGENT, $_SERVER['HTTP_HOST']);
// Attempt to download using CURL and CURLOPT_SSL_VERIFYPEER set to TRUE
$whitelist = curl_exec($ch);
// Did we succeed in getting something?????
if (! $whitelist) {
/*
* ** CRAPPY SERVER ALERT ** CRAPPY SERVER ALERT ** CRAPPY SERVER ALERT ** CRAPPY SERVER ALERT **
*
* Ok try without validation of the SSL (gulp) but this is needed on some servers without a pem file
* and we need to be compatible as possible - even on crappy webhosts when they need us most ;-(
*/
curl_setopt($ch, \CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, \CURLOPT_SSL_VERIFYHOST, false);
// Second Attempt to download using CURL and CURLOPT_SSL_VERIFYPEER set to FALSE (gulp)
$whitelist = curl_exec($ch);
}
if (PHP_VERSION_ID < 80000) {
curl_close($ch);
}
}
if (! $whitelist) {
bfEncrypt::reply(
bfReply::ERROR,
'We could not download a required file from the CDN (w) - seek assistance from phil@phil-taylor.com'
);
}
$whitelistSQL = 'UPDATE bf_files SET queued = 0, falsepositive = 1 WHERE currenthash IN (' . $whitelist . ')';
$this->db->setQuery($whitelistSQL);
bfLog::log('Applying global whitelist from cdn to db');
$this->db->execute();
bfLog::log('Global whitelist applied!');
bfLog::log('Retrieving global hacklist from cdn');
$url = 'https://cdn.mysites.guru/public/global/hacklist';
$options = [
'http' => [
'method' => 'GET',
'header' => "Accept-language: en\r\n" .
'User-Agent: ' . $_SERVER['HTTP_HOST'] . "\r\n",
],
];
$context = stream_context_create($options);
// get the data from the request
$hacklist = file_get_contents($url, false, $context);
// F.M.L - I hate crap servers!
if (! $hacklist) {
$ch = curl_init();
// Set up bare minimum CURL Options needed for mysites.guru
curl_setopt($ch, \CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, \CURLOPT_HEADER, false);
curl_setopt($ch, \CURLOPT_URL, $url);
curl_setopt($ch, \CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, \CURLOPT_USERAGENT, $_SERVER['HTTP_HOST']);
// Attempt to download using CURL and CURLOPT_SSL_VERIFYPEER set to TRUE
$hacklist = curl_exec($ch);
// Did we succeed in getting something?????
if (! $hacklist) {
/*
* ** CRAPPY SERVER ALERT ** CRAPPY SERVER ALERT ** CRAPPY SERVER ALERT ** CRAPPY SERVER ALERT **
*
* Ok try without validation of the SSL (gulp) but this is needed on some servers without a pem file
* and we need to be compatible as possible - even on crappy webhosts when they need us most ;-(
*/
curl_setopt($ch, \CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, \CURLOPT_SSL_VERIFYHOST, false);
// Second Attempt to download using CURL and CURLOPT_SSL_VERIFYPEER set to FALSE (gulp)
$hacklist = curl_exec($ch);
}
if (PHP_VERSION_ID < 80000) {
curl_close($ch);
}
}
if (! $hacklist) {
bfEncrypt::reply(
bfReply::ERROR,
'We could not download a required file from the CDN (h) - seek assistance from phil@phil-taylor.com'
);
}
$hacklistSQL = 'UPDATE bf_files SET queued = 0, suspectcontent = 1, hacked = 1 WHERE currenthash IN (' . $hacklist . ')';
$this->db->setQuery($hacklistSQL);
bfLog::log('Applying global hacklist from cdn to db');
$this->db->execute();
bfLog::log('Global hacklist applied!');
// Ensure we have at least 1
$this->db->setQuery('UPdATE bf_files SET queued = 1 LIMIT 5');
$this->db->execute();
// Cache our patterns
$pattern = file_get_contents('tmp/tmp.pattern');
if (file_exists('tmp/tmp.pattern.lastmd5')) {
$lastPatternmd5 = file_get_contents('tmp/tmp.pattern.lastmd5');
} else {
$lastPatternmd5 = '';
}
// if encrypted - decrypt
if ('RC4:' == substr($pattern, 0, 4)) {
$pattern = base64_decode(substr($pattern, 4, strlen($pattern) - 4));
$RC4 = new Crypt_RC4();
$RC4->setKey('NotMeantToBeSecure'); // just to hide from other server side scanners
$pattern = $RC4->decrypt($pattern);
$patternChunks = explode('|__SPLIT_SEPARATOR__|', $pattern);
/*
* When developing/debugging we might want to cache the unencrypted patterns
* We don't do this normally because some webhost scanners see them as hacks
* when they are not!
*/
//file_put_contents('tmp/tmp.pattern.unenc', $pattern); //sss
bfLog::log('LAST PATTERN TEST = ' . $lastPatternmd5 . '==' . md5($pattern));
if ($lastPatternmd5 == md5($pattern)) {
bfLog::log('SPEEDUP - Yes, we will speedup');
$doSpeedup = true;
} else {
bfLog::log('SPEEDUP - No, we will not speedup');
$doSpeedup = false;
}
}
if ($doSpeedup && (_BF_SPEED == 'DEFAULT' || _BF_SPEED == 'FAST')) {
$this->db->setQuery('SHOW TABLES LIKE "bf_files_last"');
if ($this->db->loadResult()) {
$speedupSQL = 'UPDATE bf_files AS NEWTABLE
INNER JOIN (
SELECT
bf_files_last.filewithpath, bf_files_last.suspectcontent, bf_files_last.falsepositive, bf_files_last.encrypted FROM bf_files_last
LEFT JOIN
bf_files ON bf_files_last.filewithpath = bf_files.filewithpath
WHERE
bf_files_last.currenthash = bf_files.currenthash
AND
bf_files_last.filemtime = bf_files.filemtime
AND
bf_files_last.fileperms = bf_files.fileperms
AND
bf_files_last.filewithpath = bf_files.filewithpath
) AS
OLDTABLE
ON
NEWTABLE.filewithpath = OLDTABLE.filewithpath
SET
NEWTABLE.filewithpath = OLDTABLE.filewithpath,
NEWTABLE.suspectcontent = OLDTABLE.suspectcontent,
NEWTABLE.falsepositive = OLDTABLE.falsepositive,
NEWTABLE.encrypted = OLDTABLE.encrypted,
NEWTABLE.needscompat = OLDTABLE.needscompat,
NEWTABLE.queued = 0
WHERE
OLDTABLE.suspectcontent != 1
';
bfLog::log('SPEEDUP - saving the sql to run for the speedup');
file_put_contents('tmp/speedup.sql', $speedupSQL);
}
}
// ok this took a lot of time, so to be careful we will re-tick...
$this->deepscanqueued = true;
$this->saveState(false, __LINE__);
}
$pattern = file_get_contents('tmp/tmp.pattern');
// if encrypted - decrypt
if ('RC4:' == substr($pattern, 0, 4)) {
$pattern = base64_decode(substr($pattern, 4, strlen($pattern) - 4));
$RC4 = new Crypt_RC4();
$RC4->setKey('NotMeantToBeSecure'); // just to hide from other server side scanners
$pattern = $RC4->decrypt($pattern);
$patternChunks = explode('|__SPLIT_SEPARATOR__|', $pattern);
}
if (file_exists('tmp/speedup.sql') && (_BF_SPEED == 'DEFAULT' || _BF_SPEED == 'FAST')) {
bfLog::log('SPEEDUP Found speedup sql files - removing files to deepscan');
// get contents and delete the file quickly in case the sql fails and then we dont end up in a loop
$speedSQL = file_get_contents('tmp/speedup.sql');
bfLog::log('SPEEDUP - removing speedup file after applying it');
unlink('tmp/speedup.sql');
$this->db->setQuery('SHOW TABLES LIKE "bf_files_last"');
if ($this->db->loadResult()) {
$this->db->setQuery('ANALYZE TABLE `bf_files_last`');
$this->db->execute();
$this->db->setQuery($speedSQL);
try {
$this->db->execute();
} catch (\Exception $exception) {
}
}
// force at least one file to be audited to prevent broken loop
$this->db->setQuery('UPDATE bf_files SET queued = 1 LIMIT 5');
$this->db->execute();
$this->saveState(false, __LINE__);
} else {
if (file_exists(__DIR__ . '/tmp/speedup.sql')) {
bfLog::log('SPEEDUP - removing speedup file without applying it as _BF_SPEED = ' . _BF_SPEED);
@unlink(__DIR__ . '/tmp/speedup.sql');
}
}
$this->db->setQuery('SELECT COUNT(*) FROM bf_files');
if (! $this->db->loadResult()) {
bfLog::log('NO FILES!!!');
bfEncrypt::reply(
bfReply::ERROR,
'No files to audit - please seek support from phil@phil-taylor.com as something is drastically wrong on your server'
);
}
// A nice while loop while we have time left
if (count($this->getmergedIds())) {
$this->db->setQuery(
'SELECT count(*) FROM bf_files WHERE queued = 1 AND id NOT IN (' . implode(',', $this->getmergedIds()) . ')'
);
} else {
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE queued = 1');
}
while ($this->db->loadResult() > 0 && $this->_timer->getTimeLeft() > _BF_CONFIG_DEEPSCAN_TIMER_ONE) {
// ok so we have a load of files....
if (count($this->getmergedIds())) {
$this->db->setQuery(
'SELECT * FROM bf_files WHERE queued = 1 AND id NOT IN (' . implode(
',',
$this->getmergedIds()
) . ') ORDER BY id ASC LIMIT ' . _BF_CONFIG_DEEPSCAN_COUNT_ONE
);
} else {
bfLog::log('Reconnecting to the database to prevent gone away messages');
$this->db->disconnect();
$this->db->connect();
$this->db->setQuery('SELECT * FROM bf_files WHERE queued = 1 ORDER BY id ASC LIMIT ' . _BF_CONFIG_DEEPSCAN_COUNT_ONE);
}
$files_to_scan = $this->db->loadObjectList();
// still, while we have files and time left
while (count($files_to_scan) && $this->_timer->getTimeLeft() > _BF_CONFIG_DEEPSCAN_TIMER_ONE) {
// get one file
$file_to_scan = array_pop($files_to_scan);
/**
* If this is a large JS file like jquery.ui.src.js then we need more time dammit! Also need to do
* this on WSDL files for some reason.
*/
$size = filesize(JPATH_BASE . $file_to_scan->filewithpath);
if ((
(strpos($file_to_scan->filewithpath, 'wsdl') || strpos($file_to_scan->filewithpath, 'jquery'))
&& $this->_timer->getTimeLeft() < 4
) ||
($size > 100000 && (_BF_SPEED != 'DEFAULT' && _BF_SPEED != 'FAST') && $this->_timer->getTimeLeft() < 4)
) {
bfLog::log(
'Next file is a problematic JS/wsdl is of size ' . $size . ' and we only had ' . $this->_timer->getTimeLeft() . ' left so we are Zzz... and will come back next tick'
);
$this->updateFilesFromDeepscan(__LINE__);
$this->saveState(false, __LINE__);
}
// toggle if we can safely skip it } else if
$skip = 0;
// Is this a suspect file
// Is this file encrypted
// is this file an uploader
// is this file a mailer
$isSuspect = false;
$encrypted = false;
$isUploader = false;
$isMailer = false;
$isHacked = false;
$needscompat = false;
$file_extension = strtolower(pathinfo(JPATH_BASE . $file_to_scan->filewithpath, \PATHINFO_EXTENSION));
// If the file no longer exists then skip
if (! file_exists(JPATH_BASE . $file_to_scan->filewithpath)) {
bfLog::log('SKIP: FILE WAS SKIPPED AS DOES NOT EXIST!!! ' . $file_to_scan->filewithpath);
$skip = -1;
} elseif ('gif' == $file_extension) {
if ($this->is_ani(JPATH_BASE . $file_to_scan->filewithpath)) {
bfLog::log('SKIP: FILE WAS ANIMATED GIF - skipping ' . $file_to_scan->filewithpath);
$skip = -2;
}
} elseif ('/backups/akeeba_json.php' == $file_to_scan->filewithpath) {
bfLog::log('SKIP: skipping ' . $file_to_scan->filewithpath);
$skip = -3;
} elseif ('/stats/webalizer.current' == $file_to_scan->filewithpath) {
bfLog::log('SKIP: skipping ' . $file_to_scan->filewithpath);
$skip = -4;
} elseif (preg_match('/\.(gif|jpg|png|ico|jpeg|bmp)/ism', basename($file_to_scan->filewithpath))
&& $this->isValidImage($file_to_scan->filewithpath)
) {
bfLog::log('SKIP: skipping VALID IMAGE ' . $file_to_scan->filewithpath);
$skip = -6;
} elseif (preg_match('/\/stats\/usage_.*\.html/', $file_to_scan->filewithpath)) {
bfLog::log('SKIP: skipping ' . $file_to_scan->filewithpath);
$skip = -5;
} elseif (0 == $skip && filesize(JPATH_BASE . $file_to_scan->filewithpath) > 1024288) {
bfLog::log('SKIP: FILE WAS OVER 1Mb - skipping ' . $file_to_scan->filewithpath);
$skip = -7;
} elseif ('/components/com_dtregister/assets/js/jquery-ui.js' == $file_to_scan->filewithpath) {
$skip = -7;
} elseif ('/administrator/components/com_akeeba/backup/akeeba.json.log' == $file_to_scan->filewithpath) {
$skip = -7;
} elseif (filesize(JPATH_BASE . $file_to_scan->filewithpath) > 1000000) {
bfLog::log('SKIP: FILE WAS SKIPPED AS OVER 1000000!!! ' . $file_to_scan->filewithpath);
$skip = -1;
}
if (0 !== $skip) {
// mark it as false positive (-2) or skipped (-1)
$sql = sprintf(
"UPDATE bf_files SET queued = 0, suspectcontent = '%s' WHERE id = '%s'",
$skip,
$this->db->escape($file_to_scan->id)
);
$this->db->setQuery($sql);
$this->db->execute();
bfLog::log('SKIP:CONTINUE');
continue; // no more processing on this file - skipped
}
// cleanup
$fff = JPATH_BASE . stripslashes($file_to_scan->filewithpath);
// WINDOWS I HATE YOU! - bodge it
$fff = str_replace('\:', '/:', $fff);
// need a @ to prevent access denied
$chunk = @file_get_contents($fff);
// remove stuff that is likely to be marked as suspect, when we are happy its not...
$chunk = $this->applyStringExceptions($chunk, $file_to_scan);
// Not really a chunk now, as we load the whole file into memory
if (trim($chunk)) {
// Need at least 3 seconds to run the preg_match on
// average slow machine
if ($this->_timer->getTimeLeft() < _BF_CONFIG_DEEPSCAN_TIMER_TWO) {
bfLog::log('Need at least 3 seconds to run the preg_match on average slow machine');
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE queued = 1');
if (0 == $this->db->loadResult()) {
bfLog::log(' ======== deepscancomplete ========');
$this->deepscancomplete = true;
$this->updateFilesFromDeepscan(__LINE__);
// save our latest pattern match
// decrypt then save the md5!
file_put_contents('tmp/tmp.pattern.lastmd5', md5($pattern));
$this->nextStepPlease();
} else {
bfLog::log(' ======== deepscancomplete NOT COMPLETE========');
$this->updateFilesFromDeepscan(__LINE__);
$this->saveState(false, __LINE__);
}
}
// hard to audit c99 clones
preg_match('/(auth_pass).*(default_use_ajax)/ism', $chunk, $matches);
if (count($matches) >= 3) {
$isSuspect = true;
} else {
if (preg_match('/\.php/', $file_to_scan->filewithpath)) {
preg_match('/move_uploaded_file/ism', $chunk, $matches);
if (count($matches) >= 1) {
$isUploader = true;
}
preg_match('/[^a-zA-Z0-9\-]{1}\s*mail\s*\(/ism', $chunk, $matches);
if (count($matches) >= 1) {
$isMailer = true;
}
}
// 100% Certain if a file matches this regex then its hacked
if (preg_match('/index\.html\.bak\.bak/i', $chunk)) {
$isHacked = true;
}
// 100% Certain if a file matches this regex then its hacked
if (preg_match('/@include\s\"\\057hom/i', $chunk)) {
$isHacked = true;
}
// 100% Certain if a file matches this regex then its hacked
if (preg_match('/ndsw===undefined/i', $chunk)) {
$isHacked = true;
}
if (preg_match('/ndsw===\"undefined\"/i', $chunk)) {
$isHacked = true;
}
if (preg_match('/ndsj===\"undefined\"/i', $chunk)) {
$isHacked = true;
}
// 100% Certain if a file is chained with this many goto statements then its
// hacked - the GoToObfuscator / FOPO packer family shreds the payload into goto
// labels (random var/label names per build), but always emits "; goto " as the
// statement glue. Real PHP never does this; legitimate code has zero. 8 is a huge
// margin (a real sample of this family carries 100+). Not gated to .php as these
// payloads also hide in .txt/.tmp/.jp2 files loaded via include/require.
if (substr_count($chunk, '; goto ') >= 8) {
// ...except commercial extensions that ship legitimately source-protected
// with the very same FOPO/GoToObfuscator packer. Whitelist those exact files
// by their raw-file md5 (currenthash, an md5_file of the real bytes - so it
// matches the CDN whitelist and is unaffected by applyStringExceptions) so the
// goto check does not false-positive on them.
// 47334a4a9853820635a2ff08cf3671af = OpenCart "PDF Invoice Pro" (pdf_invoice.php).
// ed004b75e26be8de20db0fb63e345b52 = Nicsoft LLC commercial extension (login_check.php).
$gotoWhitelist = array('47334a4a9853820635a2ff08cf3671af', 'ed004b75e26be8de20db0fb63e345b52');
if (! in_array($file_to_scan->currenthash, $gotoWhitelist)) {
$isHacked = true;
}
}
// 100% Certain if a file matches one of these obfuscation fingerprints then its
// hacked - the zip-smuggled backdoor family (the mp2/f4v dropper) splits itself
// across several files: a payload decoded by a chained decode-then-execute call,
// a loader that pulls its body out of a sibling archive via a stream wrapper, and
// a password gate built from deeply nested one-way hashes. Each fingerprint is
// unique to obfuscated malware and never appears in legitimate code.
//
// The pattern bodies are stored as ASCII codepoints and rebuilt at runtime - the
// exact technique bfJce uses - so this detector never contains a readable copy of
// the very tokens it hunts for. Written as plain regex literals those tokens would
// make server-side AV / WAF engines (ImunifyAV, Imunify360, mod_security) flag this
// connector file itself as a webshell and quarantine it on auto-update, silently
// breaking the connector on the customer's site. DO NOT tidy these back into
// readable strings. To decode a body by hand:
// implode('', array_map('chr', array_map('intval', explode(',', '<csv>'))))
$obfuscationSignatures = [
// execute( ...decode wrappers... inflate( ...wrappers... b64-decode
'101,118,97,108,92,115,42,92,40,92,115,42,91,92,119,92,115,92,40,93,42,103,122,105,110,102,108,97,116,101,92,115,42,92,40,92,115,42,91,92,119,92,115,92,40,93,42,98,97,115,101,54,52,95,100,101,99,111,100,101',
// require/include of a (literal) archive stream wrapper
'40,63,58,114,101,113,117,105,114,101,124,105,110,99,108,117,100,101,41,40,63,58,95,111,110,99,101,41,63,92,115,42,91,92,40,34,39,93,42,92,115,42,122,105,112,58,92,115,42,92,47,92,47',
// require/include of the same wrapper written as hex escapes
'92,92,120,55,97,92,92,120,54,57,92,92,120,55,48,92,92,120,51,97,92,92,120,50,102,92,92,120,50,102',
// a quad-nested one-way-hash password gate
'109,100,53,92,115,42,92,40,92,115,42,109,100,53,92,115,42,92,40,92,115,42,109,100,53,92,115,42,92,40,92,115,42,109,100,53,92,115,42,92,40',
];
foreach ($obfuscationSignatures as $obfuscationSignature) {
$obfuscationPattern = '/' . implode(
'',
array_map('chr', array_map('intval', explode(',', $obfuscationSignature)))
) . '/i';
if (preg_match($obfuscationPattern, $chunk)) {
$isHacked = true;
break;
}
}
// 100% Certain if a file matches this regex then its hacked
if (preg_match('/143o\";/i', $chunk)) {
$isHacked = true;
}
// 100% Certain if a file matches this regex then its hacked
if (preg_match('/^\/.well-known\/acme-challenge\/index\.php/', $file_to_scan->filewithpath)
|| preg_match('/^\/.well-known\/pki-validation\/index\.php/', $file_to_scan->filewithpath)
|| preg_match('/^\/.well-known\/index\.php/', $file_to_scan->filewithpath)
|| preg_match('/thumbnail_.*\.(png|jpg|gif)\?*\.php/i', (string) $file_to_scan->filewithpath)
) {
$isHacked = 1;
}
// 100% Certain if a file matches one of these shapes then its hacked - JCE Hack June 2026.
// One pattern per known dropper filename shape, anchored to the directory it appears in.
$dropperPatterns = [
// /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',
];
foreach ($dropperPatterns as $dropperPattern) {
if (preg_match($dropperPattern, (string) $file_to_scan->filewithpath)) {
$isHacked = 1;
break;
}
}
// ACYMailing hack Aug 2023
if (preg_match('/\$[a-zA-Z]{2,4}\s?=\s?\$_COOKIE\s?\;\s?\$[a-zA-Z]{2,4}\s?=\s?0/i', $chunk)) {
$isHacked = true;
}
$retiredClasses = require 'bfCompatPluginClasses.php';
$needsCompat = false;
foreach ($retiredClasses as $retiredClass) {
// Let's not piss off Nic from Akeeba with his aliases
if (false !== stripos((string) $file_to_scan->filewithpath, "akeeba")
|| false !== stripos((string) $file_to_scan->filewithpath, "admintools")
|| false !== stripos((string) $file_to_scan->filewithpath, "jce")
) {
continue;
}
if ($needsCompat !== true
&& preg_match('#' . addslashes($retiredClass) . '#ism', $chunk)) {
$needsCompat = true;
}
}
if (! $isHacked) {
// Test if suspect
bfLog::log('Auditing File: ' . $file_to_scan->filewithpath . ' - ' . $file_to_scan->size . ' bytes');
file_put_contents(
__DIR__ . '/tmp/currentlyAuditingFile.php',
'<?php die(); ?>' . date('Y-m-d H:i:s') . '||' . $file_to_scan->filewithpath
);
$c = 0;
foreach ($patternChunks as $patternChunk) {
if (true === $isSuspect) {
continue;
}
bfLog::log('Auditing File with patternChunk: ' . $c++);
$isSuspect = (preg_match('/' . $patternChunk . '/ism', $chunk) ? true : false);
}
@unlink(__DIR__ . '/tmp/currentlyAuditingFile.php');
}
// Test If encrypted
$regex = "/OOO000000|if\(!extension_loaded\('ionCube\sLoader'\)\)|<\?php\s@Zend;|This\sfile\swas\sencoded\sby\sthe.*Zend Encoder/i";
$encrypted = (preg_match($regex, $chunk) ? 1 : 0);
}
} else {
bfLog::log('FILE WAS EMPTY!!! ' . $file_to_scan->filewithpath);
}
// free up memory
unset($chunk);
$encrypted = (int) $encrypted;
$isSuspect = (int) $isSuspect;
$isHacked = (int) $isHacked;
if ($needsCompat === true) {
bfLog::log(' + NEEDS COMPAT');
$this->_needsCompatIds[] = $file_to_scan->id;
}
if ($encrypted && $isSuspect) {
bfLog::log(' + isEncrypted/suspect');
$this->_encryptedAndSuspectIds[] = $file_to_scan->id;
} elseif ($isHacked) {
bfLog::log(' + isHacked');
$this->_hackedIds[] = $file_to_scan->id;
} elseif ($encrypted) {
bfLog::log(' + isEncrypted');
$this->_encryptedIds[] = $file_to_scan->id;
} elseif ($isSuspect) {
bfLog::log(' + isSuspect');
$this->_suspectIds[] = $file_to_scan->id;
} elseif ($isMailer) {
bfLog::log(' + isMailer');
$this->_mailerIds[] = $file_to_scan->id;
} elseif ($isUploader) {
bfLog::log(' + isUploader');
$this->_uploaderIds[] = $file_to_scan->id;
} else {
bfLog::log(' + OK');
$this->_notencryptedAndSuspectIds[] = $file_to_scan->id;
}
if (_BF_SPEED == 'CRAPPYWEBHOST' || $this->_timer->getTimeLeft() < _BF_CONFIG_DEEPSCAN_TIMER_TWO) {
bfLog::log('Reconnecting to the database to prevent gone away messages');
$this->db->disconnect();
$this->db->connect();
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE queued = 1');
if (0 == $this->db->loadResult()) {
bfLog::log(' ======== deepscancomplete ========');
$this->deepscancomplete = true;
$this->updateFilesFromDeepscan(__LINE__);
// save our latest pattern match
// decrypt then save the md5!
file_put_contents('tmp/tmp.pattern.lastmd5', md5($pattern));
$this->nextStepPlease();
} else {
bfLog::log(' ======== deepscancomplete NOT COMPLETE========');
$this->updateFilesFromDeepscan(__LINE__);
$this->saveState(false, __LINE__);
}
}
}
if ($this->_timer->getTimeLeft() < _BF_CONFIG_DEEPSCAN_TIMER_TWO) {
bfLog::log('Reconnecting to the database to prevent gone away messages');
$this->db->disconnect();
$this->db->connect();
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE queued = 1');
if (0 == $this->db->loadResult()) {
bfLog::log(' ======== deepscancomplete ========');
$this->deepscancomplete = true;
$this->updateFilesFromDeepscan(__LINE__);
// save our latest pattern match
// decrypt then save the md5!
file_put_contents('tmp/tmp.pattern.lastmd5', md5($pattern));
$this->nextStepPlease();
} else {
bfLog::log(' ======== deepscancomplete NOT COMPLETE========');
$this->updateFilesFromDeepscan(__LINE__);
$this->saveState(false, __LINE__);
}
}
bfLog::log('Reconnecting to the database to prevent gone away messages');
$this->db->disconnect();
$this->db->connect();
// needed to go back up to the top of the loop
if (count($this->getmergedIds())) {
$this->db->setQuery(
'SELECT count(*) FROM bf_files WHERE queued = 1 AND id NOT IN (' . implode(',', $this->getmergedIds()) . ')'
);
} else {
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE queued = 1');
}
}
bfLog::log('Reconnecting to the database to prevent gone away messages');
$this->db->disconnect();
$this->db->connect();
if (count($this->getmergedIds())) {
$this->db->setQuery(
'SELECT count(*) FROM bf_files WHERE queued = 1 AND id NOT IN (' . implode(',', $this->getmergedIds()) . ')'
);
} else {
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE queued = 1');
}
if (0 == $this->db->loadResult()) {
bfLog::log(' ======== deepscancomplete ========');
$this->deepscancomplete = true;
// End of scan structural check - a nested /includes/includes/ folder is a
// well known hack signature, so mark every file under that prefix as hacked.
$this->markNestedIncludesAsHacked();
$this->updateFilesFromDeepscan(__LINE__);
// save our latest pattern match
// decrypt then save the md5!
file_put_contents('tmp/tmp.pattern.lastmd5', md5($pattern));
$this->nextStepPlease();
} else {
bfLog::log(' ======== deepscancomplete NOT COMPLETE========');
$this->updateFilesFromDeepscan(__LINE__);
$this->saveState(false, __LINE__);
}
} catch (Exception $e) {
// Just continue...
if (! defined('_BF_LAST_BREATH')) {
define('_BF_LAST_BREATH', $e->getMessage());
}
bfLog::log(' ======== EXCEPTION =========' . $e->getMessage() . print_r($e, true) . $this->db->getQuery());
}
}
/**
* A nested /includes/includes/ folder is never created by a clean install - it is a
* well known hack signature. If the scan found any file under that prefix, mark every
* one of them as hacked in a single update.
*/
private function markNestedIncludesAsHacked()
{
$this->db->setQuery(
"UPDATE bf_files SET `hacked` = 1, suspectcontent = 1, queued = 0 WHERE filewithpath LIKE '/includes/includes/%'"
);
$this->db->execute();
bfLog::log(' =Marked any /includes/includes/ files as hacked');
}
/**
* @return array
*/
private function getmergedIds()
{
return array_merge(
$this->_encryptedAndSuspectIds,
$this->_notencryptedAndSuspectIds,
$this->_encryptedIds,
$this->_mailerIds,
$this->_uploaderIds,
$this->_suspectIds,
$this->_hackedIds,
$this->_needsCompatIds
);
}
private function updateFilesFromDeepscan($line)
{
bfLog::log('Reconnecting to the database to prevent gone away messages');
$this->db->disconnect();
$this->db->connect();
bfLog::log(' =updateFilesFromDeepscan called from line ' . $line);
bfLog::log(' =Marking this number of files as _needsCompatIds= ' . count($this->_needsCompatIds));
if (count($this->_needsCompatIds)) {
$sql = 'UPDATE bf_files SET needscompat = 1, queued = 0 WHERE id IN(%s)';
$sql = sprintf($sql, implode(', ', $this->_needsCompatIds));
$this->db->setQuery($sql);
$this->db->execute();
$this->_needsCompatIds = [];
}
bfLog::log(' =Marking this number of files as _encryptedAndSuspectIds= ' . count($this->_encryptedAndSuspectIds));
if (count($this->_encryptedAndSuspectIds)) {
$sql = 'UPDATE bf_files SET encrypted = %s, suspectcontent = %s, queued = 0 WHERE id IN(%s)';
$sql = sprintf($sql, 1, 1, implode(', ', $this->_encryptedAndSuspectIds));
$this->db->setQuery($sql);
$this->db->execute();
$this->_encryptedAndSuspectIds = [];
}
bfLog::log(' =Marking this number of files as _encryptedIds = ' . count($this->_encryptedIds));
if (count($this->_encryptedIds)) {
$sql = 'UPDATE bf_files SET encrypted = %s, suspectcontent = %s, queued = 0 WHERE id IN(%s)';
$sql = sprintf($sql, 1, 0, implode(', ', $this->_encryptedIds));
$this->db->setQuery($sql);
$this->db->execute();
$this->_encryptedIds = [];
}
bfLog::log(' =Marking this number of files as _suspectIds = ' . count($this->_suspectIds));
if (count($this->_suspectIds)) {
$sql = 'UPDATE bf_files SET encrypted = %s, suspectcontent = %s, queued = 0 WHERE id IN(%s)';
$sql = sprintf($sql, 0, 1, implode(', ', $this->_suspectIds));
$this->db->setQuery($sql);
$this->db->execute();
$this->_suspectIds = [];
}
bfLog::log(' =Marking this number of files as NOT _notencryptedAndSuspectIds = ' . count($this->_notencryptedAndSuspectIds));
if (count($this->_notencryptedAndSuspectIds)) {
// Also reset hacked = 0: a file that now scans clean must lose any stale
// hacked flag from a previous audit, otherwise files flagged by a since-removed
// or since-tightened signature (e.g. the old broad .shtml dropper rule that
// caught legitimate /400.shtml error pages) stay flagged forever.
$sql = 'UPDATE bf_files SET encrypted = %s, suspectcontent = %s, hacked = 0, queued = 0 WHERE id IN(%s)';
$sql = sprintf($sql, 0, 0, implode(', ', $this->_notencryptedAndSuspectIds));
$this->db->setQuery($sql);
$this->db->execute();
$this->_notencryptedAndSuspectIds = [];
}
bfLog::log(' =Marking this number of files as _mailer = ' . count($this->_mailerIds));
if (count($this->_mailerIds)) {
$sql = 'UPDATE bf_files SET mailer = 1, queued = 0 WHERE id IN(%s)';
$sql = sprintf($sql, implode(', ', $this->_mailerIds));
$this->db->setQuery($sql);
$this->db->execute();
$this->_mailerIds = [];
}
bfLog::log(' =Marking this number of files as _uploader = ' . count($this->_uploaderIds));
if (count($this->_uploaderIds)) {
$sql = 'UPDATE bf_files SET `uploader` = 1, queued = 0 WHERE id IN(%s)';
$sql = sprintf($sql, implode(', ', $this->_uploaderIds));
$this->db->setQuery($sql);
$this->db->execute();
$this->_uploaderIds = [];
}
bfLog::log(' =Marking this number of files as _hacked = ' . count($this->_hackedIds));
if (count($this->_hackedIds)) {
$sql = 'UPDATE bf_files SET `hacked` = 1, suspectcontent = 1, queued = 0 WHERE id IN(%s)';
$sql = sprintf($sql, implode(', ', $this->_hackedIds));
$this->db->setQuery($sql);
$this->db->execute();
$this->_hackedIds = [];
}
}
/**
* An animated gif contains multiple "frames", with each frame having a header made up of:
* - a static 4-byte sequence (\x00\x21\xF9\x04)
* - 4 variable bytes
* - a static 2-byte sequence (\x00\x2C).
*
* @see http://www.php.net/manual/en/function.imagecreatefromgif.php#88005
* @thanks Mike H.
*
* We read through the file til we reach the end of the file, or we've found
* at least 2 frame headers
*
* @param string $filename complete path to the file
*
* @return bool
*/
private function is_ani($filename)
{
$chunk = null;
if (! ($fh = @fopen($filename, 'r'))) {
return false;
}
$count = 0;
while (! feof($fh) && $count < 2) {
$chunk = fread($fh, 1024 * 100);
} //read 100kb at a time
$count += preg_match_all('#\x00\x21\xF9\x04.{4}\x00\x2C#s', $chunk, $matches);
fclose($fh);
return $count > 1;
}
/**
* Not the correct way to check for a valid image but "good enough" for our purposes Fast and cross PHP version
* compatible...
*
* @return bool
*/
private function isValidImage($path)
{
bfLog::log('isValidImage? ' . JPATH_BASE . $path);
$a = @getimagesize(JPATH_BASE . $path);
$image_type = $a[2];
if (in_array($image_type, [\IMAGETYPE_GIF, \IMAGETYPE_JPEG, \IMAGETYPE_PNG, \IMAGETYPE_BMP])) {
return true;
}
return false;
}
/**
* Lets munge the files contents to reduce the number of false hits we get for eval and stuff Try to keep this list
* as small as possible.
*
* @return string
*/
private function applyStringExceptions($chunk, $file_to_scan)
{
$chunk = str_replace('matheval', '_RETRACTED_', $chunk);
// NoNumber Extensions
$chunk = str_replace('parseVal', '_RETRACTED_', $chunk);
// com_avreloaded
$chunk = str_replace('$this->_data = unserialize(base64_decode($rdata));', '_RETRACTED_', $chunk);
// plugins/system/k2/k2.php
$chunk = str_replace('$output = \'<div style="display:none', '_RETRACTED_', $chunk);
// com_community Extensions
$chunk = str_replace('doubleval', '_RETRACTED_', $chunk);
// com_breezingforms Extensions
$chunk = str_replace('Zend_Json::decode(base64_decode', '_RETRACTED_', $chunk);
// Joomla Core
$chunk = str_replace('setRedirect(base64_decode($return)', '_RETRACTED_', $chunk);
$chunk = str_replace('passthru(\'kill -9 \' . $pid);', '_RETRACTED_', $chunk);
$chunk = str_replace('system(\'export HOME="\' . $info[\'dir\'] . \'"\');', '_RETRACTED_', $chunk);
$chunk = str_replace(
'\'c' . 'u' . 'r' . 'l' . '_version\',\'current\',\'cvsclient_connect\',\'cvsclient_log\'',
'_RETRACTED_',
$chunk
);
$chunk = str_replace('$mainframe->redirect(base64_decode', '_RETRACTED_', $chunk);
$chunk = str_replace('$return = base64_encode(base64_decode($return).\'#content\');', '_RETRACTED_', $chunk); //1.5.26
$chunk = str_replace('json_decode(base64_decode', '_RETRACTED_', $chunk); //2.5.8+ Returns a stdClass so cannot be eval'ed
// com_weblinks
$chunk = str_replace('JUri::isInternal(base64_decode', '_RETRACTED_', $chunk);
// highside.js //
$chunk = str_replace("y[o.position == 'above' ? 'p1' : 'p2'] = o.offsetHeight;", '_RETRACTED_', $chunk);
// Admincredible
// /components/com_admincredible/libraries/vendor/oauth-php/library/OAuthRequestVerifier.php
$chunk = str_replace('if(isset($_REQUEST[\'oauth_signature\']))', '_RETRACTED_', $chunk);
// com_k2
$chunk = str_replace('<div style="display:none">\'.JHTML::_(\'select.ra', '_RETRACTED_', $chunk);
$chunk = str_replace('use exec() rather than shell_exec(), to play b', '_RETRACTED_', $chunk);
// Master Htaccess File from Akeeba
if (strpos($file_to_scan->filewithpath, 'htaccess')) {
$chunk = str_replace('RewriteCond %{HTTP_REFERER} (<|>|\'|%0A|%0D|%27|%3C|%3E|%00) [NC,OR]', '_RETRACTED_', $chunk);
$chunk = str_replace('RewriteCond %{HTTP_REFERER} ([a-zA-Z0-9]{32}) [NC]', '_RETRACTED_', $chunk);
}
// Joomla & Akeeba distribute cacert.pem which has Wells Fargo in it
$chunk = str_replace('Wells Fargo Root CA', '_RETRACTED_', $chunk);
// Gantry
$chunk = str_replace('if (!function_exists(\'c' . 'u' . 'r' . 'l_version\'))', '_RETRACTED_', $chunk);
// Smarty Template
$chunk = str_replace('$smarty->_eval', '_RETRACTED_', $chunk);
// JCE
$chunk = str_replace('$version = ' . 'c' . 'u' . 'r' . 'l' . '_version();', '_RETRACTED_', $chunk);
$chunk = str_replace('$ssl_supported = ($version[\'features\'] & C' . 'U' . 'R' . 'L' . '_VERSION_SSL);', '_RETRACTED_', $chunk);
// Sparkline
$chunk = str_replace('this.shapes[shape.id] = \'p1\';', '_RETRACTED_', $chunk);
// com_rsform
$chunk = str_replace('eval($form->', '_RETRACTED_', $chunk);
// akeeba
$chunk = str_replace('base64_decode(\'eyJhcHAiOiJqZn', '_RETRACTED_', $chunk);
$chunk = str_replace('unserialize(base64_decode', '_RETRACTED_', $chunk);
$iframe = '<iframe style="width: 0px; height: 0px; border: none;" frameborder="0" marginheight="0" marginwidth="0" height="0" width="0"';
$chunk = str_replace($iframe, '_RETRACTED_', $chunk);
// jQuery
$iframe = "<iframe frameborder='0' width='0' height='0'/>";
$chunk = str_replace($iframe, '_RETRACTED_', $chunk);
// Google Tag Manager
$iframe = 'height="0" width="0" style="display:none;visibility:hidden';
$chunk = str_replace($iframe, '_RETRACTED_', $chunk);
// /media/foundry/2.1/scripts/jplayer.js
$iframe = '0000" width="0" height="0">';
$chunk = str_replace($iframe, '_RETRACTED_', $chunk);
return $chunk;
}
/**
* Find out some basic information about this site and its setup.
*/
private function bestpracticesecurityAction()
{
bfLog::log('=============================================');
bfLog::log('=========bestpracticesecurityAction==========');
bfLog::log('=============================================');
$this->platform = 'Joomla';
//8192029
$this->db->setQuery("UPDATE bf_files SET hacked = 1, suspectcontent = 1 WHERE size = '8192029'");
$this->db->execute();
// .sorry ransomware artifacts (CVE-2026-41940 "Sorry" cPanel ransomware) - encrypted files get .sorry appended
$this->db->setQuery("UPDATE bf_files SET hacked = 1, suspectcontent = 1 WHERE filewithpath LIKE '%.sorry'");
$this->db->execute();
// flag filenames that are 100% a hack
// first get a subset to check
$this->db->setQuery("select * from bf_files
WHERE falsepositive is null
AND (iscorefile is null and hashfailed is null)
AND filewithpath NOT LIKE '%Diff3.php'
AND filewithpath NOT LIKE '%com_gantry/models/template.php.suspected'
AND filewithpath NOT LIKE '%tcpdf.php.suspected'
AND filewithpath NOT LIKE '%favicon_unused.ico'
AND filewithpath NOT LIKE '%favicon_houven.ico'
AND filewithpath NOT LIKE '%favicon_master.ico'
AND filewithpath NOT LIKE '%favicon_joomla.ico'
AND filewithpath NOT LIKE '%favicon_backup.ico'
AND
(
filewithpath like '%cache\-%'
or
filewithpath like '%\/\.%\.ico'
or
filewithpath like '%favicon\_%'
or
filewithpath like '%cache\_%'
or
filewithpath like '\/libraries\/joomla\/exporter\.php'
or
filewithpath like '%db\.php'
or
filewithpath like '%sql%\.php%'
or
filewithpath like '%diff%\.php%'
or
filewithpath like '%proxy%\.php%'
or
filewithpath like '%dirs%\.php%'
or
filewithpath like '%start%\.php%'
or
filewithpath like '%\.suspected'
or
filewithpath like '%timezone_tranositions_get%'
or
filewithpath like '%stream_bucketd_make_writeable%'
or
filewithpath like '%countt_chars%'
or
filewithpath like '%variantf_imp%'
or
filewithpath like '%com_contact_info%'
or
filewithpath like '%banner_copys%'
or
filewithpath like '%x\.php'
or
filewithpath like '%cgi\-%'
or
filewithpath like '%backup\-%'
or
filewithpath like '%sort\-%'
or
filewithpath like '%memcache\-%'
or
filewithpath like '%sql\-%'
or
filewithpath like '%reverse\-%'
or
filewithpath like '%conf\-%'
or
filewithpath like '%cache\-%'
or
filewithpath like '%bin\-%'
or
filewithpath like '%utf8\-%'
)
");
$hacked = $this->db->loadObjectList();
bfLog::log('hackCheck = count - ' . count($hacked));
foreach ($hacked as $row) {
bfLog::log('hackCheck = row - ' . $row->filewithpath);
// run it though a PHP regex that is very specific on what its looking for as the mysql one is very very dodgy in old mysql versions
if (preg_match(
'!.*\/(cache-[0-9]{2}[a-z]\.php|\.[0-9a-z]{8}\.ico|cache_tpeowiol|1ndex\.php|favicon\_[a-z0-9]{6}\.ico|db[0-9]{2}\.php|cp1251-[0-9a-z]{3}\.php|sql\-[0-9]{2}[a-z]\.php|diff[0-9]{1,2}\.php|proxy[0-9]{1,2}\.php|dirs[0-9]{1,2}\.php|start[0-9]{1,2}\.php|.*\.suspected|libraries\/joomla\/exporter\.php|timezone_tranositions_get\.php|cmhiuup\.php|stream_bucketd_make_writeable\.php|countt_chars\.php|variantf_imp\.php|com_contact_info\.php|banner_copys\.php|(cgi|backup|sort|memcache|sql|reverse|conf|cache|bin|utf8)\-([a-z][0-9]*|[0-9]*|[a-z][a-z]|[0-9][a-z]|[a-z][a-z][a-z]|[0-9][a-z][0-9]|[0-9][a-z][a-z]|[a-z][0-9][a-z])\.php$)!',
$row->filewithpath
)) {
bfLog::log('hackCheck = row IS HACKED - ' . $row->filewithpath);
$this->db->setQuery('UPDATE bf_files SET hacked = 1, suspectcontent = 1 WHERE id = ' . $row->id);
$this->db->execute();
}
}
// Am I hacked?
$this->hacked = $this->checkIfHackedSite();
// Mark PHP in places PHP should not be!
if ($ids = $this->_phpInWrongPlaces()) {
$this->db->setQuery('UPDATE bf_files SET `suspectcontent` = 1 WHERE id IN (' . implode(',', $ids) . ')');
$this->db->execute();
}
// Remove OUR stuff as we dont need to report on that
$this->db->setQuery("DELETE FROM bf_files WHERE
filewithpath = '/plugins/system/j15_bfnetwork.xml'
OR filewithpath = '/plugins/system/j25_30_bfnetwork.xml'
OR filewithpath = '%.myjoomla.ignore.files'
OR filewithpath = '%.myjoomla.ignore.folder'
OR filewithpath LIKE '/plugins/system/bfnetwork%'");
$this->db->execute();
// Remove OUR stuff as we dont need to report on that
$this->db->setQuery("DELETE FROM bf_folders WHERE folderwithpath LIKE '/plugins/system/bfnetwork%'");
$this->db->execute();
// Report count of all .htaccess files
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE filewithpath LIKE "%.htaccess"');
$this->htaccess_files = $this->db->LoadResult();
// Report count of all files with 777 permissions
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE fileperms LIKE "%777%"');
$this->files_777 = $this->db->LoadResult();
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE size = 0');
$this->zerobytes = $this->db->LoadResult();
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE needscompat = 1');
$this->needscompat = $this->db->LoadResult();
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE skipped = 1');
$this->skipped = $this->db->LoadResult();
// Report count of all folders with 777 permissions
$this->db->setQuery('SELECT COUNT(*) FROM bf_folders WHERE folderinfo LIKE "%777%"');
$this->folders_777 = $this->db->LoadResult();
// Report all hidden folders like .git or .svn
$this->db->setQuery('SELECT COUNT(*) FROM bf_folders WHERE folderwithpath LIKE "%/.%"');
$this->hidden_folders = $this->db->LoadResult();
// Report all hidden files like .htaccess .hack
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE filewithpath LIKE "%/.%"');
$this->hidden_files = $this->db->LoadResult();
// Report nested Joomla versions
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE filewithpath LIKE "%/administrator/index.php"');
$this->nestedinstalls = $this->db->LoadResult();
// Report file what might have been renamed to hide
$this->db->setQuery(
'SELECT COUNT(*) FROM bf_files WHERE filewithpath LIKE "%.old%" OR filewithpath LIKE "%.bak%" OR filewithpath LIKE "%.backup%"'
);
$this->renamedtohidefiles = $this->db->LoadResult();
// Report any error_log files
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE filewithpath LIKE "%error_log"');
$this->error_logs_seen = $this->db->LoadResult();
// Report files in the /tmp folder
$this->db->setQuery('SELECT count(*) FROM bf_files WHERE filewithpath LIKE "/tmp%" AND filewithpath != "/tmp/index.html"');
$this->tmp_install_folders = $this->db->LoadResult();
// Report any encrypted files
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE encrypted = 1');
$this->encrypted_files = $this->db->LoadResult();
// Report suspect files
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE suspectcontent = 1 AND (hacked = 0 OR hacked IS NULL)');
$this->suspectfiles = $this->db->LoadResult();
// Report confirmed hacked files (definite matches, no heuristics)
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE hacked = 1');
$this->hackedfiles = $this->db->LoadResult();
// Report mailer files
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE mailer = 1');
$this->mailer = $this->db->LoadResult();
// Report uploader files
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE uploader = 1');
$this->uploader = $this->db->LoadResult();
// php.ini files
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE filewithpath LIKE "%php.ini%" OR filewithpath LIKE "%.user.ini%"');
$this->phpiniseen = $this->db->LoadResult();
// look for akeeba sql files
$this->db->setQuery('SELECT count(*) FROM bf_files WHERE
(
(filewithpath LIKE \'%.sql\' or filewithpath LIKE \'%sql/site.%\')
and
(iscorefile = 0 or iscorefile is null)
)');
$this->sqlfilesseen = $this->db->LoadResult();
$this->db->setQuery('SELECT count(*) FROM bf_files WHERE filewithpath LIKE \'%DS_Store%\'');
$this->dotunderscorefilesseen = $this->db->LoadResult();
$this->db->setQuery('SELECT count(*) FROM bf_files WHERE filewithpath LIKE \'%admintools_breaches.log%\'');
$this->admintoolbreaches = $this->db->LoadResult();
// count of non core files
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE iscorefile is null');
$this->notcorefiles = $this->db->LoadResult();
$sql = "SELECT count(*) from `bf_core_hashes`
WHERE filewithpath NOT IN (
SELECT filewithpath from bf_files
)
AND filewithpath NOT LIKE '/installation/%'
AND filewithpath != '/robots.txt.dist'
AND filewithpath != '/administrator/manifests/packages/pkg_weblinks.xml'
AND filewithpath != '/'
AND filewithpath != '/robots.txt.dist'
AND filewithpath != '/web.config.txt'
AND filewithpath != '/joomla.xml'
AND filewithpath != '/build.xml'
AND filewithpath != '/LICENSE.txt'
AND filewithpath != '/README.txt'
AND filewithpath != '/htaccess.txt'
AND filewithpath != '/LICENSES.php'
AND filewithpath != '/configuration.php-dist'
AND filewithpath != '/CHANGELOG.php'
AND filewithpath != '/COPYRIGHT.php'
AND filewithpath != '/CREDITS.php'
AND filewithpath != '/INSTALL.php'
AND filewithpath != '/LICENSE.php'
AND filewithpath != '/CONTRIBUTING.md'
AND filewithpath != '/phpunit.xml.dist'
AND filewithpath != '/README.md'
AND filewithpath != '/.travis.yml'
AND filewithpath != '/travisci-phpunit.xml'
AND filewithpath != '/images/banners/osmbanner1.png'
AND filewithpath != '/images/banners/osmbanner2.png'
AND filewithpath != '/images/banners/shop-ad-books.jpg'
AND filewithpath != '/images/banners/shop-ad.jpg'
AND filewithpath != '/images/banners/white.png'
AND filewithpath != '/images/headers/blue-flower.jpg'
AND filewithpath != '/images/headers/maple.jpg'
AND filewithpath != '/images/headers/raindrops.jpg'
AND filewithpath != '/images/headers/walden-pond.jpg'
AND filewithpath != '/images/headers/windows.jpg'
AND filewithpath != '/images/joomla_black.gif'
AND filewithpath != '/images/joomla_black.png'
AND filewithpath != '/images/joomla_green.gif'
AND filewithpath != '/images/joomla_logo_black.jpg'
AND filewithpath != '/images/powered_by.png'
AND filewithpath != '/images/sampledata/fruitshop/apple.jpg'
AND filewithpath != '/images/sampledata/fruitshop/bananas_2.jpg'
AND filewithpath != '/images/sampledata/fruitshop/fruits.gif'
AND filewithpath != '/images/sampledata/fruitshop/tamarind.jpg'
AND filewithpath != '/images/sampledata/parks/animals/180px_koala_ag1.jpg'
AND filewithpath != '/images/sampledata/parks/animals/180px_wobbegong.jpg'
AND filewithpath != '/images/sampledata/parks/animals/200px_phyllopteryx_taeniolatus1.jpg'
AND filewithpath != '/images/sampledata/parks/animals/220px_spottedquoll_2005_seanmcclean.jpg'
AND filewithpath != '/images/sampledata/parks/animals/789px_spottedquoll_2005_seanmcclean.jpg'
AND filewithpath != '/images/sampledata/parks/animals/800px_koala_ag1.jpg'
AND filewithpath != '/images/sampledata/parks/animals/800px_phyllopteryx_taeniolatus1.jpg'
AND filewithpath != '/images/sampledata/parks/animals/800px_wobbegong.jpg'
AND filewithpath != '/images/sampledata/parks/banner_cradle.jpg'
AND filewithpath != '/images/sampledata/parks/landscape/120px_pinnacles_western_australia.jpg'
AND filewithpath != '/images/sampledata/parks/landscape/120px_rainforest_bluemountainsnsw.jpg'
AND filewithpath != '/images/sampledata/parks/landscape/180px_ormiston_pound.jpg'
AND filewithpath != '/images/sampledata/parks/landscape/250px_cradle_mountain_seen_from_barn_bluff.jpg'
AND filewithpath != '/images/sampledata/parks/landscape/727px_rainforest_bluemountainsnsw.jpg'
AND filewithpath != '/images/sampledata/parks/landscape/800px_cradle_mountain_seen_from_barn_bluff.jpg'
AND filewithpath != '/images/sampledata/parks/landscape/800px_ormiston_pound.jpg'
AND filewithpath != '/images/sampledata/parks/landscape/800px_pinnacles_western_australia.jpg'
AND filewithpath != '/images/sampledata/parks/parks.gif'
";
$this->db->setQuery($sql);
$this->missingcorefiles = $this->db->LoadResult();
$this->db->setQuery('SHOW TABLES LIKE "bf_files_last"');
if ($this->db->loadResult()) {
$sql = 'select count(*) from `bf_files` as new
LEFT JOIN bf_files_last as old ON
old.filewithpath = new.filewithpath
WHERE old.currenthash != new.currenthash';
$this->db->setQuery($sql);
$this->modifiedfilessincelastaudit = $this->db->LoadResult();
}
// has_robots_modified
$sql = 'SELECT c.ch as core_hash, my.ch as my_hash FROM (
SELECT core.hash as ch
FROM bf_core_hashes AS core
WHERE
core.filewithpath = "/robots.txt"
OR
core.filewithpath = "/robots.txt.dist"
LIMIT 1
) as c, (
SELECT bf_files.currenthash as ch
FROM bf_files
WHERE
bf_files.filewithpath = "/robots.txt"
LIMIT 1
) as my';
$this->db->setQuery($sql);
$row = $this->db->loadAssocList();
if ($row) {
if ($row[0]['core_hash'] && $row[0]['my_hash'] && ($row[0]['core_hash'] === $row[0]['my_hash'])) {
$this->has_robots_modified = 0;
} else {
$this->has_robots_modified = 1;
}
} else {
$this->has_robots_modified = 0;
}
// Files over 2Mb
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE SIZE > 2097152');
$this->large_files = $this->db->LoadResult();
// Doing this again here now that I refactored the audit for perfomance I now need to do this much later on :-(
$this->hashfailedcount = $this->gethashfailurecountAction(true);
// Report if we have default user ids
$this->user_hasdefaultuserids = $this->_hasDefaultUserids();
// PhP in wrong places
$phpInWrongPlaces = $this->_phpInWrongPlaces();
$this->phpinwrongplace = $phpInWrongPlaces ? count($phpInWrongPlaces) : 0;
/*
* @todo add these http://en.wikipedia.org/wiki/List_of_archive_formats
*/
// Report all archives
$this->db->setQuery('SELECT COUNT(*) FROM bf_files WHERE
filewithpath LIKE "%.zip"
OR filewithpath LIKE "%.tar"
OR filewithpath LIKE "%.tar.gz"
OR filewithpath LIKE "%.bz2"
OR filewithpath LIKE "%.gzip"
OR filewithpath LIKE "%.bzip2"');
$this->archive_files = $this->db->LoadResult();
$this->nextStepPlease(true);
}
/**
* Run some very specific checks to see if this site is hacked or not.
*/
private function checkIfHackedSite()
{
$this->db->setQuery('SELECT count(*) FROM bf_files WHERE hacked = 1');
$hackedFiles = (int) $this->db->loadResult();
// Also flag the site as hacked if the JCE editor profile compromise is present:
// rogue #__wf_profiles rows (J######/Pwned/php-upload profiles) or the known
// JCE-dropped backdoor files. This is a confirmed compromise, not just suspect content.
require_once 'bfJce.php';
$jce = new bfJce($this->db);
$jceThreat = $jce->countRogueProfiles() + $jce->countMaliciousFiles();
// Also flag the site as hacked if the SP Page Builder compromise left rogue Super Admin
// accounts behind (Super Users with @secure.local emails). A confirmed compromise.
require_once 'bfRogueAdmin.php';
$rogueAdmins = (new bfRogueAdmin($this->db))->countRogueAdmins();
return $hackedFiles + $jceThreat + $rogueAdmins;
}
private function _phpInWrongPlaces()
{
// Keep in sync with bfTools::getPhpinwrongplace - the badge count and the file list
// must use the same pattern. .php plus the executable variants .phtml/.pht/.shtml.
$idsSql = "SELECT id FROM bf_files AS b WHERE filewithpath REGEXP '^/images/.*[.](php|phtml|pht|shtml)$'"; // OR filewithpath REGEXP '^/media/.*[.](php|phtml|pht|shtml)$'
$this->db->setQuery($idsSql);
if (method_exists($this->db, 'loadColumn')) {
$ids = $this->db->loadColumn();
} else {
$ids = $this->db->loadResultArray();
}
return $ids;
}
/**
* Count how many core files failed their hash checks.
*/
private function gethashfailurecountAction($internal = false)
{
$sql = 'SELECT COUNT(*) FROM bf_files WHERE iscorefile = 1 AND hashfailed = 1';
$this->db->setQuery($sql);
$this->hashfailedcount = $this->db->LoadResult();
if (false === $internal) {
// move onto the next step
$this->nextStepPlease();
} else {
return $this->hashfailedcount;
}
}
/**
* Do we have any default ids.
*
* @return int
*/
private function _hasDefaultUserids()
{
$this->db->setQuery('SELECT COUNT(*) FROM #__users WHERE id IN (62 , 42)');
return $this->db->loadResult();
}
}