Как отловить бэкдуры (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',
        '/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 что перед выполнением любого 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
------------------------------