Как отловить бэкдуры (backdoor)

Отловить бэкдуры или трояны на большом проекте бывает головная боль для программиста/системного администратора если он туда попал, даже если проект использовал Git или другую систему контроля версий это не всегда панацея что бы найти backdoor.
Большинство бэкдуров для PHP обсуфицированы что бы их было труднее найти и после распаковки своего кода используют eval, вот здесь мы и будем их ловить.

В данной статье будет описан метод перехвата функции eval логирования ее и блокирования подозрительных вызовов, данный способ актуален для хостинга на VDS/VPS или выделенном сервере (Dedicated Server) так как на потребуется собрать свое расширение для PHP.

Сборка модуля и примеры актуальные для:
  • CentOS 7
  • 1С-Битрикс: Веб-окружение 7.5.5
  • PHP 8.1.29
  • Сайт установлен в папку /home/bitrix/www

Ставим
 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',
         '/kutep.ua/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',
         '/kutep.ua/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 что перед выполнением любого php файла нужно выполнить этот скрипт
https://www.php.net/manual/ru/ini.core.php#ini.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-дата.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-дата.log
весь eval код который скрипт пропустил он запишет в лог evalhook-дата.log у Битрикса много мест где используется 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
 ------------------------------