Как отловить бэкдуры (backdoor)
6 мин16.08.2024
Отловить бэкдуры или трояны на большом проекте — головная боль для программиста и системного администратора. Даже система контроля версий не панацея: она покажет изменённые файлы, но не подскажет, какой из них вредоносный.
Большинство бэкдуров для PHP обфусцированы, чтобы их было труднее найти, и после распаковки своего кода вызывают eval. Вот здесь мы их и поймаем.
Ниже — метод перехвата eval: как записывать её вызовы в журнал и блокировать подозрительные. Способ подходит для VDS, VPS или выделенного сервера: понадобится собрать своё расширение для PHP, а на обычном хостинге такой возможности нет.
Окружение
Сборка модуля и примеры проверены на такой связке:
- CentOS 7;
- 1С-Битрикс: Веб-окружение 7.5.5;
- PHP 8.1.29;
- сайт установлен в папку
/home/bitrix/www.
Собираем расширение
Ставим заголовочные файлы PHP:
yum install php-devel
Если у вас PHP 7:
клонируем репозиторий
git clone https://github.com/extremecoders-re/php-eval-hook
cd php-eval-hook
Для PHP 8:
git clone https://github.com/dev-bx/php8-eval-hook
cd php8-eval-hook
Затем собираем модуль:
phpize
./configure --enable-evalhook
make
make install
Прописываем его, чтобы загружался:
echo "extension=evalhook.so" > /etc/php.d/10-evalhook.ini
Перезапускаем Apache:
systemctl restart httpd
Включаем перехват
Создаём файл /home/bitrix/www/evalhook.php:
<?php
/*
* Copyright (c) 2024. DEV-BX.RU
* ruslan@dev-bx.ru
*/
class DevBxLogger {
private $filename = false;
/**
* @var bool
*/
private $logRequestUri = true;
/**
* @var bool
*/
private $logTrace = true;
/**
* @var bool
*/
private $logFooter = true;
public static function checkDirPath($path)
{
if (function_exists('CheckDirPath'))
{
return CheckDirPath($path);
}
//remove file name
if(mb_substr($path, -1) != "/")
{
$p = mb_strrpos($path, "/");
$path = mb_substr($path, 0, $p);
}
$path = rtrim($path, "/");
if($path == "")
{
//current folder always exists
return true;
}
if(!file_exists($path))
{
return mkdir($path, 0755, true);
}
return is_dir($path);
}
public static function getBackTrace($limit = 0, $options = null, $skip = 1)
{
if(!defined("DEBUG_BACKTRACE_PROVIDE_OBJECT"))
{
define("DEBUG_BACKTRACE_PROVIDE_OBJECT", 1);
}
if ($options === null)
{
$options = ~DEBUG_BACKTRACE_PROVIDE_OBJECT;
}
$trace = debug_backtrace($options, ($limit > 0? $limit + $skip : 0));
if ($limit > 0)
{
return array_slice($trace, $skip, $limit);
}
return array_slice($trace, $skip);
}
protected static function formatTrace(array $trace = null)
{
if ($trace)
{
$traceLines = array();
foreach ($trace as $traceNum => $traceInfo)
{
$traceLine = '';
if (array_key_exists('class', $traceInfo))
$traceLine .= $traceInfo['class'].$traceInfo['type'];
if (array_key_exists('function', $traceInfo))
$traceLine .= $traceInfo['function'].'()';
if (array_key_exists('file', $traceInfo))
{
$traceLine .= ' '.$traceInfo['file'];
if (array_key_exists('line', $traceInfo))
$traceLine .= ':'.$traceInfo['line'];
}
if ($traceLine)
$traceLines[] = ' from '.$traceLine;
}
return implode("\n", $traceLines);
}
else
{
return "";
}
}
public static function getFileNameForLog($trace)
{
$arRegTpl = array(
'component.$1-%Y-%m-%d.log' => '#.*\/components\/.+\/(.+)\/.*$#U',
'module.$1-%Y-%m-%d.log' => '#.*\/modules\/(.+)\/.*$#U',
'admin.$1-%Y-%m-%d.log' => '#\/bitrix\/admin\/(.+)\..*$#',
'$1-%Y-%m-%d.log' => '#.*\/(.+\.php)$#',
);
$fileName = 'debug-%Y-%m-%d.log';
foreach ($trace as $traceNum => $traceInfo)
{
if (array_key_exists('file', $traceInfo))
{
foreach ($arRegTpl as $replace=>$pattern)
{
if (preg_match($pattern, $traceInfo['file']))
{
$fileName = preg_replace($pattern,$replace, $traceInfo['file']);
break 2;
}
}
}
}
return static::formatFileName($fileName);
}
private static function formatFileName($fileName)
{
$d = new \DateTime();
$arMacro = array(
'%Y' => $d->format('Y'),
'%y' => $d->format('y'),
'%m' => $d->format('m'),
'%d' => $d->format('d'),
'%H' => $d->format('H'),
'%h' => $d->format('h'),
'%i' => $d->format('i'),
'%s' => $d->format('s'),
);
return str_replace(array_keys($arMacro),array_values($arMacro), $fileName);
}
private static $registryMap = [];
private function __construct($filename)
{
$this->filename = $filename;
}
public static function getInstance($filename = false)
{
if (isset(self::$registryMap[$filename]))
return self::$registryMap[$filename];
self::$registryMap[$filename] = new static($filename);
return self::$registryMap[$filename];
}
public function setLogRequestUri($value): DevBxLogger
{
$this->logRequestUri = $value === true;
return $this;
}
public function setLogTrace($value): DevBxLogger
{
$this->logTrace = $value === true;
return $this;
}
public function setLogFooter($value): DevBxLogger
{
$this->logFooter = $value === true;
return $this;
}
public function logVar($var, $varName = '', $traceSkip = 1): DevBxLogger
{
$arTrace = self::getBackTrace(30, null, $traceSkip);
if ($this->filename === false)
{
$logFilename = $_SERVER['DOCUMENT_ROOT'].DIRECTORY_SEPARATOR.'logs'.DIRECTORY_SEPARATOR.static::getFileNameForLog($arTrace);
} else
{
$logFilename = $_SERVER['DOCUMENT_ROOT'].DIRECTORY_SEPARATOR.'logs'.DIRECTORY_SEPARATOR.static::formatFileName($this->filename);
}
$header = '';
if (isset($_SERVER["REQUEST_URI"]) && $_SERVER["REQUEST_URI"] && $this->logRequestUri)
$header .= "REQUEST URI: ".$_SERVER["REQUEST_URI"]."\n";
$trace = static::formatTrace($arTrace);
$body = '';
if ($varName)
$body = $varName.":\n";
if (is_object($var) || is_array($var))
$body .= var_export($var, true);
else
$body .= $var;
$footer = str_repeat("-", 30);
$logSessid = '';
$logTime = '';
if (function_exists('bitrix_sessid_val'))
{
$logSessid = "\nSESSID: ".bitrix_sessid_val();
}
if (isset($_SERVER['REQUEST_TIME_FLOAT']))
{
$logTime = "\nTIME: ".(microtime(true)-$_SERVER['REQUEST_TIME_FLOAT']);
}
if (!isset($_SERVER['DEVBX_DEBUG_ID']))
{
$_SERVER['DEVBX_DEBUG_ID'] = md5(uniqid());
}
$logDebugId = "\nDEBUG ID: ".$_SERVER['DEVBX_DEBUG_ID'];
$message =
($header ? "\n" . $header : '').
"\nDate: ".date("Y-m-d H:i:s") .
$logSessid.
$logTime.
$logDebugId.
"\n" . $body .
($this->logTrace ? "\n\n" . $trace : '').
($this->logFooter ? "\n" . $footer : '').
"\n";
self::checkDirPath($logFilename);
file_put_contents($logFilename, $message, FILE_APPEND);
return $this;
}
public static function log($var, $varName = '', $fileName = false)
{
self::getInstance($fileName)->logVar($var, $varName, 2);
}
}
function __eval($code, $file)
{
$arTrustRules = [
'/^\$eval_result=/',
'/^return CSite::/',
'/^namespace/',
'/public static function getMap()/m',
'/^return \(\(isset\(/mi',
'/extends \\\\Bitrix\\\\Main\\\\ORM\\\\/mi',
'/\$FORM->ShowInput/mi',
];
$arBlockRules = [
'/require_once/mi',
'/wget_file_upload/mi',
'/system\(/mi',
'/exec\(/mi',
'/copy\(/mi',
'/copy_file_upload/mi',
'/file_raw_upload/mi',
'/file_put_contents/mi',
'/inject_include_file/mi',
'/chmod/mi',
'/base64_decode/mi',
'/ini_get/mi',
'/touch/mi',
'/opendir/mi',
'/mkdir/mi',
'/shell_exec/mi',
'/fwrite/mi',
'/readfile/mi',
'/evaltest/mi',
];
foreach ($arBlockRules as $rule) {
if (preg_match($rule, $code)) {
DevBxLogger::log($code, $file, 'eval-block-hook-%Y-%m-%d.log');
return '';
}
}
foreach ($arTrustRules as $rule) {
if (preg_match($rule, $code))
return $code;
}
DevBxLogger::log($code, $file, 'evalhook-%Y-%m-%d.log');
return $code;
}
function devbxCheckPostData()
{
$postData = file_get_contents('php://input');
$arBlockRules = [
'/require_once/mi',
'/wget_file_upload/mi',
'/exec\(/mi',
'/copy\(/mi',
'/copy_file_upload/mi',
'/file_raw_upload/mi',
'/file_put_contents/mi',
'/inject_include_file/mi',
'/chmod/mi',
'/base64_decode/mi',
'/ini_get/mi',
//'/touch/mi',
'/opendir/mi',
'/mkdir/mi',
'/shell_exec/mi',
'/fwrite/mi',
'/readfile/mi',
'/evaltest/mi',
'/\$\_SERVER/mi',
'/wp_43/mi',
'/ext_www/mi',
];
foreach ($arBlockRules as $rule) {
if (preg_match($rule, $postData)) {
DevBxLogger::log(array(
'rule' => $rule,
'rawData' => $postData,
'$_POST' => $_POST,
'$_REQUEST' => $_REQUEST,
'$_FILES' => $_FILES,
), 'postData', 'postdata-block-hook-%Y-%m-%d.log');
die();
}
}
if (!empty($_FILES)) {
foreach ($_FILES as $ar) {
if ($ar['size'] > 0 && $ar['error'] == 0) {
$fileData = file_get_contents($ar['tmp_name']);
foreach ($arBlockRules as $rule) {
if (preg_match($rule, $fileData)) {
DevBxLogger::log(array(
'rule' => $rule,
'fileData' => $fileData,
'$_POST' => $_POST,
'$_REQUEST' => $_REQUEST,
'$_FILES' => $_FILES,
), 'postData', 'postdata-file-block-hook-%Y-%m-%d.log');
die();
}
}
}
}
}
}
devbxCheckPostData();
В /home/bitrix/www/.htaccess прописываем строчку:
php_value auto_prepend_file /home/bitrix/www/evalhook.php
Она указывает PHP, что перед выполнением любого файла нужно выполнить этот скрипт, — директива auto_prepend_file.
Проверяем
Проверяем работу скриптом evaltest.php:
<?php
eval("shell_exec('ls'); echo 'injection success';die();");
echo 'injection failed';
Открываем его в браузере — на экране должно появиться injection failed.
Что попадает в журнал
В папке /home/bitrix/www/logs появится файл с именем eval-block-hook- и датой, например eval-block-hook-2024-08-16.log:
REQUEST URI: /evaltest.php
Date: 2024-08-16 14:32:52
TIME: 0.0065360069274902
DEBUG ID: 3f9caf496039270d16f7dab563060167
/home/bitrix/www/evaltest.php(2) : eval()'d code:
shell_exec('ls'); echo 'injection success';die();
from DevBxLogger::log() /home/bitrix/www/evalhook.php:306
from __eval() /home/bitrix/www/evaltest.php:2
from eval() /home/bitrix/www/evaltest.php:2
------------------------------
Ещё evalhook.php проверяет POST-запросы на инъекции кода и блокирует их, записывая в журнал с именем postdata-block-hook- и датой.
Весь пропущенный код eval скрипт пишет в отдельный журнал — evalhook- с датой. У Битрикса много мест, где eval вызывается штатно и ничего криминального в этом нет.
Пример штатного вызова:
REQUEST URI: /
Date: 2024-08-16 14:35:39
SESSID: 10660d3d043af7d4a3468573867381ef
TIME: 0.038811922073364
DEBUG ID: 2d8e9318f6e99b02d76be926df0d6a67
/home/bitrix/www/bitrix/modules/main/classes/general/site.php(930) : eval()'d code:
return preg_match("#^/desktop_app/router.php\?alias=([\.\-0-9a-zA-Z]+)&videoconf#", $GLOBALS['APPLICATION']->GetCurPage(0));
from DevBxLogger::log() /home/bitrix/www/evalhook.php:316
from __eval() /home/bitrix/www/bitrix/modules/main/classes/general/site.php:930
from eval() /home/bitrix/www/bitrix/modules/main/classes/general/site.php:930
from CAllSite::GetCurTemplate() /home/bitrix/www/bitrix/modules/main/include.php:1
from require_once() /home/bitrix/www/bitrix/modules/main/include/prolog_before.php:19
from require_once() /home/bitrix/www/bitrix/modules/main/include/prolog.php:10
from require_once() /home/bitrix/www/bitrix/header.php:1
from require() /home/bitrix/www/index.php:2
------------------------------