Как отловить бэкдуры (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:

bash
yum install php-devel

Если у вас PHP 7:

клонируем репозиторий

bash
git clone https://github.com/extremecoders-re/php-eval-hook
cd php-eval-hook

Для PHP 8:

bash
git clone https://github.com/dev-bx/php8-eval-hook
cd php8-eval-hook

Затем собираем модуль:

bash
phpize
./configure --enable-evalhook
make
make install

Прописываем его, чтобы загружался:

bash
echo "extension=evalhook.so" > /etc/php.d/10-evalhook.ini

Перезапускаем Apache:

bash
systemctl restart httpd

Включаем перехват

Создаём файл /home/bitrix/www/evalhook.php:

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 прописываем строчку:

apache
php_value auto_prepend_file /home/bitrix/www/evalhook.php

Она указывает PHP, что перед выполнением любого файла нужно выполнить этот скрипт, — директива auto_prepend_file.

Проверяем

Проверяем работу скриптом evaltest.php:

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:

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 вызывается штатно и ничего криминального в этом нет.

Пример штатного вызова:

log
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
------------------------------

В статье использованы материалы

← Все статьи