-
Автор темы
- #1
Шалом. Давно не виделись, прошло целых полгода с моей последней статьи, особо реверсить для статьи было нечего, но тут мне блинчик поведал об Overwatch 2. Лето приближается к своему каноничному закату, поэтому пока было свободное время я прошёлся по некоторым модулям защиты этой игры, но основной упор сделал на деобфускации импортов игры.
Начнём с самого простого модуля - анти-дизассемблирование.
Анти-Дизассемблирование
Суть этой техники в том, чтобы визуально дизассемблер начал неправильно декодировать инструкции. Обычно реверсер это замечает в тот момент, когда при листинге дизассемблера у него меняются некоторые инструкции.
В чем же суть реализации такой техники, как в Overwatch? Вы все наверняка обращали внимание на выравнивание INT3 между функциями.
Что же делает Overwatch? Он проходится по некоторым INT3 инструкциям и заменяет их на рандомные байты, тем самым он ломает следующую последовательность инструкции
После патчинга следующая функция после патчинга выравнивания приобретает такой вид:
Как можно заметить, последовательность была нарушена и визуально пострадала инструкция `mov qword ptr ss:[rsp+20],r9`
И есчо пару примерчиков:
После:
Но существует и второй способ, он схож с первым.
В трюке есть условный JCC, который прыгает на другую часть кода, однако поверх этой части кода фигурируют неиспользуемые рандомные байты. Сделаны они для того, чтобы сломать правильность декода дальнейших инструкции. Когда вы делаете этот джамп, вас перебрасывает на валидный код, но стоит вам сделать прокрут колесом мыши вверх или вниз - дизассемблированный код приобретет совсем иной вид. Для внесения ясности работы, я проиллюстрировал эту ситуацию, дизассемблированный пример был взят прямиком из игры.
![1693401815116.png 1693401815116.png](https://yougame.biz/data/attachments/257/257370-a258345eb59ffc00762f0125288b5a76.jpg)
Загрузчик
Переходим плавно к загрузчику игры. Изначально всё и вся зашифровано в программе, за исключением первого TLS-каллбека. Его роль в том, чтобы дешифровать остальные TLS-каллбеки, расшифровать текст секцию и создать свою IAT.
Так как изначально все кроме первого каллбека остаются зашифрованным, в дебаггере надо снять INT3/INT 3/UD2 бряки, которые отладчик может их ставить в зависимости от ваших настроек, иначе не игра сможет нормально расшифровать остальные каллбеки и вас благополучно крашнет.
Self-Remapping
Первый TLS-Callback полностью ремаппит все секции программы, эти регионы получают флаг SEC_NO_CHANGE, из-за которого как-либо изменять память больше невозможно. Исходя из этого в дебаггере урезаются возможности ставить бряки, которые патчут память (INT3/INT 3/UD2, аппаратные остаются функционально способными).
К счастью, автор того PoC выложил плагин для x64dbg [
Шифрование импортов
В разделе "Загрузчик" я рассказывал о том, что TLS-каллбек создают свою IAT, так вот:
вместо обыкновенной IAT в игре присутствует таблица указателей, которая используется для расшифровки адреса нужного импорта.
Но для начала разберёмся как вообще появилась память для этих указателей, ведь каждый запуск их адрес будет разным.
Первым делом происходит аллокация виртуальной памяти путём вызова VirtualAlloc.
Дальше TLS-каллбек заполняет аллоцированную память рандомными байтами, для которых помимо всего прочего использовался rdtsc для рандомизации.
Если убрать весь джанк-код и сделать код линейным, то мы получим следующий фрагмент кода:
Такая итерация будет выполняться до тех пор, пока вся память не будет заполнена случайными байтами.
Вызов импорта выглядит обыкновенным на первый взгляд, но после вызова происходит декрипт нужного импорта.
Первый способ связан с использованием только инструкции ADD
Происходит несколько таких операции и после декрипта происходит прыжок по нужной функции.
Пример:
Второй способ связан уже с инструкцией XOR
Третий способ связан с использованием SUB и ADD сразу
Четвертый способ связан с инструкцией IMUL
Мы напишем плагин, который будет декриптить код и выдавать нам адрес импорта, который был изначально зашифрован под тоннами арифметических операции.
Подготовка плагина
Подготовим всё для регистрации команды фикса импортов.
Регистрируем нашу команду с помощью x64pluginsdk и её функции - "_plugin_registercommand".
Внутри нашего каллбека мы будем считывать аргумент и брать его базовый адрес.
То есть пример работы команды будет выглядеть следующий образом:
"z_fix_imports 00007FF767F37BD1" и после этого плагин получит его базовый адрес - 00007FF766900000.
В целом, давайте проясним работу алгоритма поиска зашифрованных импортов.
Он не самый лучший и не самый быстрый по своей работе, но раз заспидранил, то ничего не поделаешь
Плагин будет проходится по всей текст секции и искать две инструкции:
После нахождения инструкции мы будем дизассемблировать инструкцию по этому указателю, если это mov rax, imm64, то это наш пациент, которого мы будем деобфусцировать.
Как будем деобфусцировать и дизассемблировать?
Для дизассемблирования возьмём библиотеку Zydis, для деобфускации - Unicorn Engine
Unicorn Engine - эмулятор цпу, позволяющий выполнять код в отдельно выделенной среде.
У игры очень простенькая обфускация, можно обойтись простой эмуляцией этих инструкции и в конце прочитать значение регистра. Таким образом мы получим наш заветный импорт.
Сам алгоритм проиллюстрирован так:
![1693402121215.png 1693402121215.png](https://yougame.biz/data/attachments/257/257372-d216f5ea358aff3c12f53c8aa404bf76.jpg)
Написание деобфускатора
Подготовим нашим символы для дальнейшего опознавания импорта по его адресу.
После подготовки символов мы реализуем уже сам каллбек:
Как и упоминалось выше: мы читаем аргумент и берём его базовый адрес, затем инициализируем символы и начинаем поиск наших потеряшек
Сам неймспейс Import выглядит так:
В Import::Search мы дизассемблируем каждую инструкцию и проверяем мнемонику и инфу, взятую с операнда:
После проверки на нужную нам инструкцию мы вычисляем значение абсолютного адреса для операнда инструкции и проверяем его на валидность, поскольку по какой-то причине не все указатели в операндах call и jmp являются валидными.
В ScanIatEntry мы передаем адрес инструкции call/jmp qword ptr и вычисленный абсолютный адрес операнда.
Переходим плавно к ScanIatEntry, в ней мы будем дизассемблировать полученный адрес операнда и проверять его, не является ли это mov инструкцией с операндом imm, если это оно, то подготавливаем нашу выделенную среду для эмуляции кода
Начинаем подготавливать наш эмулятор:
Про существование такого прекрасного эмулятора мало кто знает, поэтому буду расписывать код поэтапно.
Для начала нам надо создать новый экземпляр, чтобы в будущем все дальнейшие функции могли работать только с этим экземпляром.
Создаём новый вектор, где будут находится байты нашего адреса
Для нашей среды нам необходимо мапнуть этот адрес, и записать в него наш буффер, чтобы было с чем работать
Теперь осталось только зарегать каллбек для хука, т.е. когда будем стартовать эмуляция - хук сработает и мы сможем видеть работу декрипта.
В 5 параметре uc_hook_add принимается юзер дата, то есть вы можете подставить туда любую свою структуру с нужными данными и в хуке сможете пользоваться ею, как к примеру я добавил туда структуру с адресом call qword ptr/jmp qword ptr, с которого всё и началось
Со всем мы закончили, осталось запустить сам эмулятор с помощью uc_emu_start
К слову, uc_emu_start не создаёт новый поток, а начинаю эмуляцию в главном потоке плагина, поэтому проверка на возвращаемый статус функции можно получить только после конца эмуляции
Полный код функции:
После вызова uc_emu_start будет вызван наш долгожданный хук Emulator::HookCode
Самая нужная информация будет находится именно в нём
Для начала мы будем дизассемблировать каждую трассированную эмулятором функцию:
Когда мы про дизассемблируем инструкцию мы её залоггируем в журнале отладчика и проверим не является ли инструкция 'jmp reg', которая нам нужна:
Если наша проверка проходит, то мы останавливаем эмуляцию и читаем значение по регистру и берем название импорта по адресу
Раньше я думал, что в шеллкоде игры под конец декрипта импортов прыжок происходит всегда по значению в RAX, пока не увидел jmp R8, поэтому я закостылил конвертацию регистров из Zydis в тип Unicorn
Полный код:
Так, ладно, чо та я отвлёкся, попробуем запустить наше чудо
![1693402655497.png 1693402655497.png](https://yougame.biz/data/attachments/257/257373-56de3a6b433b8b21ba2135f320571520.jpg)
И ура! Мы смогли добыть нужный импорт, однако, есть и такие ситуации, когда функция Symbol::GetApiNameByAddress возвращает строку "Unknown import name", происходит это в таких случаях, когда в игре присутствует импорт не системной библиотеки, а игровой. Например гейский импорт vx_uninitialize из vivoxsdk.dll возвращал такую строку:
Но в основном всё спокойно декриптилось, вот ещё пару примерчиков работы плагина:
Заключение
Спасибо за чтение данной статьи, идея про Overwatch пришла ко мне слишком поздно и мне в срочном порядке надо было успеть сделать статью до 1 сентября, ведь потом у меня вовсе не было бы времени, поэтому я не стал рассматривать многие модули защиты, а сконцентрировался лишь на декрипте импортов, это лучше, чем ничего.
(Понимаю, что у кого-то найдутся адекватный и неадекватные замечания к коду, не будем вмешиваться в это каноничное событие)
Подписывайтесь на мой блог:
Всего доброго!![Kissing Heart :kissingheart: :kissingheart:](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
Начнём с самого простого модуля - анти-дизассемблирование.
Анти-Дизассемблирование
Суть этой техники в том, чтобы визуально дизассемблер начал неправильно декодировать инструкции. Обычно реверсер это замечает в тот момент, когда при листинге дизассемблера у него меняются некоторые инструкции.
В чем же суть реализации такой техники, как в Overwatch? Вы все наверняка обращали внимание на выравнивание INT3 между функциями.
Оригинальный код:
00007FFB03BB115A | mov rbx,qword ptr ss:[rsp+30] |
00007FFB03BB115F | add rsp,20 |
00007FFB03BB1163 | pop rdi |
00007FFB03BB1164 | ret |
00007FFB03BB1165 | int3 |
00007FFB03BB1166 | int3 |
00007FFB03BB1167 | int3 |
00007FFB03BB1168 | int3 |
00007FFB03BB1169 | int3 |
00007FFB03BB116A | int3 |
00007FFB03BB116B | int3 |
00007FFB03BB116C | int3 |
00007FFB03BB116D | int3 |
00007FFB03BB116E | int3 |
00007FFB03BB116F | int3 |
00007FFB03BB1170 | mov qword ptr ss:[rsp+20],r9 |
00007FFB03BB1175 | mov qword ptr ss:[rsp+18],r8 |
00007FFB03BB117A | mov qword ptr ss:[rsp+10],rdx |
00007FFB03BB117F | mov qword ptr ss:[rsp+8],rcx |
00007FFB03BB1184 | push rbx |
После патчинга следующая функция после патчинга выравнивания приобретает такой вид:
Perl:
00007FFB03BB115A | mov rbx,qword ptr ss:[rsp+30] |
00007FFB03BB115F | add rsp,20 |
00007FFB03BB1163 | pop rdi |
00007FFB03BB1164 | ret |
00007FFB03BB1165 | sbb byte ptr ds:[rdx-2161EDFD],dl |
00007FFB03BB116B | int C9 |
00007FFB03BB116D | or eax,894CE309 |
00007FFB03BB1172 | and al,20 |
00007FFB03BB1175 | mov qword ptr ss:[rsp+18],r8 |
00007FFB03BB117A | mov qword ptr ss:[rsp+10],rdx |
00007FFB03BB117F | mov qword ptr ss:[rsp+8],rcx |
И есчо пару примерчиков:
Perl:
00007FFB124B1000 | int3 |
00007FFB124B1001 | int3 |
00007FFB124B1002 | int3 |
00007FFB124B1003 | int3 |
00007FFB124B1004 | int3 |
00007FFB124B1005 | int3 |
00007FFB124B1006 | int3 |
00007FFB124B1007 | int3 |
00007FFB124B1008 | push rbp |
00007FFB124B100A | push rbx |
00007FFB124B100B | push rsi |
00007FFB124B100C | push rdi |
00007FFB124B100D | push r12 |
00007FFB124B100F | push r14 |
00007FFB124B1011 | push r15 |
Perl:
00007FFB124B1000 | stosb |
00007FFB124B1001 | ??? |
00007FFB124B1002 | or bh,byte ptr ds:[rax-76] |
00007FFB124B1005 | fmul st(0),st(0) |
00007FFB124B1007 | xchg ecx,eax |
00007FFB124B1008 | push rbp |
00007FFB124B100A | push rbx |
00007FFB124B100B | push rsi |
00007FFB124B100C | push rdi |
00007FFB124B100D | push r12 |
00007FFB124B100F | push r14 |
00007FFB124B1011 | push r15 |
В трюке есть условный JCC, который прыгает на другую часть кода, однако поверх этой части кода фигурируют неиспользуемые рандомные байты. Сделаны они для того, чтобы сломать правильность декода дальнейших инструкции. Когда вы делаете этот джамп, вас перебрасывает на валидный код, но стоит вам сделать прокрут колесом мыши вверх или вниз - дизассемблированный код приобретет совсем иной вид. Для внесения ясности работы, я проиллюстрировал эту ситуацию, дизассемблированный пример был взят прямиком из игры.
![1693401815116.png 1693401815116.png](https://yougame.biz/data/attachments/257/257370-a258345eb59ffc00762f0125288b5a76.jpg)
Загрузчик
Переходим плавно к загрузчику игры. Изначально всё и вся зашифровано в программе, за исключением первого TLS-каллбека. Его роль в том, чтобы дешифровать остальные TLS-каллбеки, расшифровать текст секцию и создать свою IAT.
Так как изначально все кроме первого каллбека остаются зашифрованным, в дебаггере надо снять INT3/INT 3/UD2 бряки, которые отладчик может их ставить в зависимости от ваших настроек, иначе не игра сможет нормально расшифровать остальные каллбеки и вас благополучно крашнет.
Self-Remapping
Первый TLS-Callback полностью ремаппит все секции программы, эти регионы получают флаг SEC_NO_CHANGE, из-за которого как-либо изменять память больше невозможно. Исходя из этого в дебаггере урезаются возможности ставить бряки, которые патчут память (INT3/INT 3/UD2, аппаратные остаются функционально способными).
К счастью, автор того PoC выложил плагин для x64dbg [
Пожалуйста, авторизуйтесь для просмотра ссылки.
] позволяющий снова ремапнуть регион, но уже с возможностью патчить. Возьмём это на вооружение.Шифрование импортов
В разделе "Загрузчик" я рассказывал о том, что TLS-каллбек создают свою IAT, так вот:
вместо обыкновенной IAT в игре присутствует таблица указателей, которая используется для расшифровки адреса нужного импорта.
Но для начала разберёмся как вообще появилась память для этих указателей, ведь каждый запуск их адрес будет разным.
Первым делом происходит аллокация виртуальной памяти путём вызова VirtualAlloc.
Дальше TLS-каллбек заполняет аллоцированную память рандомными байтами, для которых помимо всего прочего использовался rdtsc для рандомизации.
Если убрать весь джанк-код и сделать код линейным, то мы получим следующий фрагмент кода:
Perl:
mov qword ptr ss:[rsp+28],FFFFFFFFFFFFE916
mov rax, qword ptr ss:[rsp+28]
add rax,1197
add rax,E79
add rax,FFFFFFFFFFFFF6DD
rdtsc
movzx ecx,byte ptr ds:[rsi]
inc r8d
shl rdx,20
inc rsi
or rax,rdx
add eax,ecx
shr edi,cl
add edi,eax
xor byte ptr ds:[rsi],dil
Вызов импорта выглядит обыкновенным на первый взгляд, но после вызова происходит декрипт нужного импорта.
Первый способ связан с использованием только инструкции ADD
Происходит несколько таких операции и после декрипта происходит прыжок по нужной функции.
Пример:
Perl:
00007FF60C273DFC | call qword ptr ds:[7FF60C352660] ; Вызываем указатель
00000298EF050096 | mov rax,7FFCA9CF6668 ; Зашифрованный адрес импорта
00000298EF0500A0 | jmp 298EF0500A9 ; Прыгаем на дальнейший декрипт
00000298EF0500A9 | add rax,1C813BD8
00000298EF0500AF | jmp 298EF050AD7
00000298EF050AD7 | add rax,FFFFFFFFB547C73C
00000298EF050ADD | jmp 298EF050190
00000298EF050190 | add rax,FFFFFFFF80B1DBF8
00000298EF050196 | jmp 298EF050621
00000298EF050621 | add rax,FFFFFFFFDC684F5C
00000298EF050627 | jmp 298EF0501FC
00000298EF0501FC | add rax,6A99FBD8
00000298EF050202 | jmp 298EF0508F1
00000298EF0508F1 | add rax,FFFFFFFFF8FD17BC
00000298EF0508F7 | jmp 298EF05020F
00000298EF05020F | add rax,68C99BF8
00000298EF050215 | jmp 298EF050244
00000298EF050244 | add rax,4CBE1FDC
00000298EF05024A | jmp 298EF050271
00000298EF050271 | add rax,FFFFFFFFF6391ED8
00000298EF050277 | jmp 298EF050420
00000298EF050420 | jmp rax ; Прыжок на нужный импорт
Perl:
000002B77AE60074 | xor rax,5FDB5085
000002B77AE6007A | xor rax,39B059F7
000002B77AE60080 | xor rax,FFFFFFFFF5A057D9
000002B77AE60086 | jmp 2B77AE6053C
000002B77AE6053C | xor rax,FFFFFFFFD5D00C1F
000002B77AE60542 | xor rax,44C399D
000002B77AE60548 | xor rax,FFFFFFFFDA087B87
000002B77AE6054E | jmp 2B77AE60BFC
000002B77AE60BFC | xor rax,FFFFFFFFD104E051
000002B77AE60C02 | jmp rax
Perl:
00000247FF6E0CA4 | add rax,FFFFFFFFDFCB973A
00000247FF6E0CAA | jmp 247FF6E0DE8
00000247FF6E0DE8 | add rax,FFFFFFFFCE5BD872
00000247FF6E0DEE | jmp 247FF6E0F50
00000247FF6E0F50 | sub rax,FFFFFFFFC0652A92
00000247FF6E0F56 | jmp 247FF6E01A3
00000247FF6E01A3 | jmp rax
Perl:
00000247FF7B0154 | mov rax,C547CBCDBA543010
00000247FF7B015E | mov r10,F53984EF7B760B7D
00000247FF7B0168 | imul rax,r10
00000247FF7B016C | mov r10,F3D4562A617B126B
00000247FF7B0176 | imul rax,r10
00000247FF7B017A | mov r10,241319DA052C45D9
00000247FF7B0184 | imul rax,r10
00000247FF7B0188 | jmp 247FF7B0357
00000247FF7B0357 | mov r10,16CE147D3C99A9F3
00000247FF7B0361 | imul rax,r10
00000247FF7B0365 | mov r10,3C27C175F3A08451
00000247FF7B036F | imul rax,r10
00000247FF7B0373 | mov r10,88943EAE18835E6F
00000247FF7B037D | imul rax,r10
00000247FF7B0381 | mov r10,DEED5FEF0CE916B5
00000247FF7B038B | imul rax,r10
00000247FF7B038F | jmp 247FF7B0191
00000247FF7B0191 | mov r10,260AB341D699CBC3
00000247FF7B019B | imul rax,r10
00000247FF7B019F | jmp rax
Подготовка плагина
Подготовим всё для регистрации команды фикса импортов.
C++:
namespace Core
{
//
// Command for registration in plugin
inline std::string_view cFixImportsCommand = "z_fix_imports";
//
// Callback for commands
static bool CallbackFixImportsCommand ([[maybe_unused]] int argc, char* argv[]);
//
// Functions for register and unregister commands
void RegisterCommands ();
void UnregisterCommands ();
}
C++:
void Core::RegisterCommands ()
{
_plugin_registercommand (pluginHandle, cFixImportsCommand.data (), (CBPLUGINCOMMAND)CallbackFixImportsCommand, true);
}
void Core::UnregisterCommands ()
{
_plugin_unregistercommand (pluginHandle, cFixImportsCommand.data ());
}
То есть пример работы команды будет выглядеть следующий образом:
"z_fix_imports 00007FF767F37BD1" и после этого плагин получит его базовый адрес - 00007FF766900000.
В целом, давайте проясним работу алгоритма поиска зашифрованных импортов.
Он не самый лучший и не самый быстрый по своей работе, но раз заспидранил, то ничего не поделаешь
Плагин будет проходится по всей текст секции и искать две инструкции:
Perl:
jmp qword ptr:ds[encrypted_iat_ptr]
call qword ptr:ds[encrypted_iat_ptr]
Как будем деобфусцировать и дизассемблировать?
Для дизассемблирования возьмём библиотеку Zydis, для деобфускации - Unicorn Engine
Unicorn Engine - эмулятор цпу, позволяющий выполнять код в отдельно выделенной среде.
У игры очень простенькая обфускация, можно обойтись простой эмуляцией этих инструкции и в конце прочитать значение регистра. Таким образом мы получим наш заветный импорт.
Сам алгоритм проиллюстрирован так:
![1693402121215.png 1693402121215.png](https://yougame.biz/data/attachments/257/257372-d216f5ea358aff3c12f53c8aa404bf76.jpg)
Написание деобфускатора
Подготовим нашим символы для дальнейшего опознавания импорта по его адресу.
C++:
void Symbol::Init ()
{
SymSetOptions (SYMOPT_UNDNAME | SYMOPT_DEFERRED_LOADS);
if (!SymInitialize (DbgGetProcessHandle (), NULL, TRUE))
dprintf ("SymInitialize returned error: %d\n", GetLastError());
}
std::string Symbol::GetApiNameByAddress (void* Address)
{
char buffer[sizeof (SYMBOL_INFO) + MAX_SYM_NAME * sizeof (TCHAR)];
PSYMBOL_INFO pSymbol = (PSYMBOL_INFO)buffer;
pSymbol->SizeOfStruct = sizeof (SYMBOL_INFO);
pSymbol->MaxNameLen = MAX_SYM_NAME;
DWORD64 dwDisplacement = 0;
if (SymFromAddr (DbgGetProcessHandle (), (DWORD64)Address, &dwDisplacement, pSymbol))
return std::string (pSymbol->Name, pSymbol->NameLen);
return "Unknown import name";
}
C++:
static bool Core::CallbackFixImportsCommand ([[maybe_unused]] int argc, char* argv[])
{
duint RegionSize;
const auto GameAddress = DbgValFromString (argv[1]);
const auto BaseAddress = DbgMemFindBaseAddr (GameAddress, &RegionSize);
Symbol::Init ();
Import::Search (BaseAddress, RegionSize);
return true;
}
Сам неймспейс Import выглядит так:
C++:
namespace Import
{
void ScanIatEntry (ZyanU64 AddressForPatching, ZyanU64 Address);
void Search (ZyanU64 BaseAddress, SIZE_T RegionSize);
}
C++:
for (; Offset < RegionSize;)
{
const auto Status = ZydisDisassembleIntel (
ZYDIS_MACHINE_MODE_LONG_64,
(ZyanU64)(CurrentAddress + Offset),
(const void*)(vecBuffer.data () + Offset),
RegionSize - Offset,
&sInstruction);
if (Status == ZYAN_STATUS_SUCCESS)
{
if ((sInstruction.info.mnemonic == ZYDIS_MNEMONIC_CALL ||
sInstruction.info.mnemonic == ZYDIS_MNEMONIC_JMP) &&
sInstruction.operands[0].type == ZYDIS_OPERAND_TYPE_MEMORY &&
sInstruction.operands[0].mem.index == ZYDIS_REGISTER_NONE &&
sInstruction.operands[0].mem.base == ZYDIS_REGISTER_RIP &&
sInstruction.info.length == 6)
{
CallCounter++;
uint64_t AbsolutePointer;
ZydisCalcAbsoluteAddress (&sInstruction.info, &sInstruction.operands[0], (ZyanU64)(CurrentAddress + Offset), &AbsolutePointer);
if (DbgMemIsValidReadPtr (AbsolutePointer))
ScanIatEntry ((CurrentAddress + Offset), AbsolutePointer);
}
Offset += sInstruction.info.length;
}
else if (Status == ZYDIS_STATUS_NO_MORE_DATA)
break;
else
Offset += 1;
}
В ScanIatEntry мы передаем адрес инструкции call/jmp qword ptr и вычисленный абсолютный адрес операнда.
Переходим плавно к ScanIatEntry, в ней мы будем дизассемблировать полученный адрес операнда и проверять его, не является ли это mov инструкцией с операндом imm, если это оно, то подготавливаем нашу выделенную среду для эмуляции кода
C++:
void Import::ScanIatEntry (ZyanU64 AddressForPatching, ZyanU64 Address)
{
std::vector<ZyanU8> vecBuffer (ZYDIS_MAX_INSTRUCTION_LENGTH);
if (!DbgMemRead (Address, vecBuffer.data (), ZYDIS_MAX_INSTRUCTION_LENGTH))
return;
Status = ZydisDisassembleIntel (
ZYDIS_MACHINE_MODE_LONG_64,
Address,
vecBuffer.data (),
ZYDIS_MAX_INSTRUCTION_LENGTH,
&sInstruction);
vecBuffer.clear ();
vecBuffer.shrink_to_fit ();
if (Status != ZYAN_STATUS_SUCCESS)
return;
if (sInstruction.info.mnemonic == ZYDIS_MNEMONIC_MOV &&
sInstruction.operands[1].type == ZYDIS_OPERAND_TYPE_IMMEDIATE &&
sInstruction.info.length == 10)
{
Emulator::Start (AddressForPatching, Address);
}
}
C++:
namespace Emulator
{
struct S_CallData
{
uint64_t AddressForPatching;
S_CallData (uint64_t Address)
{
AddressForPatching = Address;
}
};
//
// Callback for traced instructions
static void HookCode (uc_engine* Engine, uint64_t Address, uint32_t Size, S_CallData* pData);
//
// Start emulation
void Start (uint64_t AddressForPatching, uint64_t Address);
}
Про существование такого прекрасного эмулятора мало кто знает, поэтому буду расписывать код поэтапно.
Для начала нам надо создать новый экземпляр, чтобы в будущем все дальнейшие функции могли работать только с этим экземпляром.
C++:
uc_engine* Engine;
uc_err Status;
Status = uc_open (UC_ARCH_X86, UC_MODE_64, &Engine);
if (Status != UC_ERR_OK) {
dprintf ("uc_open failed: %d\n", Status);
return;
}
C++:
std::vector<ZyanU8> vecBuffer (0x1000);
if (DbgMemRead (Entry, &vecBuffer[0], vecBuffer.size ()))
...
Код:
Status = uc_mem_map (Engine, Entry, 0x1000, UC_PROT_READ | UC_PROT_EXEC);
if (Status != UC_ERR_OK) {
dprintf ("uc_mem_map failed: %d\n", Status);
uc_close (Engine);
return;
}
Status = uc_mem_write (Engine, Entry, vecBuffer.data (), vecBuffer.size ());
if (Status != UC_ERR_OK) {
dprintf ("uc_mem_write failed: %d\n", Status);
uc_close (Engine);
return;
}
C++:
uc_hook HookTrace;
Status = uc_hook_add (Engine, &HookTrace, UC_HOOK_CODE, HookCode, new S_CallData (AddressForPatching), Entry, Entry + 0x1000);
if (Status != UC_ERR_OK) {
dprintf ("uc_hook_add failed: %d\n", Status);
uc_close (Engine);
return;
}
Со всем мы закончили, осталось запустить сам эмулятор с помощью uc_emu_start
C++:
Status = uc_emu_start (Engine, Entry, Entry + 0x1000, 0, 0);
if (Status != UC_ERR_OK) {
dprintf ("uc_emu_start failed: %d\n", Status);
uc_close (Engine);
return;
}
Полный код функции:
C++:
void Emulator::Start (uint64_t AddressForPatching, uint64_t Entry)
{
uc_engine* Engine;
uc_err Status;
Status = uc_open (UC_ARCH_X86, UC_MODE_64, &Engine);
if (Status != UC_ERR_OK) {
dprintf ("uc_open failed: %d\n", Status);
return;
}
std::vector<ZyanU8> vecBuffer (0x1000);
if (DbgMemRead (Entry, &vecBuffer[0], vecBuffer.size ()))
{
if (vecBuffer.empty ())
{
dprintf ("Buffer is empty!\n");
uc_close (Engine);
return;
}
Status = uc_mem_map (Engine, Entry, 0x1000, UC_PROT_READ | UC_PROT_EXEC);
if (Status != UC_ERR_OK) {
dprintf ("uc_mem_map failed: %d\n", Status);
uc_close (Engine);
return;
}
Status = uc_mem_write (Engine, Entry, vecBuffer.data (), vecBuffer.size ());
if (Status != UC_ERR_OK) {
dprintf ("uc_mem_write failed: %d\n", Status);
uc_close (Engine);
return;
}
uc_hook HookTrace;
Status = uc_hook_add (Engine, &HookTrace, UC_HOOK_CODE, HookCode, new S_CallData (AddressForPatching), Entry, Entry + 0x1000);
if (Status != UC_ERR_OK) {
dprintf ("uc_hook_add failed: %d\n", Status);
uc_close (Engine);
return;
}
Status = uc_emu_start (Engine, Entry, Entry + 0x1000, 0, 0);
if (Status != UC_ERR_OK) {
dprintf ("uc_emu_start failed: %d\n", Status);
uc_close (Engine);
return;
}
uc_close (Engine);
}
else
{
uc_close (Engine);
return;
}
}
Самая нужная информация будет находится именно в нём
Для начала мы будем дизассемблировать каждую трассированную эмулятором функцию:
C++:
std::vector<ZyanU8> vecBuffer (Size);
if (!DbgMemRead (Address, &vecBuffer[0], Size))
return;
ZydisDisassembledInstruction sInstruction{};
ZyanStatus Status = ZydisDisassembleIntel (
ZYDIS_MACHINE_MODE_LONG_64,
Address,
vecBuffer.data (),
Size,
&sInstruction);
vecBuffer.clear ();
vecBuffer.shrink_to_fit ();
C++:
dprintf ("[!] Tracing instruction at 0x%p: '%s'\n", sInstruction.runtime_address, sInstruction.text);
if (sInstruction.info.mnemonic == ZYDIS_MNEMONIC_JMP &&
sInstruction.operands[0].type == ZYDIS_OPERAND_TYPE_REGISTER)
C++:
uc_x86_reg Register = ConvertRegister (sInstruction.operands[0].reg.value);
uint64_t RegisterAddress;
uc_reg_read (Engine, Register, &RegisterAddress);
std::string cImportName = Symbol::GetApiNameByAddress ((void*)RegisterAddress);
dprintf ("[+] Import detected! Call from: 0x%p -> 0x%p (Name: %s)\n", sInstruction.runtime_address, RegisterAddress, cImportName.c_str ());
uc_emu_stop (Engine);
C++:
uc_x86_reg ConvertRegister (ZydisRegister Register)
{
static const uc_x86_reg kUnicornRegisters[] = {
UC_X86_REG_RAX, UC_X86_REG_RBX, UC_X86_REG_RCX, UC_X86_REG_RDX,
UC_X86_REG_RBP, UC_X86_REG_RSP, UC_X86_REG_RSI, UC_X86_REG_RDI,
UC_X86_REG_R8, UC_X86_REG_R9, UC_X86_REG_R10, UC_X86_REG_R11,
UC_X86_REG_R12, UC_X86_REG_R13, UC_X86_REG_R14, UC_X86_REG_R15
};
if (Register >= ZYDIS_REGISTER_RAX && Register <= ZYDIS_REGISTER_R15)
return kUnicornRegisters[Register - ZYDIS_REGISTER_RAX];
return UC_X86_REG_INVALID;
}
C++:
void Emulator::HookCode (uc_engine* Engine, uint64_t Address, uint32_t Size, S_CallData* pData)
{
std::vector<ZyanU8> vecBuffer (Size);
if (!DbgMemRead (Address, &vecBuffer[0], Size))
return;
ZydisDisassembledInstruction sInstruction{};
ZyanStatus Status = ZydisDisassembleIntel (
ZYDIS_MACHINE_MODE_LONG_64,
Address,
vecBuffer.data (),
Size,
&sInstruction);
vecBuffer.clear ();
vecBuffer.shrink_to_fit ();
if (Status == ZYAN_STATUS_SUCCESS)
{
dprintf ("[!] Tracing instruction at 0x%p: '%s'\n", sInstruction.runtime_address, sInstruction.text);
if (sInstruction.info.mnemonic == ZYDIS_MNEMONIC_JMP &&
sInstruction.operands[0].type == ZYDIS_OPERAND_TYPE_REGISTER)
{
uc_x86_reg Register = ConvertRegister (sInstruction.operands[0].reg.value);
uint64_t RegisterAddress;
uc_reg_read (Engine, Register, &RegisterAddress);
std::string cImportName = Symbol::GetApiNameByAddress ((void*)RegisterAddress);
dprintf ("[+] Import detected! Call from: 0x%p -> 0x%p (Name: %s)\n", sInstruction.runtime_address, RegisterAddress, cImportName.c_str ());
uc_emu_stop (Engine);
}
}
}
![1693402655497.png 1693402655497.png](https://yougame.biz/data/attachments/257/257373-56de3a6b433b8b21ba2135f320571520.jpg)
И ура! Мы смогли добыть нужный импорт, однако, есть и такие ситуации, когда функция Symbol::GetApiNameByAddress возвращает строку "Unknown import name", происходит это в таких случаях, когда в игре присутствует импорт не системной библиотеки, а игровой. Например гейский импорт vx_uninitialize из vivoxsdk.dll возвращал такую строку:
Код:
[Overwatch Import Fixer] [+] Import detected! Call from: 0x0000021FD08D0096 -> 0x00007FF8D71FEE80 (Name: Unknown import name)
Код:
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C0000: 'mov rax, 0x7FF9B64416FD'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C000A: 'jmp 0x0000021FD06C004D'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C004D: 'add rax, 0xFFFFFFFF868B93DD'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C0053: 'jmp 0x0000021FD06C0221'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C0221: 'add rax, 0xD6C3785'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C0227: 'jmp 0x0000021FD06C043D'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C043D: 'add rax, 0x1A543FBD'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C0443: 'jmp 0x0000021FD06C06E9'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C06E9: 'add rax, 0x7FF8946D'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C06EF: 'jmp 0x0000021FD06C0929'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C0929: 'add rax, 0xFFFFFFFFDF7D7AE1'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C092F: 'jmp 0x0000021FD06C0A91'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C0A91: 'add rax, 0xFFFFFFFFBAA2B7C5'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C0A97: 'jmp 0x0000021FD06C0C1D'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C0C1D: 'add rax, 0xFFFFFFFFF90FDAC1'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C0C23: 'jmp 0x0000021FD06C0EA5'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000021FD06C0EA5: 'jmp rax'
[Overwatch Import Fixer] [+] Import detected! Call from: 0x0000021FD06C0EA5 -> 0x00007FF977B8C3F0 (Name: VirtualProtect)
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA0000: 'mov rax, 0xD675EBA93AAFED00'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA000A: 'mov r10, 0x5C7C4DBB41E91BB9'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA0014: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA0018: 'mov r10, 0xBCD2FDCFC2352D8F'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA0022: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA0026: 'mov r10, 0x2D74EBEBD962525D'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA0030: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA0034: 'jmp 0x000001DACAAA0E97'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA0E97: 'mov r10, 0x60C0BC6E52F12617'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA0EA1: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA0EA5: 'mov r10, 0xD378B6447D8A73D5'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA0EAF: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA0EB3: 'mov r10, 0x28A9ECB64AC118B'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA0EBD: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA0EC1: 'mov r10, 0xFF8C565F2E727971'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA0ECB: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA0ECF: 'jmp 0x000001DACAAA03C0'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA03C0: 'mov r10, 0xC678CFC249D9E4E7'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA03CA: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA03CE: 'mov r10, 0xBCC688D30CB287D5'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA03D8: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA03DC: 'mov r10, 0xB87B1BDE38E0271B'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA03E6: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA03EA: 'mov r10, 0x48ECF25983C01171'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA03F4: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA03F8: 'jmp 0x000001DACAAA09F0'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA09F0: 'mov r10, 0xF74F435536F2A293'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA09FA: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAAA09FE: 'jmp rax'
[Overwatch Import Fixer] [+] Import detected! Call from: 0x000001DACAAA09FE -> 0x00007FF977B92D00 (Name: VerifyVersionInfoA)
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30000: 'mov rax, 0x53B11A51893E7704'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB3000A: 'mov r10, 0x17EC1A29205CF647'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30014: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30018: 'mov r10, 0x1AA2F5B450178679'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30022: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30026: 'mov r10, 0x5F56E50323074A3'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30030: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30034: 'jmp 0x000001DACAB30C12'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30C12: 'mov r10, 0xA1F21957A70737F1'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30C1C: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30C20: 'mov r10, 0x7D08C8EC0C4DD71F'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30C2A: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30C2E: 'mov r10, 0x5BD350834A588995'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30C38: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30C3C: 'mov r10, 0x7F4603492144AAB3'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30C46: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30C4A: 'jmp 0x000001DACAB30117'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB30117: 'add rax, 0xFFFFFFFFBB1AC32C'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACAB3011D: 'jmp rax'
[Overwatch Import Fixer] [+] Import detected! Call from: 0x000001DACAB3011D -> 0x00007FF976252EE0 (Name: MapVirtualKeyA)
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACA9B0000: 'mov rax, 0xB3B35CAFA3361260'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACA9B000A: 'mov r10, 0x39FE4440D04EB237'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACA9B0014: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACA9B0018: 'mov r10, 0xAB30D80EA9B0BFB5'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACA9B0022: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACA9B0026: 'mov r10, 0xB0907473EED165AB'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACA9B0030: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACA9B0034: 'jmp 0x000001DACA9B0591'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACA9B0591: 'mov r10, 0x504F35E4F9D4511'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACA9B059B: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACA9B059F: 'mov r10, 0x5C498AFCCDA8E823'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACA9B05A9: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACA9B05AD: 'mov r10, 0x8E5CC9BC0988E6ED'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACA9B05B7: 'imul rax, r10'
[Overwatch Import Fixer] [!] Tracing instruction at 0x000001DACA9B05BB: 'jmp rax'
[Overwatch Import Fixer] [+] Import detected! Call from: 0x000001DACA9B05BB -> 0x00007FF977B952A0 (Name: CreateDirectoryA)
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B28330000: 'mov rax, 0x7FF828934F82'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B2833000A: 'jmp 0x0000020B283303A4'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B283303A4: 'add rax, 0x788C04D4'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B283303AA: 'jmp 0x0000020B2833059C'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B2833059C: 'add rax, 0xFFFFFFFFC5E35F8D'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B283305A2: 'jmp 0x0000020B28330824'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B28330824: 'add rax, 0x69ACF899'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B2833082A: 'jmp 0x0000020B28330B60'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B28330B60: 'add rax, 0xFFFFFFFF84E3E635'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B28330B66: 'jmp 0x0000020B28330E30'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B28330E30: 'add rax, 0x5C0B90B9'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B28330E36: 'jmp 0x0000020B283301EB'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B283301EB: 'add rax, 0x3E3A1EF1'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B283301F1: 'jmp 0x0000020B283303E3'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B283303E3: 'add rax, 0x35162F11'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B283303E9: 'jmp 0x0000020B283307AF'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B283307AF: 'add rax, 0x7F909368'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B283307B5: 'jmp 0x0000020B28330983'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B28330983: 'add rax, 0x1F13355C'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B28330989: 'jmp 0x0000020B28330AA3'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B28330AA3: 'add rax, 0xFFFFFFFF91C86348'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B28330AA9: 'jmp 0x0000020B2833002A'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B2833002A: 'add rax, 0x225DB598'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B28330030: 'jmp 0x0000020B2833022A'
[Overwatch Import Fixer] [!] Tracing instruction at 0x0000020B2833022A: 'jmp rax'
[Overwatch Import Fixer] [+] Import detected! Call from: 0x0000020B2833022A -> 0x00007FF977B95310 (Name: DeleteFileW)
Спасибо за чтение данной статьи, идея про Overwatch пришла ко мне слишком поздно и мне в срочном порядке надо было успеть сделать статью до 1 сентября, ведь потом у меня вовсе не было бы времени, поэтому я не стал рассматривать многие модули защиты, а сконцентрировался лишь на декрипте импортов, это лучше, чем ничего.
(Понимаю, что у кого-то найдутся адекватный и неадекватные замечания к коду, не будем вмешиваться в это каноничное событие)
Подписывайтесь на мой блог:
Пожалуйста, авторизуйтесь для просмотра ссылки.
Всего доброго!