[Из песочницы] Детектим виртуальную машину на C#: 1 уровень

Одним жуть каким прохладным январским утром от знакомого прилетел вопрос как на C# определить, не запущена ли программа в ОС (оконное приложение в ОС Windows 7 или новее) на виртуальной машине.


Требования к такому детектору были достаточно жёсткими:

  1. Должен быть полностью в исходных кодах,
  2. Должен собираться с помощью Visual Studio,
  3. Должен работать из-под аккаунта непривилегированного пользователя (нельзя использовать методы, требующие, к примеру, установки драйверов устройств, или иных операций, для которых нужны права администратора),
  4. Разрешено использовать .NET Framework 4.5 и никаких лишних зависимостей (типа Visual C++ Redistributable Package).

Под катом описание реализованного детектора на C# (в следующей части с некоторыми элементами C++) и приличным количеством неприличного кода с использованием Visual Studio 2015 Community.


Структура публикации


  • 1 уровень. Изучение матчасти и простейших существующих решений:
    • немного теории касательно виртуализации,
    • реализация проверки ВМ с помощью данных из Windows Management Instrumentation (WMI).
  • 2 уровень. Поиск статей и публикаций про детектирование запуска в виртуальных машинах:
    • допиливаем реализацию с WMI,
    • работа с инструкцией CPUID.
  • 3 уровень. Поиск материалов с хакерских конференций:
    • допиливаем работу с CPUID,
    • делаем сводную таблицу параметров и результатов тестирования.

1 уровень. Изучение матчасти и существующих решений


Немного теории касательно виртуализации


Прежде, чем пытаться писать детектор сферического коня в вакууме виртуальной машины, следует кратко обозначить, как в рамках задачи мы понимаем термин «виртуальная машина».


Понятие виртуализации можно поделить на две категории (1):

  • Первая виртуализация ресурсов. Рассмотрим на живом примере, почему принцип работы сервиса хранения файлов Dropbox можно трактовать как виртуализацию ресурсов:
    1. Мы точно знаем, как загружать/выгружать файлы, и как взаимодействовать с сервисом, но как это всё работает внутри мы с уверенностью сказать не можем => инкапсуляция.
    2. У каждого пользователя есть свой уникальный аккаунт, у каждого аккаунта установлена квота на размер сохраняемых файлов, для каждого аккаунта можно настраивать разрешения на доступ индивидуально (хотя по факту данные разных пользователей вполне могут храниться на одном и том же носителе информации) => разделение ресурсов.
    3. Скорее всего, под капотом Dropbox находится не один и не два компьютера, а как минимум пара сотен серверов, функционирующих и обрабатывающих команды от клиентов в рамках системы Dropbox как единое целое => кластеризация.
  • Вторая виртуализация платформ создание программных систем на основе существующих аппаратно-программных комплексов, зависящих или независящих от них (1).

Во второй категории сразу введём два термина: система, предоставляющая аппаратные ресурсы и ПО для виртуализации (хостовая система, host) и эмулируемая система (гостевая система, guest).


При этом в реальности в роли «гостевой системы» могут выступать:

  1. Абсолютно всё аппаратное и программное обеспечение эмулируемой системы такой тип виртуализации называется полной эмуляцией или симуляцией. Примеры программ, обеспечивающих такой тип виртуализации: Bochs, QEMU.
  2. Всё программное обеспечение и только часть аппаратного (часть достаточная для обеспечения изоляции гостевой системы) такой тип виртуализации назовём частичной эмуляцией или нативной виртуализацией. Примеры программ, обеспечивающих такой тип виртуализации: VMWare Workstation, VMWare ESXi, Microsoft Hyper-V, Oracle VirtualBox.
  3. Также существуют частичная виртуализация, паравиртуализация, виртуализация уровня операционной системы и виртуализация уровня приложений. Во всех трёх случаях физически ОС у нас одна, а гостевыми системами считаются либо отдельные процессы, либо отдельные группы процессов (например, user-mode процессы).

Итог сего экскурса: в рамках статьи и создания детектора виртуальной машины нас будут интересовать только нативная виртуализация платформ (то есть проверять мы будем только запуск в окружении Hyper-V, VirtualBox или других программ, использующих нативную виртуализацию). При этом далее термин «виртуальная машина» мы будем трактовать согласно определению с сайта VMWare: «это строго изолированный контейнер ПО, содержащий операционную систему и приложения» (2).


Реализация проверки ВМ с помощью данных из Windows Management Instrumentation (WMI)


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


Прочитав замечательно свёрстанные рекламные страницы разработчиков популярных программ виртуализации, в голове вырисовывается некая общая схема их работы (разумеется схема работы программ, а не разработчиков):


  • Есть хостовая ОС. Не ограничивая общности будем считать, что она одна.
  • На хостовой ОС установлена программа для обеспечения виртуализации (далее гипервизор).
  • Гипервизор предоставляет интерфейс для установки и последующей работы гостевой ОС.

Примечание: имеют место случаи, когда хостовая ОС и гипервизор есть единое тонкое целое, что позволяет уменьшить расходование ресурсов компьютера по сравнению с использованием хостовой ОС и гипервизора по отдельности (примеры: VMWare ESXi или Windows Hyper-V Server).

Вот только на практике почти в каждом гипервизоре имеется возможность установить «гостевые дополнения» (guest additions) специальный набор программ и драйверов, дающих гипервизору
расширенный контроль за функциями гостевой ОС (проверка, а не зависла ли гостевая ОС, динамическое изменение доступной ОС оперативной памяти, «общая» мышка для хостовой и гостевой ОС). Однако, как же реализуют такое действо, если, согласно рекламе, «ВМ это строго изолированный контейнер ПО»?


Получается, что гостевые дополнения, устанавливаемые на гостевую ОС, каким-то строго определённым образом взаимодействуют напрямую с гипервизором, запущенным в хостовой ОС. То есть если программа определения ВМ сможет воспользоваться таким взаимодействием она докажет, что ОС запущена на ВМ! Правда, по условиям задачи доказательство надо проводить из-под User-Mode без использования собственных драйверов…


Сразу вырисовываются следующие места для проверки:

  • Поиск определённого оборудования в системе, которого на физической машине просто не может быть.
  • Поиск аппаратных интерфейсов, которые реализованы только в ВМ (или наоборот, только на физической машине).

Собственно, если ввести в поисковой строке «detect hyper-v C#» или «detect vmware C#», примерно на это и натыкаешься, осталось только обобщить.

Наиболее полное описание различных критериев проверки было найдено в статье 2013 года в журнале Хакер (3), возьмём статью за основу. А для получения соответствующих данных об оборудовании и процессах ОС воспользуемся механизмом Windows Management Instrumentation (WMI) дословно «инструментарием управления Windows». В частности, через WMI можно несложно, быстро и без прав администратора получить большое количество информации об оборудовании, которое видит ОС.


Для получения данных через WMI нам понадобится построить запрос на языке WQL (WMI Query Language), который по сути является сильно упрощённым SQL. Например, если мы хотим получить через WMI имеющуюся в ОС информацию о процессорах, требуется выполнить следующий запрос:


SELECT * FROM Win32_Processor

Ответ на этот запрос набор объектов типа Win32_Processor с заранее известными названиями полей (подробный список доступных полей и классов см. в 4). Разумеется, если нам не требуются все-все поля, вместо * можно перечислить через запятую только необходимые. В WQL-операторе SELECT, по аналогии с SQL, также поддерживается условие WHERE, позволяющее делать выборку только по объектам, значения в полях которых удовлетворяют указанным условиям.


Для «затравки» научимся получать следующие данные из WMI-объектов следующих типов (данные и ожидаемые в ВМ значения взяты из 3):

WMI-объект и его свойства Условие на WQL-запрос объектов Как использовать
Win32_Processor  
     Manufacturer   В случае VirtualBox равен 'VBoxVBoxVBox', в случае VMWare 'VMwareVMware', в случае Parallels 'prl hyperv '.
Win32_BaseBoard  
     Manufacturer   В случае Hyper-V равен 'Microsoft Corporation' при том, что Microsoft материнские платы не выпускает (интересно, а что показывает этот параметр на планшетах Microsoft Surface?).
Win32_DiskDrive  
     PNPDeviceID   В случае VirtualBox содержит 'VBOX_HARDDISK', в случае VMWare содержит 'VEN_VMWARE'.
Win32_NetworkAdapter  
     MACAddress PhysicalAdapter=1 Известно, что по трём старшим байтам MAC-адреса можно определить производителя и производители виртуальных машин не исключение (то есть если адаптер с признаком PhysicalAdapter=1, но имеет MAC-адрес из пула VMWare то с высокой вероятностью программа была запущена на ВМ).
Win32_Process  
     Name   При установке гостевых дополнений на ВМ в системе появляются дополнительные процессы с известными именами.

Реализуем получение данных об оборудовании через WMI в отдельном проекте в виде библиотеки TTC.Utils.Environment.


Структурируем проект следующим образом:

  1. Entities объекты с данными (сущности), полученными от WMI.
  2. Services сервисы; например, служба, инкапсулирующая взаимодействие с WMI-обёрткой .NET.
  3. Interfaces интерфейсы; например, интерфейс сервиса работы с WMI.
  4. Queries объекты, содержащие параметры запросов к WMI, с помощью которых извлекаются заданные типы сущностей.

Хочется, чтобы пользователь данной библиотеки мог написать примерно такой код:


var bios = wmiService.QueryFirst(new WmiBiosQuery());
var processors = wmiService.QueryAll(new WmiProcessorQuery());

и не волновался по поводу механизма взаимодействия с WMI, построения запроса или преобразования ответа в строго типизированный класс языка C#.


Что ж, реализовать такое на самом деле не очень сложно.


Сначала подключим к проекту ссылку на библиотеку System.Management (именно в ней находятся классы .NET для доступа к WMI). Далее опишем интерфейс сервиса IWmiService (реализация этого интерфейса будет извлекать данные и преобразовывать их в строго типизированные объекты):

Код IWmiService.cs
/// 
/// Интерфейс сервиса получения данных Windows Management Instrumentation (WMI).
/// 
public interface IWmiService
{
    /// 
    /// Получение первой записи из указанного запроса к WMI.
    /// 
    /// Тип сущности, в которую выгружаются результаты запроса.
    /// Объект, содержащий параметры WMI-запроса.
    /// Сущность с результатами запроса.
    TResult QueryFirst(WmiQueryBase wmiQuery)
        where TResult : class, new();

    /// 
    /// Получение набора записей из указанного запроса к WMI.
    /// 
    /// Тип сущности, в которую выгружаются результаты запроса.
    /// Объект, содержащий параметры WMI-запроса.
    /// Коллекция сущностей с результатами запроса.
    IReadOnlyCollection QueryAll(WmiQueryBase wmiQuery)
        where TResult : class, new();
}


Теперь установим как будут выглядеть сущности в нашем проекте. Предположим для детектирования нам потребуются следующие поля из WMI-объектов типа Win32_BaseBoard:
Код WmiBaseBoard.cs — до
public class WmiBaseBoard
{
    public string Manufacturer { get; private set; }

    public string Product { get; private set; }

    public string SerialNumber { get; private set; }
}


В идеале надо писать DTO, чтобы с его помощью транслировать данные из результата WML-запроса в вышеуказанную сущность, но если постулировать, что свойства сущностей в проекте будут 1 к 1 соответствовать полям объектов из результатов WML-запроса, то делать DTO на каждую сущность значит писать достаточно много однообразного кода.

Воспользуемся главным свойством любого программиста (ленью) и вместо создания полноценной DTO просто отметим атрибутом каждое свойство следующим атрибутом, позволяющим связать свойство и поле результата WML-запроса:

Код WmiResultAttribute.cs
/// 
/// Указание, какому свойству сущности соответствует поле объекта WMI.
/// 
[AttributeUsage(AttributeTargets.Property)]
public class WmiResultAttribute : Attribute
{
    public WmiResultAttribute(string propertyName)
    {
        PropertyName = propertyName;
    }

    /// 
    /// Имя поля в объекте WMI.
    /// 
    public string PropertyName { get; }
}


Разметив свойства сущности указанными атрибутами, получим:

Код WmiBaseBoard.cs — после
public class WmiBaseBoard
{
    internal const string MANUFACTURER = "Manufacturer";
    internal const string PRODUCT = "Product";
    internal const string SERIAL_NUMBER = "SerialNumber";

    // ReSharper disable UnusedAutoPropertyAccessor.Local
    [WmiResult(MANUFACTURER)]
    public string Manufacturer { get; private set; }

    [WmiResult(PRODUCT)]
    public string Product { get; private set; }

    [WmiResult(SERIAL_NUMBER)]
    public string SerialNumber { get; private set; }
    // ReSharper restore UnusedAutoPropertyAccessor.Local
}


Осталось разобраться с объектом, который будет хранить запрос. Уверен, вы обратили внимание, что в предыдущем примере кода названия полей WQL-результатов запроса вынесены в internal-константы. Это было сделано специально чтобы не дублировать их в классе запроса. Кстати, получился интересный побочный эффект с использованием такой модели вы не сможете прочесть из WMI данные поля некоторого WMI-объекта пока не укажете, в какое свойство какой сущности он должен извлекаться.

Код WmiQueryBase.cs
using System.Management;
/// 
/// Базовый класс данных параметров запроса к WMI.
/// 
public class WmiQueryBase
{
    private readonly SelectQuery _selectQuery;

    /// 
    /// Конструктор запроса к WMI.
    /// 
    /// Название таблицы, к которой производится запрос.
    /// Условие запроса.
    /// Результирующие столбцы запроса.
    protected WmiQueryBase(string className, 
        string condition = null, string[] selectedProperties = null)
    {
        _selectQuery = new SelectQuery(className, condition, selectedProperties);
    }

    /// 
    /// Объект со сформированным SELECT-запросом к WMI.
    /// 
    internal SelectQuery SelectQuery
    {
        get { return _selectQuery; }
    }
}


Код WmiBaseBoardQuery.cs
using TTC.Utils.Environment.Entities;
public class WmiBaseBoardQuery : WmiQueryBase
{
    public WmiBiosQuery()
        : base("Win32_BaseBoard", null, new[]
        {
            WmiBios.MANUFACTURER,
            WmiBios.PRODUCT,
            WmiBios.SERIAL_NUMBER,
        })
    {
    }
}


При такой структуре классов *Query есть только одна неприятность: неудобно формировать параметры WHERE-части WML-запроса внутри класса. Приходится действовать по старинке и ручками формировать строку в зависимости от параметров:

Код WmiNetworkAdapterQuery.cs
using System.Text;
using TTC.Utils.Environment.Entities;
public class WmiNetworkAdapterQuery : WmiQueryBase
{
    private static readonly string[] COLUMN_NAMES =
        {
            WmiNetworkAdapter.GUID,
            WmiNetworkAdapter.MAC_ADDRESS,
            WmiNetworkAdapter.PNP_DEVICE_ID,
        };

    public WmiNetworkAdapterQuery(WmiNetworkAdapterType adapterType = WmiNetworkAdapterType.All)
        : base("Win32_NetworkAdapter", null, COLUMN_NAMES)
    {
        if (adapterType == WmiNetworkAdapterType.Physical)
            SelectQuery.Condition = "PhysicalAdapter=1";
        else if (adapterType == WmiNetworkAdapterType.Virtual)
            SelectQuery.Condition = "PhysicalAdapter=0";
    }
}


Хорошо: данные по сущностям раскидали, запросы писать с грехом-пополам научились, осталось только разобраться как будет выглядеть сервис, работающий с указанными классами:

Код WmiService.cs
/// 
/// Сервис получения данных Windows Management Instrumentation (WMI).
/// 
public class WmiService : IWmiService
{
    /// 
    /// Извлечение заданных в запросе столбцов из полученных записей WMI с приведением типов.
    /// 
    /// Тип сущности, в которую выгружаются результаты запроса.
    /// Объект, полученный в результате запроса WMI.
    /// Сущность с результатами запроса.
    private static TResult Extract(ManagementBaseObject managementObject)
        where TResult : class, new()
    {
        var result = new TResult();
        foreach (var property in typeof(TResult).GetProperties())
        {
            var wmiAttribute = (WmiResultAttribute)Attribute.GetCustomAttribute(property, typeof(WmiResultAttribute));
            if (wmiAttribute != null)
            {
                var sourceValue = managementObject.Properties[wmiAttribute.PropertyName].Value;
                var targetType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
                object targetValue;
                if (sourceValue == null)
                {
                    targetValue = null;
                }
                else if (targetType == typeof(DateTime))
                {
                    targetValue = ManagementDateTimeConverter.ToDateTime(sourceValue.ToString()).ToUniversalTime();
                }
                else if (targetType == typeof(Guid))
                {
                    targetValue = Guid.Parse(sourceValue.ToString());
                }
                else
                {
                    targetValue = Convert.ChangeType(
                        managementObject.Properties[wmiAttribute.PropertyName].Value, targetType);
                }
                property.SetValue(result, targetValue);
            }
        }
        return result;
    }

    /// 
    /// Получение набора данных из указанного запроса к WMI.
    /// 
    /// Запрос для получения данных.
    /// Существующий объект для выполнения запросов к WMI.
    /// Результирующая коллекция объектов в таблице.
    private ManagementObjectCollection QueryAll(SelectQuery selectQuery, ManagementObjectSearcher searcher = null)
    {
        searcher = searcher ?? new ManagementObjectSearcher();
        searcher.Query = selectQuery;
        return searcher.Get();
    }

    /// 
    /// Получение первой строки данных из указанного запроса к WMI.
    /// 
    /// Запрос для получения данных.
    /// Существующий объект для выполнения запросов к WMI.
    /// Результирующая коллекция объектов в таблице.
    private ManagementBaseObject QueryFirst(SelectQuery selectQuery, ManagementObjectSearcher searcher = null)
    {
        return QueryAll(selectQuery, searcher).Cast().FirstOrDefault();
    }

    public TResult QueryFirst(WmiQueryBase wmiQuery)
        where TResult : class, new()
    {
        var managementObject = QueryFirst(wmiQuery.SelectQuery);
        return managementObject == null ? null : Extract(managementObject);
    }

    public IReadOnlyCollection QueryAll(WmiQueryBase wmiQuery)
        where TResult : class, new()
    {
        var managementObjects = QueryAll(wmiQuery.SelectQuery);
        return managementObjects?.Cast()
            .Select(Extract)
            .ToList();
    }
}


Пара слов касательно метода WmiService.Extract.

У объектов WMI обычно достаточно большое количество свойств (причем многие поля могут иметь значение NULL). В предположении, что в рамках задачи выгружать из WMI мы будем только небольшое количество свойств объектов, логично начать маппинг данных с перебора свойств результирующей сущности. Далее, при наличии у свойства атрибута WmiResultAttribute мы считываем из объекта результата запроса значение свойства с указанным в атрибуте именем и выполняем преобразование типов. При этом, если свойство сущности имеет тип, с которым стандартный метод Convert.ChangeType не справится или преобразует тип не так, как нам хочется, мы легко можем передать управление на своё преобразование (как это сделано для типов System.DateTime и System.Guid).


Кстати, было бы ещё лучше разделить Extract на два метода: первый извлекает информацию из типа класса, второй заполняет экземпляры (иначе метод QueryAll для второго и последующих элементов выходной коллекции делает ненужную работу по повторному изучению структуры его типа). Но конкретно для целей детектирования виртуалки мы вряд ли будет ожидать более 10 объектов за один запрос, поэтому предлагаю списать эту задачу с пометкой «не реализовано, ибо природная лень». Но если у кого-то дойдут руки до такой модификации с радостью приму вашу доработку.


Послесловие


Чтобы не заканчивать эту часть статьи только библиотекой, сделаем самое простое приложение, использующее возможности данной библиотеки для детектирования нескольких самых популярных виртуальных машин фирм VMWare, Microsoft, Parallels и Oracle на осове вышеизложенных критериев.


Создадим отдельный проект консольное приложение TTC.Utils.VMDetect и создадим в нём такой класс DemoTrivialVmDetector:

Код WmiService.cs
/// 
/// Тестовый класс для проверки запуска из-под ВМ.
/// 
class DemoTrivialVmDetector
{
    private readonly IWmiService _wmiService;

    public DemoTrivialVmDetector(IWmiService wmiService)
    {
        _wmiService = wmiService;
    }

    public MachineType GetMachineType()
    {
        var wmiProcessor = _wmiService.QueryFirst(new WmiProcessorQuery());
        if (wmiProcessor.Manufacturer != null)
        {
            if (wmiProcessor.Manufacturer.Contains("VBoxVBoxVBox"))
                return MachineType.VirtualBox;
            if (wmiProcessor.Manufacturer.Contains("VMwareVMware"))
                return MachineType.VMWare;
            if (wmiProcessor.Manufacturer.Contains("prl hyperv"))
                return MachineType.Parallels;
        }

        var wmiBaseBoard = _wmiService.QueryFirst(new WmiBaseBoardQuery());
        if (wmiBaseBoard.Manufacturer != null)
        {
            if (wmiBaseBoard.Manufacturer.Contains("Microsoft Corporation"))
                return MachineType.HyperV;
        }

        var wmiDiskDrives = _wmiService.QueryAll(new WmiDiskDriveQuery());
        if (wmiDiskDrives != null)
            foreach (var wmiDiskDrive in wmiDiskDrives)
            {
                if (wmiDiskDrive.PnpDeviceId.Contains("VBOX_HARDDISK"))
                    return MachineType.VirtualBox;
                if (wmiDiskDrive.PnpDeviceId.Contains("VEN_VMWARE"))
                    return MachineType.VMWare;
            }

        return MachineType.Unknown;
    }
}


Весь код, включая библиотеку и простейшее тестовое приложение, выложен в репозитории на github, отзывы и комментарии приветствуются.


В следующей части мы чутка структурируем работу с известными ВМ и с помощью ассемблерной инструкции CPUID попробуем детектировать уже неизвестные ВМ.


Источники


  1. Виртуализация: новый подход к построению IT-инфраструктуры
  2. Виртуализация при помощи VMWare
  3. Детектим виртуалки (xakep.ru)
  4. WMI: Win32 Provider (MSDN)

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

  • 24 февраля 2017 в 09:52

    +1

    К сожалению, ваш 1 уровень (да и последующие, к сожалению) — совершенно бесполезный детект, без обид. Эти строковые константы у vmWare / VirtualBox (про Hyper-V не встречал) патчатся на ура и есть готовые решения, недавно совсем натыкался на совсем уж киддиса, который в лоб смог забить почти все детекты, вот, почитайте на досуге: https://exelab.ru/f/index.php? action=vthread&forum=5&topic=24207
    • 24 февраля 2017 в 09:57 (комментарий был изменён)

      +1

      И там Клерк объявился с интересным предложением (ядро Win 8+, встроенный механизм детекта виртуальной среды по таймингам), скину сюда, т.к. ссылки не могу нормально вставлять:
      ExpDetectHypervisorCr3Heuristic ()
      NTSTATUS
      NtQueryLicenseValue (
          PUNICODE_STRING "Kernel-VMDetection-Private",
          NULL,
          PBOOL Result,
          ULONG 4,
          PULONG ResultLength);
      
      Так что, отставить строковые константы оборудования и cpuid (с ним давно уже покончено), надо смотреть в сторону того, что нет эмуляции TLB и всяких хитрых таймингов
    • 24 февраля 2017 в 10:04 (комментарий был изменён)

      0

      Приветствую!

      Во-первых огромное спасибо за ссылку — одна из целей публикации это сбор дополнительной информации касательно особенностей детектирования ВМ.

      А насчёт бесполезности — мне кажется вы рано клеймите этим словом последующие уровни. У первого уровня задача по сути обозначить проблему. На втором уровне добавляется детектирования через инструкцию CPUID (EAX=40000000h) и через бит hypervisor present (EAX = 1, смотрим 31-бит в ECX). Это поведение гипервизоров пропатчить чуть сложнее.
      А на 3 м уровне рассматриваются уже особенности реализации некоторых гипервизоров для определения.
      Приведу конкретный пример: применительно к тому же VMWare — в ВМ чипсет компьютера детектируется как Intel 440BX (это socket 370, уровень Pentium II). Соответственно, увидев в системе указаный чипсет вкупе с процессором Intel Kaby Lake, да ещё и одноядерным — мы можем почти со 100% уверенностью утверждать что это виртуалка.

  • 24 февраля 2017 в 10:16

    0

    Добавлю свои закладки:
    1) Attacks on Virtual Machine Emulators
    2) On the Cutting Edge: Thwarting Virtual Machine Detection
    • 24 февраля 2017 в 10:22

      0

      Большое спасибо! Обязательно просмотрю.

© Habrahabr.ru