unbound
Пользователь
-
Автор темы
- #1
Дисклеймер
Вся информация в этой статье предназначена исключительно для изучения механизма криптации и не является гайдом по взлому. Скорее она направлена на то, чтобы пролить свет на внутреннюю работу EasyAntiCheat, чтобы пользователи могли лучше понять, что происходит за кулисами, когда они играют в свои любимые игры. Любые высказанные здесь мнения не отражают точку зрения EasyAntiCheat или любых других упомянутых сторон. Эта статья предоставляется «как есть» без каких-либо явных или подразумеваемых гарантий, включая, помимо прочего, подразумеваемые гарантии товарного состояния и пригодности для определенной цели. Я не несу ответственности за любой ущерб, возникший в результате или в связи с использованием этой статьи.
Введение
Не секрет, что в большинстве многопользовательских игр используется EasyAntiCheat,
или же в простонародье EAC, призванный защитить честных игроков от читеров.
В этой статье мы попытаемся раскрыть один из секретов античита.
Вооружившись любознательностью и техническим мастерством, попытаемся пролить свет на защиту определения импортов,
распутывая этот клубок, созданный EasyAntiCheat для защиты онлайн-игр.
Прежде чем погрузится в анализ
стоит понимать хотя бы концепции, так что вот материал для изучения на английском:
Проблема:
Динамический анализ играет основную роль в понимании поведения кода, особенно при ревёрсе (обратной разработке).
Античиты, такие как EasyAntiCheat, осознали важность такого подхода и активно улучшают методы защиты от такого типа анализа.
Одним из основных методов анализа является наблюдение за вызовами импортированных функций.
Как правило, эти функции можно найти в IAT приложения, которая опредлена на этапе компиляции приложения.
Однако полагаться на IAT для продуктов безопасности считается довольно небезопасным решением.
Чтобы решить эту проблему, можно воспроизвести поведение путем ручного разбора EAT и самостоятельного определения импортов,
что позволяет обойтись без использования IAT.
EasyAntiCheat реализовали это решение, используя виртуализированную функцию,
которая пытается безопасно определить необходимые экспорты,
а также встраивает дешифровку для вызывающих функций DecryptImport.
Кроме того, в дополнение к встроенному дешифрованию они также используют шифрование,
в котором используются открытый и закрытый ключи,
при этом закрытый ключ удаляется во время выполнения.
При использовании этого метода практически невозможно восстановить вышеупомянутый закрытый ключ или перезаписать импорт.
Наблюдение:
Прежде чем начать, давайте рассмотрим пример из другого приложения
Вся информация в этой статье предназначена исключительно для изучения механизма криптации и не является гайдом по взлому. Скорее она направлена на то, чтобы пролить свет на внутреннюю работу EasyAntiCheat, чтобы пользователи могли лучше понять, что происходит за кулисами, когда они играют в свои любимые игры. Любые высказанные здесь мнения не отражают точку зрения EasyAntiCheat или любых других упомянутых сторон. Эта статья предоставляется «как есть» без каких-либо явных или подразумеваемых гарантий, включая, помимо прочего, подразумеваемые гарантии товарного состояния и пригодности для определенной цели. Я не несу ответственности за любой ущерб, возникший в результате или в связи с использованием этой статьи.
Введение
Не секрет, что в большинстве многопользовательских игр используется EasyAntiCheat,
или же в простонародье EAC, призванный защитить честных игроков от читеров.
В этой статье мы попытаемся раскрыть один из секретов античита.
Вооружившись любознательностью и техническим мастерством, попытаемся пролить свет на защиту определения импортов,
распутывая этот клубок, созданный EasyAntiCheat для защиты онлайн-игр.
Прежде чем погрузится в анализ
стоит понимать хотя бы концепции, так что вот материал для изучения на английском:
Пожалуйста, авторизуйтесь для просмотра ссылки.
Пожалуйста, авторизуйтесь для просмотра ссылки.
Пожалуйста, авторизуйтесь для просмотра ссылки.
Проблема:
Динамический анализ играет основную роль в понимании поведения кода, особенно при ревёрсе (обратной разработке).
Античиты, такие как EasyAntiCheat, осознали важность такого подхода и активно улучшают методы защиты от такого типа анализа.
Одним из основных методов анализа является наблюдение за вызовами импортированных функций.
Как правило, эти функции можно найти в IAT приложения, которая опредлена на этапе компиляции приложения.
Однако полагаться на IAT для продуктов безопасности считается довольно небезопасным решением.
Чтобы решить эту проблему, можно воспроизвести поведение путем ручного разбора EAT и самостоятельного определения импортов,
что позволяет обойтись без использования IAT.
EasyAntiCheat реализовали это решение, используя виртуализированную функцию,
которая пытается безопасно определить необходимые экспорты,
а также встраивает дешифровку для вызывающих функций DecryptImport.
Кроме того, в дополнение к встроенному дешифрованию они также используют шифрование,
в котором используются открытый и закрытый ключи,
при этом закрытый ключ удаляется во время выполнения.
При использовании этого метода практически невозможно восстановить вышеупомянутый закрытый ключ или перезаписать импорт.
Наблюдение:
Прежде чем начать, давайте рассмотрим пример из другого приложения
Pseudocode:
void* VgkExports::ExEnumHandleTable(void* a1)
{
v17.m128i_i64[1] = 0x3DC8C9558A64BA8Ai64;
v18.m128i_i64[0] = 0xD6EBDD7A0CEE792Bui64;
v19.m128i_i64[1] = 0x3DC8C9558A64BA8Ai64;
v18.m128i_i64[1] = 0xD6C6BCCF590828A8ui64;
v1 = _mm_load_si128(&v18);
v19.m128i_i64[0] = 0xC7884760EDC680EDui64;
v16.m128i_i64[0] = 0xB7A3B00F62AB016Eui64;
v16.m128i_i64[1] = 0xBAA4DD9B3C644CC6ui64;
v17.m128i_i64[0] = 0xC7884760EDC68088ui64;
v2 = *(__int64 **)a1;
v17 = _mm_xor_si128(v17, v19);
v16 = _mm_xor_si128(v1, v16);
Export = FindExport(*v2, v16.m128i_i8, 0i64, 0i64);
/* redacted code */
return Export;
}
Если вы когда-либо работали с VGK, сразу же заметите, что эти "заглушки" разбросаны по всему их бинарному коду.
Алгоритм относительно прост:
- Они начинают с создания строки в стеке, применения XOR для ее обфусцированния и вызова FindExport.
- Затем FindExport принимает предоставленный базовый адрес в качестве первого аргумента и находит EAT по этому адресу.
- Наконец, FindExport перебирает EAT и сравнивает предоставленное имя с именами экспортов, чтобы найти совпадение.
Если вам нужны дополнительные разъяснения по этому методу,
то я рекомендую изучить
Пожалуйста, авторизуйтесь для просмотра ссылки.
от Justas Masiulis, который расширяет концепцию,используя хэш вместо дешифруемой строки, что повышает уровень обфускации и безопасности.
Встроенное дешифрование:
Как мы видим, EasyAntiCheat гордится защитой от попыток ревёрса, это видно из их нового обфускатора.
Так вот, вместо того чтобы применять только один уровень шифрования к адресам импорта, они решили добавить еще один - их встроенное дешифрование.
Pseudocode:
v16 = DecryptImport(qword_125B00);
((void (__fastcall *)(__int64))(__ROL8__(v16, 29) ^ 0x3A505A07B9BA3B9Ei64))(v15);
Как следует из названия, каждый вызов функции DecryptImport расшифровывает второй уровень.
Это довольно умное решение с их стороны, так как это затрудняет перехват импортов без знания алгоритма расшифровки.
Конечно, можно просто использовать дизассемблер и автоматически определить используемые константы для расшифровки.
Хотя этот метод в большинстве сценариев работает хорошо, он оказывается большим вызовом для функций, использующих обфускацию.
Другой возможный подход - перехватить контекст после возврата в обфусцированный код из DecryptImport и эмулировать выполнение до завершения расшифровки.
Впрочем, в чем прикол?...
Основное Дешифрование:
Это довольно умное решение с их стороны, так как это затрудняет перехват импортов без знания алгоритма расшифровки.
Конечно, можно просто использовать дизассемблер и автоматически определить используемые константы для расшифровки.
Хотя этот метод в большинстве сценариев работает хорошо, он оказывается большим вызовом для функций, использующих обфускацию.
Другой возможный подход - перехватить контекст после возврата в обфусцированный код из DecryptImport и эмулировать выполнение до завершения расшифровки.
Впрочем, в чем прикол?...
Основное Дешифрование:
Pseudocode:
uint64_t DecryptImport( uint64_t* PublicKeys )
{
uint64_t First = DecryptFirst( PublicKeys[ 0 ] );
uint64_t Second = DecryptSecond( PublicKeys[ 1 ] ) << 32;
return First | Second;
}
Исходя из фрагмента кода, можно сделать вывод, как предположительно выглядит процесс шифрования:
- Итерирование и поиск экспорта в таблице экспорта драйвера.
- Применение встроенного шифрования к результату.
- Применение основного шифрования к результату.
- Запись обоих открытых ключей в определенную область памяти.
Установка бряков:
(Пояснение: бряк -
После установки бряка на запись открытых ключей случайного зашифрованного импорта в секции .data,
я смог увидеть, с чем имею дело, и дальше анализировал оттуда.
- Итерирование и поиск экспорта в таблице экспорта драйвера.
- Применение встроенного шифрования к результату.
- Применение основного шифрования к результату.
- Запись обоих открытых ключей в определенную область памяти.
Установка бряков:
(Пояснение: бряк -
Пожалуйста, авторизуйтесь для просмотра ссылки.
)После установки бряка на запись открытых ключей случайного зашифрованного импорта в секции .data,
я смог увидеть, с чем имею дело, и дальше анализировал оттуда.
Code:
AddEptHook_Range( EacBase + 0x125B00, EacBase + 0x125B08, HvDebugger::HvAccess::Write,
+[ ]( HvDebugger::HvContext* Context, uint64_t Address, uint64_t& Pfn )
{
WriteLog( "WriteTrap", TraceLoggingHexUInt64( EAC_BASE( Context->Rip ), "RipRva" ), TraceLoggingHexUInt64( EAC_BASE( Address ), "AddrRva" ) );
return false;
} );
Log:
{ "RipRva":"0x91FB7E", "AddrRva":"0x125B00" }
{ "RipRva":"0xD1C721", "AddrRva":"0x125B08" }
После изучения этих адресов становится очевидным, что они происходят из виртуализированной функции, что соответствует моим первоначальным ожиданиям.
Исследуя дальше:
Так как я анализировал виртуализированную функцию, а конкретно обработчик VMWRITE,
я решил взглянуть на стек, так как на нем могут находиться интересные данные,
которые могут быть полезными для нашего расследования.
Так как я анализировал виртуализированную функцию, а конкретно обработчик VMWRITE,
я решил взглянуть на стек, так как на нем могут находиться интересные данные,
которые могут быть полезными для нашего расследования.
Code:
uint64_t* Stack = ( uint64_t* )Context->Rsp;
for ( size_t Idx = 0; Idx < 200; Idx++ )
{
uint64_t Value = Stack[ Idx ];
if ( Value && IN_EAC( Value ) )
WriteLog( "StackDump", TraceLoggingHexUInt64( Idx ), TraceLoggingHexUInt64( EAC_BASE( Value ), "Rva" ) );
}
Log:
{ "Idx":"0x0", "Rva":"0x125B00" } <-- Бряк
{ "Idx":"0x23", "Rva":"0x6F4E4" }
{ "Idx":"0x24", "Rva":"0x6F4E4" }
{ "Idx":"0x25", "Rva":"0x6F598" }
{ "Idx":"0x4B", "Rva":"0x66698C" }
{ "Idx":"0x4D", "Rva":"0x125B00" } <-- Бряк
{ "Idx":"0x52", "Rva":"0x18F9E4" } <-- DriverEntry (точка входа)
{ "Idx":"0x63", "Rva":"0x18FAC0" }
{ "Idx":"0x65", "Rva":"0x18FA80" }
{ "Idx":"0x6D", "Rva":"0x74F8E2" }
Бегло просмотрев результаты, я заметил, что 0x18F9E4 - это функция DriverEntry, и все, что находится после нее, можно игнорировать.
Также можно проигнорировать 0x125B00, так как это адрес, на который я установил бряк изначально.
После удаления дубликатов, у меня остались следующие результаты:
Также можно проигнорировать 0x125B00, так как это адрес, на который я установил бряк изначально.
После удаления дубликатов, у меня остались следующие результаты:
Log:
{ "Idx":"0x23", "Rva":"0x6F4E4" }
{ "Idx":"0x25", "Rva":"0x6F598" }
{ "Idx":"0x4B", "Rva":"0x66698C" }
Функция 0x6F4E4:
__int64 __fastcall sub_6F4E4(unsigned __int64 a1, unsigned __int64 a2, unsigned int a3)
{
unsigned __int64 v3; // r9
__int64 i; // r8
v3 = a3;
for ( i = 1i64; a1; v3 = v3 * (unsigned __int128)v3 % a2 )
{
if ( (a1 & 1) != 0 )
i = (unsigned __int64)i * (unsigned __int128)v3 % a2;
a1 >>= 1;
}
return i;
}
Просто посмотрев на общую структуру этой функции, она напомнила мне начальную функцию DecryptImport, которую мы рассмотрели ранее.
Однако было довольно странно, что они расшифровывали импорты еще до их записи.
Исходя из аргументов, установки бряка и вывода значений регистров, стало очевидно,
что эта функция фактически используется для применения основного шифрования к каждому соответствующему импорту.
Однако было довольно странно, что они расшифровывали импорты еще до их записи.
Исходя из аргументов, установки бряка и вывода значений регистров, стало очевидно,
что эта функция фактически используется для применения основного шифрования к каждому соответствующему импорту.
output:
{ "Rcx":"0x35375306D545459", "Rdx":"0x12D8ED6858CD15B7", "R8":"0xA588E17A" }
{ "Rcx":"0x1D34200DE5B033A1", "Rdx":"0x237626ED2C9C28F3", "R8":"0x20FAECB8" }
{ "Rcx":"0x35375306D545459", "Rdx":"0x12D8ED6858CD15B7", "R8":"0x63558ACF" }
{ "Rcx":"0x1D34200DE5B033A1", "Rdx":"0x237626ED2C9C28F3", "R8":"0xAD8C5A8" }
{ "Rcx":"0x35375306D545459", "Rdx":"0x12D8ED6858CD15B7", "R8":"0x30696C03" }
{ "Rcx":"0x1D34200DE5B033A1", "Rdx":"0x237626ED2C9C28F3", "R8":"0x46D4F31E" }
{ "Rcx":"0x35375306D545459", "Rdx":"0x12D8ED6858CD15B7", "R8":"0xCFBFE39F" }
{ "Rcx":"0x1D34200DE5B033A1", "Rdx":"0x237626ED2C9C28F3", "R8":"0xBB301A01" }
{ "Rcx":"0x35375306D545459", "Rdx":"0x12D8ED6858CD15B7", "R8":"0xD4D49738" }
И снова это напомнило мне начальную функцию DecryptImport, так как RDX, являющийся открытым ключом, был точно таким же.
Эта функция вызывается дважды с каждым открытым и закрытым ключом для каждого зашифрованного значения, которое представлено в регистре R8.
Следующая функция в списке, а именно 0x6F598, тоже привлекла мое внимание во время анализа.
Подобно предыдущей функции, я также вывел значения регистров и заметил нечто интересное относительно этих регистров.
Просмотрев регистры, я обратил внимание только на RCX, так как он указывал на 0x1861D0, что было пустым участком в секции .data.
Я решил продолжить поиск других интересных данных и запомнил этот адрес.
Наконец, у меня остался обработчик VMCALL, который после некоторого быстрого анализа оказался бесполезным.
Глубокий анализ:
Чтобы собрать больше информации и определить как они сравнивали имена,
я решил установить ловушку на имя NtGlobalFlag в EAT (Export Address Table) ntoskrnl.exe,
так как я уже знал, что они используют этот импорт.
Эта функция вызывается дважды с каждым открытым и закрытым ключом для каждого зашифрованного значения, которое представлено в регистре R8.
Следующая функция в списке, а именно 0x6F598, тоже привлекла мое внимание во время анализа.
Подобно предыдущей функции, я также вывел значения регистров и заметил нечто интересное относительно этих регистров.
Просмотрев регистры, я обратил внимание только на RCX, так как он указывал на 0x1861D0, что было пустым участком в секции .data.
Я решил продолжить поиск других интересных данных и запомнил этот адрес.
Наконец, у меня остался обработчик VMCALL, который после некоторого быстрого анализа оказался бесполезным.
Глубокий анализ:
Чтобы собрать больше информации и определить как они сравнивали имена,
я решил установить ловушку на имя NtGlobalFlag в EAT (Export Address Table) ntoskrnl.exe,
так как я уже знал, что они используют этот импорт.
Code:
AddEptHook( Utils::GetExportName( "NtGlobalFlag" ), HvDebugger::HvAccess::Read,
+[ ]( HvDebugger::HvContext* Context, uint64_t Address, uint64_t& Pfn )
{
WriteLog( "ReadTrap", TraceLoggingHexUInt64( EAC_BASE( Context->Rip ), "Rva" ) );
uint64_t* Stack = ( uint64_t* )Context->Rsp;
for ( size_t Idx = 0; Idx < 200; Idx++ )
{
uint64_t Value = Stack[ Idx ];
if ( Value && IN_EAC( Value ) )
WriteLog( "StackDump", TraceLoggingHexUInt64( Idx ), TraceLoggingHexUInt64( EAC_BASE( Value ), "Rva" ) );
}
return false;
} );
Log:
{ "Rva":"0x2FE234" } <-- Обработчик чтения
{ "Rva":"0x4637B" } <-- Чтение SHA1
{ "Idx":"0xF", "Rva":"0x6B09A5" } <-- Вызывающий функцию SHA1
{ "Idx":"0x13", "Rva":"0x463AC" } <-- Функция SHA1
{ "Idx":"0x1E", "Rva":"0x125600" } <-- Экспортируемые ключи
{ "Idx":"0x1F", "Rva":"0x6F584" } <-- Заглушка
{ "Idx":"0x3A", "Rva":"0x1861D0" } <-- Часть .data секции
{ "Idx":"0x3D", "Rva":"0x924C56" }
{ "Idx":"0x67", "Rva":"0x66698C" } <-- Бесполезная функция
{ "Idx":"0x69", "Rva":"0x1260F8" } <-- Случайный экспорт
{ "Idx":"0x6E", "Rva":"0x18F9E4" } <-- DriverEntry (точка входа)
{ "Idx":"0x7F", "Rva":"0x18FAC0" }
{ "Idx":"0x81", "Rva":"0x18FA80" }
{ "Idx":"0x89", "Rva":"0x74F8E2" }
А это интересно! Они по-прежнему полагались на свой алгоритм SHA1 для чтения и сравнения имен экспортов,
что я заметил по уникальным константам, используемым в контексте SHA1.
Поскольку функция SHA1 получала длину строки, а начальные обработчики чтения считывали все байты в имени,
я пришел к выводу, что это была встроенная функция strlen, но виртуализированная.
Как и раньше, я удалил бесполезные пометки и оставил только один адрес: 0x924C56.
Важно отметить, что снова появляется адрес 0x1861D0, потому я счел его довольно важным.
Теперь, учитывая, что запись была инструкцией VMCALL, я вывел и адрес, и регистры.
Code:
AddEptHook( EacBase + 0x924C52, HvDebugger::HvAccess::Execute,
+[ ]( HvDebugger::HvContext* Context, uint64_t Address, uint64_t& Pfn )
{
/*
seg007:0000000000924C4B add rsp, 110h
seg007:0000000000924C52 call qword ptr [rsp+8]
seg007:0000000000924C56 sub rsp, 110h
*/
uint64_t Function = *( uint64_t* )( Context->Rsp + 8 );
WriteLog( "Vmcall", TraceLoggingHexUInt64( EAC_BASE( Function ), "Function" ) );
HvDebugger::DumpRegisters( Context );
return false;
} );
Log:
{ "Function":"0x72E98" }
{ "Name":"RCX", "Value":"0xFFFF8E8BAE7255E0" }
{ "Name":"RDX", "Value":"0xFFFFF80706E00000" } <-- базовый адрес Ntoskrnl.exe
{ "Name":"R8", "Value":"0xFFFF8E8BAE7256C0" }
Отлично, это именно та функция, которую я искал, я назвал ее InitializeImports!
Эта функция вызывалась для каждого модуля, для которого EasyAntiCheat хотел инициализировать защищенные импорты,
и ей передавался базовый адрес модуля.
После дальнейшего анализа параметры функции следующие:
Эта функция вызывалась для каждого модуля, для которого EasyAntiCheat хотел инициализировать защищенные импорты,
и ей передавался базовый адрес модуля.
После дальнейшего анализа параметры функции следующие:
RCX: Указатель на оба открытых ключа.
RDX: Базовый адрес модуля.
R8: Указатель на счётчик определённых импортов
Хотя это безусловно полезная информация, у меня все еще был вопрос: как они знали, какие импорты найти?
Как я упоминал ранее, ни один из регистров не содержал информации об этом.
Но тогда мне пришла в голову мысль: а что, если они записывали это по адресу 0x1861D0?
Code:
AddEptHook( EacBase + 0x1861D0, HvDebugger::HvAccess::Read,
+[ ]( HvDebugger::HvContext* Context, uint64_t Address, uint64_t& Pfn )
{
WriteLog( "ReadTrap", TraceLoggingString( Symbolize( Context->Rip ), "Name" ) );
return false;
} );
Log:
{ "Name":"EasyAntiCheat_EOS.sys+0x73228" } <-- Функция сортировки
{ "Name":"ntoskrnl.exe+0x3D0A9A" } <-- qsort
{ "Name":"ntoskrnl.exe+0x3D0AD3" } <-- qsort
{ "Name":"EasyAntiCheat_EOS.sys+0x7322B" } <-- Функция сортировки
{ "Name":"ntoskrnl.exe+0x3D0A23" } <-- qsort
{ "Name":"EasyAntiCheat_EOS.sys+0x21F118" } <-- Обработчик чтения
{ "Name":"EasyAntiCheat_EOS.sys+0x855527" } <-- Обработчик чтения
{ "Name":"EasyAntiCheat_EOS.sys+0x425042" } <-- Обработчик чтения
{ "Name":"EasyAntiCheat_EOS.sys+0x86D2BC" } <-- Обработчик чтения
{ "Name":"EasyAntiCheat_EOS.sys+0x425042" } <-- Обработчик чтения
Это было довольно интересно, так как сортировка импортов умная и позволяет более быстро их перебирать позже.
Code:
AddEptHook( Utils::GetExport( "qsort" ), HvDebugger::HvAccess::Execute,
+[ ]( HvDebugger::HvContext* Context, uint64_t Address, uint64_t& Pfn )
{
uint64_t ReturnAddress = *( uint64_t* )Context->Rsp;
if ( !IN_EAC( ReturnAddress ) )
return false;
Utils::DumpArray( &FormatElements, Context->Gpr->rcx /* Base of Elements */, Context->Gpr->rdx /* Number of Elements */, Context->Gpr->r8 /* Size of Elements */ );
return false;
} );
Log:
{ "Sort":"0x6556BC1D6053223C", "InlinedKey":"0x65091738C0592277", "Export":"0x1263E8", "Default":"0x6F584" }
{ "Sort":"0x386399B9B0FD723E", "InlinedKey":"0xF26FB57ABCF6FADF", "Export":"0x126408", "Default":"0x6F584" }
Структура:
struct ProtectedImportData
{
uint64_t Sort;
uint64_t InlinedKey;
uint64_t* Keys;
uint64_t Default;
}
Мои подозрения оправдались, так как EasyAntiCheat действительно временно сохраняет данные об импорте здесь и очищает их после шифрования импортов.
Еще одна интересная деталь заключается в том, что если импорт не может быть определён,
они прибегают к использованию значений по умолчанию, что я заметил, посмотрев на значения по умолчанию в IDA.
EasyAntiCheat также использует функцию RtlPcToFileHeader на самом себе для определения базового адреса ntoskrnl.exe.
Затем базовый адрес шифруется, сохраняется в секции .data с булевым значением, указывающим, что он был найден.
"Победа"
В заключение хочу сказать, что EasyAntiCheat выбрал довольно эффективный метод защиты от попыток ревёрса.
Тем не менее, как и все в этой области, он подлежит анализу.
Как обычно, я оставил упражнение для читателя, которое вы быстро заметите, когда попытаетесь сгенерировать "заглушки" для расшифровки для импорта.
До новых встреч!
Пожалуйста, авторизуйтесь для просмотра ссылки.
Всю работу выполнил:
Пожалуйста, авторизуйтесь для просмотра ссылки.
(unknowncheats)Внимание! Это любительский перевод с минимальным опытом, прошу не бить палками и спасибо за чтение...
Последнее редактирование: