Привет, Хабр! Меня зовут Анастасия Соколенко и с теми, кто читал мои предыдущие статьи, мы уже знакомы. Я отвечаю за безопасную разработку в Битрикс, а здесь рассказываю о том, как разработчикам делать сайты максимально безопасными. 

В своих предыдущих статьях (здесь и здесь) я рассказывала о четырёх типовых уязвимостях веб-приложений и о том, как разработчикам предусмотреть защиту от них при написании кода.

Сегодня поговорим о трёх методах, которые помогут противостоять злоумышленникам. Это нормализация путей, безопасная работа с десериализацией и криптоподпись (Signer).

Нормализация путей

Веб-приложения, которые предполагают загрузку или скачивание файлов, подвержены риску атак на обход путей (path traversal attacks). Предположим, что путь к определённому файлу на сервере передаётся через GET-запросы — в этом случае злоумышленник, используя определённые комбинации символов вида “/../”, может получить доступ либо к системным, либо к другим интересующим его файлам, если он знает абсолютные пути.

Эта уязвимость работает на тех сайтах, где отсутствует грамотная валидация входных данных, в том числе, замена абсолютных путей на относительные.

Рассмотрим, как правильно нормализовать пути в Битрикс.

Класс Bitrix\Main\IO\Path — то, что нужно начинающему путеводителю. Конкретно нас интересует нормализация пути (normalize) — функция принимает непустую строку и нормализует её в зависимости от системы, Win или Unix.

В чем заключается нормализация:

  • Два и более слэшей подряд заменяются на один.

  • Корректно обрабатываются "." и "..": убираются из текущего пути или заменяются на директорию выше уровнем соответственно. Всё происходит в рамках переданной строки, поэтому, например, если начать нормализацию с "..", скрипт бросит исключение.

Помимо этого в классе есть набор функций для получения имени, расширения, пути и прочей информации, но в этой статье я на них останавливаться не буду.

Как надо

Так как же сделать путь к файлу безопасным, если мы получаем его от пользователя? Простые правила:

  1. Всегда нужно знать директорию, в которой должен лежать файл: DOCUMENT_ROOT, tmp, upload и т.д.

  2. Если от пользователя ждём только название или относительный путь, нормализуем его и конкатенируем с нужной папкой.

  3. Если вдруг по какой-то причине из запроса прилетает абсолютный путь, нормализуем его и проверяем, что начинается он из допустимого места.

Примеры (абстрактные):

// Допустим, мы хотим отдать пользователю файл по GET-запросу. Мы знаем, где лежат файлы, 
//которые пользователь может захотеть с текущей страницы, поэтому принимаем от него только имя
$path = $_GET['file'];

// Нормализуем, чтобы в путь не записалось ничего лишнего
$path = Bitrix\Main\IO\Path::normalize($path);

// И конкатенируем с директорией, откуда его надо отдать
$path = \CTempFile::GetAbsoluteRoot()."/" . $path;

// Теперь даже если сильно захотеть, вверх по каталогам подняться не получится.
// А тут зачем-то ждём абсолютный путь (не надо так в реальной жизни)
$path = $_GET['path'];

// Нормализуем, дабы хакер не обходил наши проверки
$path = Bitrix\Main\IO\Path::normalize($path);

// И проверяем, что это ожидаемая директория
if (strpos($path, \CTempFile::GetAbsoluteRoot()) === 0){ ... }

И ещё раз: плохо валидированные пути позволят хакерам ходить по вашим каталогам как у себя дома. Чинится это нормализацией путей.

Что ещё важно

  1. Не забывайте обрабатывать исключения. Если в normalize передать что-то, чего там быть не должно, оно выбросит InvalidPathException.

  2. Избегайте передачи абсолютных путей в запросах. Относительные пути — наше всё.

  3. Не забывайте, что хакер может ходить не только вверх по каталогам, но и в сторону, и даже вниз. Всегда ли у него есть для этого права?

Десериализация

Для начала напомню о том, что такое сериализация и десериализация, а затем разберёмся, в чём здесь опасность и как её избежать.

Сериализация это способ представить какие-то структуры данных или состояния объектов в формате, удобном для хранения или передачи. Десериализация, соответственно, это обратный процесс извлечения структур или объектов. Например, когда веб-сервер сохраняет корзину покупок пользователя в базу в виде JSON-строки, а потом восстанавливает её при новом заходе — это и есть сериализация и десериализация.

В PHP есть функции serialize() и unserialize(). Данные они представляют в своем специальном формате. Вот так, например, сериализуется массив:

$arr = ['first' => 1, 'second' => 'two'];
$ser = serialize($arr);
// a:2:{s:5:"first";i:1;s:6:"second";s:3:"two";}

$orig = unserialize($ser);
//Array ( [first] => 1 [second] => two )

А вот так объект:

class test {
   public $var = 'var value';
}

$obj = new test();
$ser = serialize($obj);
// O:4:"test":1:{s:3:"var";s:9:"var value";}

$orig = unserialize($ser);
// test Object ( [var] => var value )

Всё просто и удобно. Но что, если мы позволяем пользователю передавать в эти функции какие-то непроверенные данные? Тут-то и начинаются проблемы.

Небезопасная десериализация

Если мы позволяем в том или ином виде передавать в функцию unserialize() произвольные данные от пользователя, злоумышленник, во-первых, может подменить какие-то чувствительные данные внутри, а во-вторых, попытаться передать свой зловредный объект вместо ожидаемого в коде сериализованного массива.

Кажется, что ничего страшного в этом нет, ведь дальше мы ничего небезопасного с ним не делаем. Но дальше от нас уже ничего не зависит. Процессы запущены.

Тут на сцену выходят магические методы, которые умеют выполняться при определённых условиях без прямого вызова. Вот эти методы волнуют хакеров в первую очередь:

  • __destruct — выполнится при уничтожении объекта (при завершении работы скрипта или же при явном удалении объекта);

  • __wakeup — выполнится, когда объект десериализуется;

  • __unserialize — если он существует, то выполнится вместо wakeup;

  • __toString — выполнится при попытке представить объект в качестве строки;

  • __sleep — выполнится при сериализации объекта.

Есть и другие методы, но пока хватит и этих.

Магия вне министерства магии: функции никто не вызывал, а они приехали взяли и вызвались самостоятельно.

class SerializeTest {
	public function __construct(){
		echo "__construct method called<br>";
	}
    public function __sleep(){
		echo "__sleep method called<br>";
		return array("s");
	}
    public function __wakeup(){
		echo "__wakeup method called<br>";
	}
    public function __toString()
	{
		return "__toString method called<br>";
	}
	public function __destruct(){
		echo "__destruct method called<br>";
	}
}

$o = new SerializeTest();
$ser = serialize($o);
$unser = unserialize($ser);
echo $o;


/*
  __construct method called
  __sleep method called
  __wakeup method called
  __toString method called
  __destruct method called
  __destruct method called
  */

Если внутри этих магических методов есть что-то, что может причинить вред (например, eval в __destruct), то всё пропало: злоумышленник сможет выполнить произвольные команды, украсть какие-нибудь данные и т.д.

А если нет, мы в безопасности? Как бы не так, веселье только начинается.

Цепочки гаджетов

Классы с магическими методами могут быть только началом более сложной атаки, которая будет включать в себя длинную цепочку вызовов разных методов разных классов. Называется это цепочкой гаджетов.

"Гаджеты" — это фрагменты кода в приложении. Отдельно они могут не причинять никакого вреда даже с непроверенным пользовательским вводом, однако могут служить для передачи инпута в другие методы других классов. Сцепляя несколько гаджетов, хакер может потенциально передать произвольные данные в какой-то опасный метод, где урон будет максимальным. Причём никакого вредного кода злоумышленник не пишет, всё уже написано разработчиком :)

Простенький пример:

class Example
{

   private $obj;

   function __construct()
   {
      // some PHP code...
   }

   function __wakeup()
	{
		if (isset($this->obj))
		{
			return $this->obj->evaluate();
		}
		else
		{
			// ...
		}
	}
}

class CodeSnippet
{

   private $code;

   function evaluate()
   {
      eval($this->code);
   }
}

$user_data = unserialize($_POST['data']);

Видишь суслика? А он есть:

O:7:"Example":1:{s:12:"Exampleobj";O:11:"CodeSnippet":1:{s:17:"CodeSnippetcode";s:10:"phpinfo();";}}

Тут в код передаётся цепочка из двух гаджетов, которая сначала попадает в __wakeup класса Example, а оттуда в evaluate класса CodeSnippet. Вот так можно было её сконструировать:

class CodeSnippet
{
   private $code = "phpinfo();";
}

class Example
{
   private $obj;

   function __construct()
   {
      $this->obj = new CodeSnippet;
   }
}

echo serialize(new Example);

И длина таких цепочек неограничена. Логика работы приложения полностью нарушена из-за непроверенных пользовательских данных в функции unserialize(). А найти цепочку гаджетов в исходниках может оказаться задачей очень непростой, но всё же решаемой.

Что поможет сделать код безопаснее

Кошмары закончились, давайте теперь о том, как всего этого не допустить.

  1. JSON
    Если есть возможность представлять данные в каком-то другом формате, то лучше использовать его. Самый простой и надёжный вариант - JSON.

  2. ['allowed_classes' => false]
    Если всё-таки планируем получать что-то от пользователя, нельзя позволить ему внедрять какие-то объекты в наш код. Для этого в функции unserialize() есть второй аргумент, в который можно либо передать массив разрешённых безопасных классов, либо запретить вообще любые классы:

    unserialize($var, ['allowed_classes' =>
    		\Bitrix\Main\Type\DateTime::class,
    		\Bitrix\Main\Type\Date::class
    ]);
    
    unserialize($var, ['allowed_classes' => false]);
  3. CheckSerializedData
    В main/tools.php есть функция CheckSerializedData, которая с помощью регулярного выражения  определяет наличие объектов в сериализованной строке и возвращает false, если что-то нашлось. Способ чуть менее надёжный, чем белый список или полный запрет классов, но тоже имеет место.

    function CheckSerializedData($str)
    {
       if(preg_match('/(^|;)[OC]\\:\\+{0,1}\\d+:/', $str)) // serialized objects
       {
          return false;
       }
        return true;
    }
    
  4. Криптографическая подпись
    Для подписей у нас есть класс Bitrix\Main\Security\Sign\Signer(). О нём речь пойдет ниже. Пока достаточно знать, что им можно подписать данные с помощью метода sign() и проверить корректность подписи методами unsign() или validate():

    $signer = new \Bitrix\Main\Security\Sign\Signer;
    
    $parameters = $signer->sign(base64_encode(serialize($parameters)), 'some.salt');
    $signer = new \Bitrix\Main\Security\Sign\Signer;
    
    $parameters = $signer->unsign($_POST['parameters'], 'some.salt');
    $parameters = unserialize(base64_decode($parameters), ['allowed_classes' => false])
    
    

    Если подпись некорректна, методы кинут исключение.

  5. Валидация
    Если мы получаем какие-то данные от пользователя, их надо проверять. Даже если мы обезопасили себя от небезопасной десериализации, нам могут подсунуть чужой id, небезопасный путь и так далее. Произвольный код хакер, может быть, и не выполнит, но неприятностей всё равно доставит. Помним про белые списки и не доверяем никому, ничему и никогда :)

Здесь можно почитать про десериализацию не только в PHP, но и в других языках.

Криптоподпись (Signer)

Передача важных данных по незащищённым каналам несёт в себе потенциальную опасность — пользователь может подменить данные, добавить вредоносный код. Чтобы защититься от этой уязвимости, необходимо подтверждать авторство данных, а в нашем случае — передаваемых значений. Для этого существует криптоподпись — валидируя ее, можно понять, действительно ли данные не менялись в процессе передачи.

Представим, что вы отправили пользователю какой-то набор параметров и ждёте, что он передаст их в другое место. Передать он, может, и передаст, но по пути обязательно что-нибудь подменит. Для защиты от этого нужна подпись. Отправляем пользователю подписанное значение, и если потом подпись окажется некорректной, значит мы имеем дело с хакером.

Как это работает у нас

Не будем погружаться в перипетии работы ЭЦП, а сразу посмотрим на нашу реализацию. Для подписей у нас есть классы \Bitrix\Main\Security\Sign\Signer и \Bitrix\Main\Security\Sign\TimeSigner и их достаточно, чтобы ничего не изобретать самостоятельно.

Оба класса обладают одинаковыми методами и отличаются только в том, что подписи у TimeSigner - временные и после истечения срока годности считаются некорректными. Далее будем говорить о Signer, но всё то же самое справедливо и для TimeSigner.

В основном нам понадобится:

  1. Подписывать строки методом sign().

  2. Возвращать оригинальное значение из подписанного методом unsign() (при условии, что подпись не подделана).

  3. Валидировать подпись с помощью validate().

  4. Получать значение подписи для какой-либо строки методом getSignature().

  5. Паковать и распаковывать несколько значений методами pack() и unpack() (нужно, чтобы подписать сразу несколько строк одной подписью).

Давайте сразу к примеру. Пусть у нас будет абстрактная задача — передать название какой-то задачи $task и её исполнителя $assignee из одного скрипта в другой.

Для этого выполним базовые приготовления:

$task = "Tell about cryptosignature";
$assignee = "Security kitty";

$signer = new \Bitrix\Main\Security\Sign\Signer(); // Создаём объект класса Signer
$pack = $signer->pack([$task, $assignee]); // Пакуем наши значения в одну строку

И дальше у нас есть два варианта. Либо мы передаём полностью подписанное сообщение:

$signed = $signer->sign($pack, "some.salt");
echo $signed; // Подписываем и отдаём клиенту. В подписи участвует соль; зачем она нам - узнаете в конце статьи

// Tell about cryptosignature.Security kitty.2d07db07054d1257f0567985df5554bae512ed63d4bdcd646af929cd478a28a0

А в каком-то другом скрипте получаем подписанное значение из запроса и ловим ошибку в случае, если подпись некорректна:

try
{
   $packed = $signer->unsign($_REQUEST['pack'], "some.salt");
}
catch (BadSignatureException)
{
   ...
}

Либо мы отдельно передаём значение и подпись, чтобы потом иметь возможность её провалидировать:

$signature = $signer->getSignature($pack, "some.salt"); // В $signature будет уникальная подпись для вашего сообщения
echo \Bitrix\Main\Web\Json::encode([$pack, $signature]);

// ["Tell about cryptosignature.Security kitty","2d07db07054d1257f0567985df5554bae512ed63d4bdcd646af929cd478a28a0"]

Вот так потом проверяем, что всё корректно:

$pack = $_REQUEST['pack'];
$signature = $_REQUEST['sign'];
if ($signer->validate($pack, $signature, "some.salt")){
    ...
}

Вот и всё, ничего сложного :)

Повторим пример из десериализации с небольшими комментариями:

$signer = new Signer()

$signature = $signer->sign(base64_encode(serialize($params)), "some.salt");
// В base64 кодируем, чтобы не передавать явно сериализованную строку.

...

$res = unserialize(base64_decode($signer->unsign($signature, "some.salt")), ["allowed_classes" => false]);
// Тут мы уверены, что значение подписи не изменялось, и безопасно её десериализуем. Соль, разумеется, используется одна и та же

Про соль

В криптографии соль — это последовательность данных, которую добавляют к криптоключу, чтобы предотвратить декодирование информации методом перебора.

Использовать соль необязательно, но настойчиво рекомендуется и вот почему. Представим, что мы вообще не используем соль, а у хакера есть возможность подписать произвольную строку. Чем это чревато? Правильно, он подпишет что-то конфиденциальное и отправит в какую-нибудь критичную функцию. Там эти данные провалидируются и начнут делать свои злые дела.

Чтобы этого избежать, нужна соль. Даже одинаковый набор данных, подписанный с разной солью, будет иметь разную подпись, поэтому такой трюк у хакера не пройдёт.
Разумеется, для каждой операции соль должна быть своей. Можно считать это чем-то вроде пространства имён.

Пара замечаний:

  1. Методы из TimeSigner отличаются только тем, что в signgetSignature и validate надо дополнительно передавать метку времени. Используйте их, если хотите ограничить срок жизни подписанных данных.

  2. У Signer'а есть ключ, и он, само собой, должен содержаться в секрете. Для этого половина этого ключа хранится в БД (в таблице b_option), а половина - в конфигах, чтобы его нельзя было получить через SQLi или чтение локальных файлов. Однако, во-первых, через SQLi + LFI его всё-таки можно достать, а во-вторых, таблица b_option кэшируется, поэтому в некоторых случаях одного только LFI будет достаточно, чтобы узнать секретный ключ.

На сегодня всё, берегите свои данные, закрывайте известные уязвимости и мониторьте появление новых. Пишите в комментариях, какие еще проблемы в безопасности для вас важны — разберём вместе!

Комментарии (2)


  1. softvnedr
    03.06.2025 00:02

    Мы должны подписывать данные, только эту подпись в двух местах всё равно можно получить и подделать) короче если очень сильно заморочиться видимо взломать можно всё что захочешь.

    По мне так разработчики Битрикс в первую очередь должны позаботиться об этом, чтобы защитить ядро.

    Но всегда есть Вася, которого обидели, он ушёл и знает все дыры.

    А потом начинается, у тех данные улетели в даркнет, эти купили данные и делают по ним рассылки и т.д.


  1. softvnedr
    03.06.2025 00:02

    Мораль, давайте не будем оставлять двери открытими) но кто-то возможно нарочно их оставляет, чтобы поймать на живца как говорится)) ведь всегда хочется взгляну в глаза этому бедолаге бледному, который по каким-то непонятным причинам это делает, может ему просто делать нечего или есть какой-то фин интерес, но задать вопрос хочется, а потом прокатиться в лес, дать ему лопату в руки и заставить рыть, возможно тогда у этих людей просыпается голос, который в будущем уже отговаривает от подобных действий))