| Welcome to The Passion Of Code Laboratory!!! | Статьи |
“От зеленого к красному”Глава 3: Программирование в Shell-код стиле. Важные техники системного программирования: SEH, VEH и API Hooking. Отключение Windows File Protection.Автор:Bill Prisoner / TPOC Программирование в Shell-код стилеЭтот раздел является своеобразным обобщением первых двух глав. Прочтя его, Вы сможете уже без особых трудностей писать простые Win32-вирусы. Код в shell-код стиле или как он еще называется – базово-независимый код требует определенных условий при его написании. Основное условие - чтобы код не зависел от адреса загрузки его в адресное пространство процесса-жертвы и от структур данных загрузчика. Надо определить адрес какой-нибудь команды, где она находилась первоначально (т.е. в первом поколении). Это значение будет константой, зашитой в коде. Далее код должен определить, где он находиться в данный момент. Для этого есть несколько способов, которые описывались в 1 главе. Вот это и называется дельта-смещением. Также мы должны знать адреса функций API, чтобы вирус был мульти-платформенным относительно Windows, т.е. работал во всех ОС Windows, т.к. известно, что адреса API-функций меняются в зависимости от ОС, а также могут поменяться в той же ОС в какой-то конфликтной ситуации, например при конфликте разделов виртуальной памяти. Для получения адресов, нужных нам функций ОС, существует много способов. Основы получения адресов мы рассмотрели. При получении адресов ОС Windows мы выполняем часть работы загрузчика. При загрузке исполняемого файла (PE, DLL, SYS, SCR) в адресное пространство процесса загрузчик заполняет таблицу адресов импорта (Import Address Table) и таблицу адресов экспорта (Export Address Table). При выполнении кода этого исполняемого файла IAT используется, чтобы хранить адреса всех API-функций, которые использует приложение. Таким образом, мы касаемся неявного связывания (implicit linking). Адрес API-функции может и не быть в IAT, его можно получить с помощью функции KERNEL32.DLL!GetProcAddress. Этой функции на вход передается описатель модуля, в котором экспортируется нужная функция и имя нужной функции. KERNEL32.DLL!GetProcAddress просматривает EAT модуля, описатель которого передается ей параметром (а описателем модуля(module handle), как известно является его базовый адрес(base address) в адресном пространстве процесса, в котором он загружен). Даже при неявном связывании ОС вызывает GetProcAddress для заполнения IAT. Мы своим кодом эмулируем процедуру GetProcAddress - не больше не меньше! В исполняемом файле есть несколько секций, которые имеют свои атрибуты. Например, секция кода не предназначена для записи. Есть секция неинициализированных данных, которая имеет нулевой размер физически, но при загрузке данного исполняемого модуля в память эта секция приобретает материальный характер. Чтобы это было именно так, загрузчик просматривает таблицу секций и если он видит, что данная секция – секция неинициализированных данных, то он выделяет память в адресном пространстве процесса с помощью функций выделения памяти. Наш код находится всегда в одной секции. Чтобы таким же образом использовать виртуальное адресное пространство для своих целей приходиться использовать функции резервирования и выделения памяти в куче или, напрямую, - в виртуальной памяти. Более того, есть проблема – если ЮЗВЕРЬ (классное слово :) ) посмотрит файл, зараженный нашим кодом, то он визуально сможет найти там чего-нибудь подозрительное. Чтобы этого не случилось приходиться шифровать наш код или строки текста, создавая соответственно, и расшифровщик. Но это естественно не единственное применение шифрования в коде. Представьте, что у нас есть код обычного приложения подсистемы Win32 на ассемблере. Задача: превратить его в код в Shell-код стиле. Сначала надо все переменные переместить в секцию с кодом и соответственно поставить прыжок на нормальный код, чтобы эти данные не начали выполняться как код. Потом вычислить дельта-смещение. Далее получить адреса всех API-функций. После этого можно превращать обычный код в код в Shell-код стиле, т.е. заменять все смещения – смещениями с учетом дельта-смещения. Первоначальный код:
Во-первых, переменные offset Text1, offset Title1 должны находиться в секции кода – т.е. там, где находиться код вируса. Из-за этого секцию с таким кодом нужно делать доступным для записи. Во-вторых, offset Text1 – это абсолютный адрес. Допустим, что мы вычислили дельта-смещение и поместили его в регистр EBP. С учетом вычисленного дельта-смещения мы должны его исправить т.о.
Теперь в EDI находиться реальный адрес строки Text1. Также делаем и со всеми остальными переменными. Допустим, что адрес функции MessageBox, находиться в переменной _MessageBox. Тогда вызываем функцию так:
Две строки
можно заменить одной
Как известно система команд современных 32-х разрядных процессоров не содержит в себе дальнего условного перехода. Но у нас код и данные расположены в одном большом сегменте, т.о. мы можем переходить на любые расстояния, используя модель памяти FLAT. Но нет команды, которая осуществляет косвенный переход. Т.е., если у нас адрес хранится в каком-нибудь регистре, то мы не можем использовать команду условного перехода, например так - jne EDI. Вот как можно реализовать косвенный переход
Этот код означает следующее – если значение в регистре EAX равно нулю, то делается дальний переход на адрес, который находиться в EDI.
В команде jmp error также используется относительный переход. По умолчанию в JMP в MASM’е трактуется как прямой внутрисегментный переход.
А вот так это можно использовать:
Обобщенный пример программирования в Shell-код стилеВ этом разделе я хотел привести нормальную программу, а потом эту же программу, но в Shell-код стиле. Но потом я передумал :) Код той и другой программы находятся в архиве, который прилагается к статье. Итак, программа рекурсивного поиска. Программа выводит на экран с помощью MessageBox’а количество найденных файлов с расширением EXE в указанной директории и всех ее поддиректориях. Файлы ищутся в директории, имя которой находиться по адресу Buffer. В архиве есть папка, которая называется ShellCoded. В ней нормальная программа называется – normal.asm, в Shell-код стиле – shellcode.asm. Внимательно рассмотрите эти программы и попробуйте их сравнить. Также потренируйтесь переводить свои программы таким же образом. Т.о. Вы можете переводить обычное Win32-приложение в приложение в shell-код стиле. Во вложении к статье я также предлагаю Вам шаблон файла, где Вам не придется получать дельта смещение и адреса API-функций. Там уже все есть как в сказке! Почти всё ;) Файл называется VXTemplateWin32.asm. Важные техники системного программированияStructured Exception HandlingВведениеStructured Exception Handling (SEH) - структурная обработка исключений, механизм, который поддерживается операционной системой и позволяющий обрабатывать ошибки в программах. В этом разделе я расскажу Вам, что такое SEH, как работает данный механизм и как его использовать в своих вирусах. SEH – это системный механизм. Представьте, что Ваша программа попытается выполнить следующий код:
Любое обращение к адресам от 0 до 0FFFFh ведет к исключению нарушения доступа к памяти. Конечно, ошибка нарушения доступа к памяти появляется не только для этих адресов, но и для всех адресов выше 2х Гб в виртуальном адресном пространстве, а также если мы пытаемся обратиться к не переданным страницам или например, произвести запись к странице к которой мы не имеем право на запись. Исключение – это событие, которое происходит в результате какой-либо ошибки. Каждое исключение имеет свой код. Например, код неправомерного доступа к памяти – 0C0000005h. Коды исключений определены в файле WINBASE.H. Допустим, выполняется пример кода, когда мы записываем 1 по адресу 0, тогда возникает исключение. ОС должна реагировать на исключение. Обычно при возникновении исключения ОС вызывает функцию, которая называется обработчиком исключений (exception handler). Эта функция – обычная CALLBACK-функция принимающая несколько параметров. Если мы обрабатываем это исключение, то мы пишем обработчик и в определенном месте указываем его адрес, чтобы, если произошло исключение, ОС смогла вызвать наш обработчик. Если обработчик выполнился, ОС решает, что дальше делать исходя из возвращаемого значения, которой вернул обработчик. Исходя из этих соображений, программа может продолжить работу, программа может завершиться или ОС вызывает следующий обработчик в цепочке (если таковой имеется). Т.е. можно устанавливать несколько обработчиков. Если мы сами не установили обработчик, то в любом приложении установлен обработчик по умолчанию и если случиться исключение, то ОС выведет сообщение о завершении программы.
Если на участок кода приведенном в примере установлен обработчик, то мы можем обработать эту ошибку с помощью специально написанного обработчика. Существует два типа обработчиков исключений – конечные и внутри-поточные. Итак… Конечный обработчикЕсли программа вызвала исключение, то, если внутри-поточные обработчики не установлены или не обрабатывают исключение, вызывается конечный обработчик. Конечный обработчик глобален для процесса, в котором он установлен, в отличии от внутри-поточного. Конечный обработчик устанавливается с помощью API-функции KERNEL32.DLL!SetUnhandledExceptionFilter. Как Вы заметили :) она экспортируется из kernel32.dll. С помщью этой функции можно установить конечный обработчик. Если в Вашей программе произошло исключение и его не обрабатывают никакие внутри-поточные обработчики, то вызывается конечный обработчик. Конечный обработчик вызывается как раз перед тем, когда ОС решила закрыть приложение. Смещение конечного обработчика передается как параметр функции KERNEL32.DLL!SetUnhandledExceptionFilter.
Функция-обработчик такой прототип прототип:
Прототип этой функции я взял из SDK. Также там описаны и возвращаемые значения этой функции. А возвращаемые значения могут быть такие: eax = -1 - перегрузить контекст и продолжить eax = 1 - выключает вывод Message Box’а eax = 0 - включает вывод Message Box’а Прототип конечного обработчика отличается от прототипа внутри-поточного обработчика. Если что-то произошло в коде вируса, то надо просто перепрыгнуть на нормальный код программы, если этот код внедрен в программу и выполняется до ее старта. Если код вируса выполняется в потоке, то мы завершаем поток. Конечно, можно попробовать исправить ошибку, и продолжить выполнение. Внутри-поточный обработчикЕсли мы хотим обрабатывать ошибки для каждого потока, т.е. устанавливать свой обработчик для каждого вида ошибок в потоке, то мы должны установить внутри-поточный обработчик. Например, ошибка нарушения доступа к памяти в одном потоке будет обрабатываться по-своему, а в другом потоке та же ошибка, уже по-другому, в зависимости от обработчика. Из внутри-поточных обработчиков можно делать цепочки. Т.е. если один обработчик не обрабатывает исключение, то исключение может обработать следующий обработчик в цепочке. По адресу FS:[0] находиться указатель на структуру SEH, ее называют SEH-фрейм. Вот описание этой структуры:
Когда мы устанавливаем обработчик исключения вручную, то мы заполняем структуру SEH и передаем указатель на нее в FS:[0]. Структура SEH должна состоять как минимум из 2-х первых двойных слов. Эта новая созданная структура должна обязательно находиться в стеке, иначе наш обработчик не будет вызван. Более того, очередная новая созданная структура должна находиться в стеке выше, чем предыдущие установленные структуры. Вот как можно установить внутри-поточный обработчик:
Когда поток начинает только выполняться, у него уже установлен один обработчик, обработчик по умолчанию, который выводит сообщение о завершении программы. Если присмотреться внимательно, то можно понять, что вышеприведенным кодом добавляется очередной элемент в связный список. По адресу FS:[0] содержится указатель на структуру SEH, в которой имеется адрес предыдущей структуры SEH в стеке. Этот связный список называется SEH-цепочка (SEH-chain). Так формируется цепочка из обработчиков исключений. Сцепление в цепочку обработчиков делается, например для того, чтобы каждый обработчик в цепочке обрабатывал свои типы исключений. Если первый обработчик не обработал исключение, то он возвращает eax=1 и управление передается следующему обработчику в цепочке. Т.е. если обработчик возвращает 1, то ОС переходит к следующему элементу в цепочке. Также для каждого куска кода может быть свой обработчик. Если данный обработчик – последний в цепочке, то у него указатель на предыдущий обработчик (поле PrevLink) будет равен -1. Чтобы точно понять, что же такое цепочка из внутри-поточных обработчиков посмотрите на рисунок:
При вызове внутри-поточного обработчика ОС использует Си-договоренность о передаче параметров, вместо стандартной договоренности, т.е. стек после вызова, вызывающий код, должен сам уравнивать, что ОС и делает. Прототип внутри-поточного обработчика имеет вид
Обработчик имеет доступ к структуре EXCEPTION_RECORD, которая содержит подробную информацию о исключении. С помощью адреса структуры SEH можно получить доступ к локальным переменным, т.к. структура SEH находится в стеке. Из структуры CONTEXT можно получить значения всех регистров, которые они имели во время возникновения исключения. Структуру CONTEXT также можно редактировать, чтобы исправить ошибку и продолжить выполнение программы. Параметр DispatcherContext обычно не используется. В заключение этого раздела приведу значения, которые могут возвращать конечный обработчик: eax = 1 - ОС вызывает следующий обработчик в цепочке eax = 0 - перезагружаем контекст и продолжаем Продолжение выполнения с безопасного местаВнутри-поточный обработчикКогда мы просто прыгаем на безопасное место из обработчика, мы не сохраняем никакие регистры, кроме регистра EIP. Например, регистры ESP, EBP не сохраняются. Именно поэтому такой способ - «грязный». Есть техника позволяющая сохранять регистры, а также иметь доступ к локальным данным. Для этого нужно написать соответствующий обработчик. Используя эту технику можно исправить ошибку и продолжить выполнение с безопасного места. Вот маленькая программа, где используется техника продолжения выполнения с безопасного места:
В начале программы, в стеке создается SEH-фрейм. По адресу FS:[0] передается указатель на этот SEH-фрейм. Помимо смещения обработчика и адреса предыдущего SEH-фрейма мы передаем смещение безопасного места, значение ESP и EBP. Т.о. мы заполняем все поля структуры SEH. Если происходит исключение, то управление передается обработчику исключений SEHHandler. Обработчик исключений, используя переданную ему структуру SEH заполняет некоторые поля структуры CONTEXT, а именно регистры ESP(для сохранения вершины стека), EBP(для доступа к локальным данным), EIP(для перехода на безопасное место). Обработчик возвращает 1 или константу ExceptionContinueExecution, чтобы сообщить операционной системе, что обработчик обработал исключение и необходимо продолжить выполнение программы в контексте указанной в структуре CONTEXT. Финальный обработчикВ финальном обработчике также можно перезагружать контекст таким образом, чтобы выполнение продолжалось с безопасного места. Но если мы хотим продолжить выполнение программы возвращать обработчик должен уже не 1, а -1. Финальному обработчику в отличие от внутри-поточного передается только структуры CONTEXT, EXCEPTION_RECORD, а структура SEH не передается, поэтому значения регистров EIP, EBP, ESP надо хранить в статической памяти или что-либо подобное, например в куче. ЗаключениеSEH также используют для переполнения стека или переполнения кучи, с помощью подмены обработчика. Это уже штучки создателей эксплойтов – отдельное сообщество компьютерного андеграунда, так же как и вирмейкеры. Очень хорошо, когда сообщества объединяются или комбинируются. Остальную информация о SEH – такую как – «раскрутка стека», «информация, которая передается обработчику», и т.д. можно прочитать в статье Джереми Гордона. Vectored Exception Handling (VEH)VEH – или векторная обработка исключений - относительно новый механизм обработки исключений. Он появился впервые в операционной системе Windows XP. Вы, наверное, испугались названия, но не бойтесь, использовать VEH очень просто. VEH это тоже самое, что и SEH – также устанавливаются обработчики исключений. Но в этих механизмах есть несколько различий. Во-первых, никаких служебных слов типа try, except, finally для С++, как раньше, нет. Т.е. это не надстройка компилятора. Во-вторых, и это очень важно – VEH это не stack-frame based механизм. Т.е. раньше все SEH-фреймы были в стеке. Теперь же узлы VEH’а находятся в куче. В-третьих, VEH обработчики глобальны для процесса. Из VEH обработчиков можно делать цепочки. Можно сравнить VEH с финальными обработчиками UnhandledExceptionFilter из которых можно делать цепочки. Различие с финальным обработчиком и в том, что векторный обработчик вызывается в первую очередь(т.е. до SEH), а финальный в последнюю. Чтобы установить векторный обработчик мы вызываем функцию AddVectoredExceptionFilter. Вот ее прототип:
FirstHandler – если этот параметр не ноль, то обработчик устанавливается, как следующий элемент в цепочке. Т.е. при возникновении исключения именно он вызовется ОС. Если этот параметр ноль, то обработчик устанавливается в начало цепочки и вызывается в том случае, если все остальные обработчики в цепочке не обрабатывают исключение, т.е. возвращают EXCEPTION_CONTINUE_SEARCH. Огромным преимуществом VEH’а над SEH’ом в том, что он отлавливает абсолютно все исключения для всех потоков. А вот у SEH’а с этим проблемы. Пример использования VEH’а:
VEH изнутриЯ попытался исследовать VEH изнутри. Что из этого получилось, описано в этом разделе. В модуле NTDLL.DLL есть статическая глобальная переменная. Назовем её CurrentVEHFrame. В этой переменной содержится адрес текущего VEH-фрейма. При вызове функции AddVectoredExceptionHandler в куче создается новый VEH-фрейм и заполняется соответствующими значениями. VEH-фреймом я называю структуру, которая определена следующим образом
Prev - адрес в куче предыдущего VEH-фрейма. Если это самый последний фрейм, то его значение равно значению адреса переменной CurrentVEHFrame.
pСurrentVEHFrame - адрес переменной CurrentVEHFrame
EncodeVEHHandler - закодированный адрес обработчика. Чтобы получить виртуальный адрес обработчика необходимо вызвать функцию RtlDecodePointer библиотеки NTDLL(можно написать так: NTDLL!RtlDecodePointer). Т.о. при вызове функции AddVectoredExceptionHandler в цепочку векторных обработчиков добавляется новый элемент. Цепочка представляет связанный список. Вот рисунок, который иллюстрирует сказанное:
Здесь при возникновении исключения будет вызван обработчик Handler1. Если он не обрабатывает исключение, то управление передается обработчику, следующему в цепочке. Еще раз повторю, что ОС определяет, что обработчик является последним в цепочке, если pCurrentVEHFrame==Prev. Это показано на рисунке. Перехват вызовов функцийОбщая картинаПерехват вызовов функций называется также «Per-process residency» техника, применяемая в операционных системах Windows. С ОС Windows поставляются файлы с расширением DLL – Dinamic Link Library. Это библиотеки динамической компоновки. Они экспортируют функции, чтобы их могли вызвать другие приложения или DLL. Чтобы приложение могло использовать какие-то сервисы ОС, оно должно вызвать одну из функций, которая экспортируются системной DLL. Все функции ОС хранятся в системных DLL. Функции, которые являются посредниками между ОС и приложением называются API(Application Programming Interface)-функциями. Соль механизма перехвата функций состоит в следующем. Когда приложение вызывает API-функцию мы можем вместо оригинальной функции вызвать свою функцию, которая может изменить результат вызова для приложения-жертвы (для того приложения, в котором мы перехватываем функции). Т.о. мы можем изменять логику работы любого приложения. Т.е. любое обращение программы к ОС мы можем контролировать, изменять или просто наблюдать за работой какого-то приложения. Мы можем понять, как работает та или иная программа по функциям, которая она вызывает. И этот способ контроля будет значительно проще для анализа, чем простая отладка. Тем более некоторые программы используют анти-отладочные механизмы. Некоторые операции в ОС Windows вообще нельзя осуществить без помощи перехвата API-функций. Перехватывать можно не только API-функции, но и любые экспортируемые функции. В вирусологии техника перехвата особенно полезна. Она используется для продвинутого заражения файлов, полезной нагрузки, получения информации нужной вирусу (например, путь к файлу для заражения), скрытия присутствия, уничтожения или нарушения работы ненужных нам программ (антивирусов и брандмауэров). В адресное пространство любого процесса загружена библиотека NTDLL.DLL. При вызове функций из kernel32.dll, например, OpenProcess в конечном итоге вызывается функция ZwOpenProcess, которая находиться в NTDLL.DLL. Низкоуровневые функции, которые находятся в NTDLL.DLL называются NativeAPI функции. Лучше перехватывать именно их, чтобы процесс жертва не смог отделаться от перехвата даже с помощью вызова Native API. Можно и просто исправить перехват. Но чтобы и этого не случилось, необходим перехват в нулевом кольце. Здесь мы будем заниматься только третьим кольцом. ПривилегииПерехват вызовов функций делается при помощи некоторого механизма. Этот механизм применим для одного конкретного процесса. Если мы хотим глобализировать наш перехват, то мы должны применить технику перехвата для всех процессов в системе. Но по умолчанию даже пользователь с привилегиями администратора не имеет возможности получить доступ к системным процессам (например, winlogon.exe). Чтобы перехватывать функции и в системных процессах необходим доступ к этим системным процессам. Вообще, для внедрения кода в удаленный процесс (а это один из важных шагов механизма перехвата) необходимы следующие привилегии:
PROCESS_CREATE_THREAD – для создания потока в удаленном процессе PROCESS_VM_WRITE – для записи в память удаленного процесса PROCESS_VM_OPERATION – для операций типа изменения прав доступа к памяти (protect и lock).
Чтобы открыть системный процесс с такими привилегиями, вызывающий функцию KERNEL32.DLL!OpenProcess должен иметь привилегию SeDebugPrivilegies. Ниже представлена процедура на ассемблере получения данной привилегии:
Здесь Priv - это строка определенная так: Priv db "SeDebugPrivilege",0 После вызова данной функции вызывающий ее процесс может открывать системные процессы.
GetLastError вернет ERROR_SUCCESS. Если открыть системный процесс без вызова функции EnableDebugPrivilege, то OpenProcess вернет ноль, а GetLastError вернет ERROR_ACCESSDENIED. Dinamic Link LibraryОбщая картинаЧтобы перехватить функцию в каком-нибудь процессе необходимо выполнить код в этом процессе. Изначально этот код не содержится в этом процессе. Т.е. его необходимо туда поместить. Для этого есть два способа: 1) Внедрение кода с помощью DLL. 2) Простое копирование кода в шел-код стиле. Большинство методов перехвата API функций используют внедрение кода с помощью DLL, т.к. при этом нет требования базовой независимости и зависимости от адресов API-функций. В случае вируса нам желательно не создавать никаких DLL, хотя нет никаких проблем, если мы создадим ее. При этом есть ограничение – это размер кода, который будет внедрен в жертву при заражении. Как создавать код в шел-код стиле мы уже знаем, теперь рассмотрим как создать DLL. DLL – это обычный PE-файл, в котором есть соответствующий флаг поля Characteristics файлового заголовка. В EXE-файле не может быть этого флага. Если в EXE файле стоит флаг DLL, то он считается некорректным. DLL – это обычно набор функций, которые экспортируются другими модулями. У DLL, как и у любого EXE файла есть точка входа. Для DLL точка входа указывает на функцию, которую условно можно назвать DLLMain. Вот её прототип:
hInstDLL – описатель данной DLL Эта функция вызывается при определенных событиях. В результате какого события была вызвана функция DLL указано в параметре reason. Вот его возможные значения и их описание: DLL_PROCESS_ATTACH - DLL получает это значение, когда впеpвые загpужается в адpесное пpостpанство пpоцесса. Вы можете использовать эту возможность для того, чтобы осуществить инициализацию. При этом значении мы устанавливаем перехватчик.
DLL_PROCESS_DETACH - DLL получает это значение, когда выгpужается из адpесного пpостpанства пpоцесса. Вы можете использовать эту возможность для того, чтобы "почистить" за собой: освободить память и так далее.
DLL_THREAD_ATTACH - DLL получает это значение, когда пpоцесс создает новый поток.
DLL_THREAD_DETACH - DLL получает это значение, когда поток в процессе был уничтожен. Создание DLLСоздание DLL мало отличается от создания EXE. Вот код самой простой DLL:
Также необходимо создать файл с расширением DEF, который должен быть примерно такого вида:
Где LIBRARY – имя библиотеки, EXPORTS – имя функции, которая экспортируется из DLL(EXPORTS может быть несколько). Необходимо при вызове DLLMain сохранять регистры esi,edi,ebx,ebp и восстанавливать их при выходе из DllMain. Для компиляции DLL нужно создать как обычно объектный файл, а для линковки используйте следующую строку: link /DLL /SUBSYSTEM:WINDOWS /DEF:DLL.def DLLSkeleton.obj Видите, ключ /DLL указывает на установку флага DLL в файловом заголовке. Внедрение и исполнение удаленного кодаВнедрить DLL в адресное пространство постороннего процесса можно несколькими способами. А именно: с помощью реестра, с помощью хуков, с помощью удаленных потоков, с помощью замены оригинальной DLL, а также DLL можно внедрить как отладчик или через функцию KERNEL32.DLL!CreateProcess. Все эти способы описаны в книгe Джеффри Рихтера «Windows для профессионалов». Можно также и даже проще внедрить просто посторонний код в чужой процесс. Хотя в этом случая потребуется время на его создание. Но мы-то с Вами знаем теперь как делать такой код. Я буду использовать метод внедрения DLL с помощью удаленных потоков, т.к. он является самым гибким. Но вы можете использовать любой другой. Это совершенно не принципиально, главное чтобы внедрение происходило правильно и в нужные приложения. Методы внедрения, конечно, отличаются друг от друга и налагают некоторые ограничения. Windows предоставляет функцию, которая называется KERNEL32.DLL!CreateRemoteThread. Она позволяет создать новый поток внутри удаленного процесса. Мы заставляем вызвать функцию KERNEL32.DLL!LoadLibrary потоком целевого процесса для загрузки нужной DLL. Одним из параметров функции KERNEL32.DLL!CreateRemoteThread является lpStartAddress, который означает адрес процедуры потока. Процедура потока принимает один параметр. KERNEL32.DLL!LoadLibrary принимает также один параметр. Т.е. как стартовый адрес удаленного потока мы можем указать адрес функции KERNEL32.DLL!LoadLibrary. При этом мы пользуемся тем, что KERNEL32.DLL проецируется во всех виртуальных адресных пространствах по одному и тому же адресу и из этого соображения предполагаем, что в удаленном процессе функция KERNEL32.DLL!LoadLibrary тоже находиться по тому же адресу что и в нашем процессе. Еще один важный момент заключается в параметре, который передается потоку и соответственно функции LoadLibrary. Мы должны передать адрес строки с именем функции. Адрес этот должен обязательно находиться в адресном пространстве целевого процесса, т.е. мы должны скопировать эту строку туда. Выделения виртуальной памяти в удаленном процессе производиться c помощью функции KERNEL32.DLL!VirtualAllocEx. Осуществлять запись и чтение памяти чужого процесса можно с помощью функций KERNEL32.DLL!WriteProcessMemory и KERNEL32.DLL!ReadProcessMemory соответственно. Освободить выделенный регион можно с помощью функции KERNEL32.DLL!VirtualFreeEx. Вот код программы с помощью, которой внедряется DLL:
После внедрения DLL вызывается DllMain с параметром DLL_PROCESS_ATTACH. Именно при обработке этого параметра мы устанавливаем перехватчик. Способы перехвата функцийПравка таблицы импортаПри вызове Win32-приложением функции экспортируемой из другого модуля, например
компилятор генерирует код следующего вида:
, где X – адрес переходника вида jmp dword ptr [Y], где Y – адрес адреса функции в IAT(Import Address Table), которую заполняет при загрузке модуля загрузчик. При особой настройке компилятора вызов может быть таким CALL DWORD PTR [Y]. Суть метода перехвата заключается в том, чтобы править значения, которые находятся по адресу Y, т.е. правка значений в таблице адресов импорта. Сначала мы сохраняем реальный адрес перехватываемой функции. Потом проходимся по IAT и правим этот реальный адрес на адрес нашего обработчика. Но править придется IAT всех модулей в данный момент загруженный в АП процесса, а также всех динамически подгружаемых. В первом случае необходимо решить задачу получения списка всех модулей загруженных в АП процесса. Во втором случае мы должны перехватывать функции LoadLibraryA, LoadLibraryW, LoadLibraryExA, LoadLibraryExW. Также необходимо сделать так, чтобы функция GetProcAddress возвращала адрес нашего перехватчика, если вдруг жертва захочет получить реальный адрес функции, которую мы перехватываем. Это можно делать двумя способами – перехватом GetProcAddress или правкой таблицы экспорта модуля, где находиться перехватываемая функция. У этого способа есть один очень большой недостаток – функции, которые не содержатся в таблице импорта, перехватываться не будут, если только мы не будем осуществлять перехват прямо при начальной загрузке процесса. Обычно перехват делается для процесса, который уже работает. Например, программа получает адрес функции с помощью GetProcAddress, а потом мы уже делаем перехват. Тогда программа минует наш обработчик и вызовет правильную функцию. Сначала я опишу процедуру, которая правит IAT указанного одним из параметров модуля. Я назвал эту процедуру EdiIATLocal. Например, мы перехватываем функцию, адрес которой X. Тогда процедура EditIATLocal анализирует таблицу импорта указанного модуля и если она встречает там адрес X, то функция меняет X на адрес нашего обработчика, который также передается как параметр функции.
А процедура EditIATGlobal правит IAT всех модулей процесса, в котором она вызывается. Мы вызываем ее в процедуре DllMain DLL, которую мы будет внедрять в адресное пространство процесса-жертвы. Она просто перечисляет все модули в адресном пространстве текущего процесса с помощью ToolHelp-функций, а потом последовательно вызывает для каждого модуля процедуру EditIATLocal, которую я описал чуть выше.
В функции DLLMain DLL, которую мы впоследствии будем внедрять во все процессы мы должны обрабатывать reason следующим образом:
Простой пример – перехват MessageBoxЯ приложил к статье исходный код DLL, которая перехватывает функции USER32.DLL!MessageBoxA и USER32.DLL!MessageBoxW в целевом процессе. Файлы исходного кода этой DLL находиться в папке HookMessBox. Чтобы посмотреть как работает перехват этих функций Вы можете использовать для внедрения мою программу DLL Injector. Например, попробуйте внедрить эту DLL в блокнот, напечатать чего-нибудь и потом нажать на крестик закрытия окна. Перехват LoadLibraryЧтобы распространить перехват на новые подгружаемые DLL, необходимо перехватывать KERNEL32.DLL!LoadLibrary. Используя функцию EditIATLocal Вы сможете с легкостью перехватить вызов KERNEL32.DLL!LoadLibrary таким образом, чтобы после загрузки новой DLL она сразу же обрабатывалась. СплайсингСначала определяется адрес функции, которую надо перехватить. Первый несколько байт данной функции заменяются на переход к нашему обработчику. Теперь, если будет вызвана перехватываемая функция, то произойдет переход на наш обработчик. Если нужно вызвать оригинальную функцию, то необходимо восстановить исходные байты. С помощью этого метода перехватываются абсолютно все вызовы из любых модулей, и при этом не надо делать ничего дополнительного. Этот метод хорош во всех отношениях, если бы не одно НО…Люди, которые понимают что-нибудь в многозадачности сразу учуяли что-то не-то. Представьте, что какой-то поток правит начало функции джапмом, но вдруг ОС отнимает у него управление и передает его другому потоку. А тот обращается к недоконца подправленной функции. В итоге произойдет ошибка и приложение, скорее всего, слетит. Есть решение этой проблемы, - останавливать все потоки, когда начало функции правиться и когда вызывается ее перехватчик (ведь перехватчик тоже правит начало функции, чтобы вызывать ее оригинал). Все эти вещи реализуются очень просто. Давайте рассмотрим функции, которые приостанавливают и запускают потоки, соответственно. Нашей задачей опять будет перехват функций USER32.DLL!MessageBoxA.
В процедуре ResumeThreads не учитывается, что поток можем остановить не мы. Но это допущение для большинства приложений не является критическим. Простой пример – перехват MessageBoxПосле того, как мы нашли реальный адрес функции MessageBoxA, мы сохраняет старые 6 байт по некоторому адресу. Далее мы записываем по этому адресу переход на наш обработчик. Код перехода выглядит так:
А вот функция, которая как раз делает то, к чему мы стремились – осуществляет перехват:
Также нужен код, который позволяет выполнить оригинальную функцию, т.е. временно убрать перехват:
А вот и сам перехватчик. Т.е. код на который мы прыгаем, при вызове перехватываемой функции.
Сплайсинг с сохранением оригинальной функцииКогда мы устанавливаем перехват с помощью сплайсинга, мы затираем первые несколько байт оригинальной функции. Если мы используем относительный JMP, то мы затираем первые 5 байт. Перед затиркой мы сохраняем эти 5 байт. Когда нам нужно вызвать оригинальную функцию, мы записываем сохраненные байты по адресу точки входа функции. Вот здесь есть проблеме связанная с реентерабельностью. Мы можем избавиться от этой проблемы. Мы должны всего лишь сохранить первые инструкции, размер которых больше или равно 5 байтам (в случае, если мы затираем начало функции относительным JMP). Тогда если мы хотим вызвать оригинальную функцию, мы вызываем инструкции по адресу, по которому мы сохраняли затертые инструкции. После выполнения этих затертых инструкций мы выполняем инструкцию JMP на адрес в перехватываемой функции, где начинается следующая инструкция. Таким образом, логика работы оригинальной функции совершенно не меняется. При этом мы можем ее вызывать без особых функций. Самая главная здесь сложность – это как определить начало следующей инструкции, т.е. здесь нам необходим дизассемблер длин. Ему на вход подается адрес, а выход – это количество байт, занимаемых инструкцией по входному адресу. Чтобы понять смысл этого метода рассмотрим простой пример. Во-первых, определим место, куда мы будем копировать инструкции, которые могут быть затерты. Мы сделаем это так:
Мы будем сохранять инструкции по адресу old_func. Мы оставляем место для некоторого количества инструкций. Мы заполняем оставшееся место в буфере 090h, т.к. эта инструкция ничего не делает, в результате её выполнения просто инкрементируется регистр EIP. В конце буфера мы ставим относительный JMP, адрес, куда мы будем переходить в этой инструкции, мы потом должны заполнить. При вызове оригинальной функции мы вызываем ее так: CALL old_func Допустим, мы перехватываем функцию Sleep. До перехвата она выглядит так:
С помощью дизассемблера длин мы вычисляем последовательно длины команд. Если с начала функции сумма длин команд больше или равно 5, то сохраняем обработанные инструкции по адресу old_func. Для функции Sleep мы сохраняем 6 байт, т.е. два PUSH’а. Также мы запоминаем адрес 77E8677F – после выполнения двух PUSH’ей мы джампим на этот адрес. После установки перехвата функция Sleep примет следующий вид:
А код old_func будет таким:
Таким образом, если мы хотим вызывать оригинальную функцию мы вызываем old_func – это и будет оригинальной функцией. old_func называется функцией-трамплином (trampoline function). Этот метод используется в продукте для перехвата функций, который называется Detours. Описанный способ не может работать если функция занимает меньше 5 байт. Эту проблему можно решить с помощью перехода не командой JMP, а командой INT 3(наш перехватчик в итоге будет обработчиком необработанных исключений). Команда INT 3 занимает 1 байт. Но производительность этого способа оставляет желать лучшего. Перехват правкой системных библиотек на жестком дискеМожно разделить способы перехвата на перехват до запуска модуля и перехват после запуска модуля. При перехвате до запуска модуля, используется техника правки системных библиотек на жестком диске. Для этого необходимо проделать следующие шаги: 1) Отключить защиту файлов ОС Windows (Windows File Protection). 2) Переименовать файл системной библиотеки, которую мы заменяем. 3) Создать правленую библиотеку и скопировать ее с оригинальным названием в системный каталог Windows, где она и была. 4) После перезагрузки перехват будет глобален для всех процессов и для этого не нужно ничего более. Чтобы осуществить все перечисленные шаги необходимо знать, что такое Windows File Protection и как его отключать без перезагрузки системы. Windows File ProtectionWindows File Protection – это сервис ОС, который защищает системные файлы ОС от изменения, повреждения или удаления. Впервые WFP появился в ОС Windows Millennium Edition. До появления WFP любая программа могла заменить системную библиотеку, что многие программы и делали при инсталляции. Из-за этого другие программы переставали работать и при этом могли забрать систему с собой в мир иной :) Такое положение вещей назвали “DLL Hell”. В Windows Millennium Edition все системные SYS, DLL, EXE, and OCX защищены. В дополнение TrueType шрифты Micross.ttf, Tahoma.ttf, и Tahomabd.ttf также защищены. Если происходит изменение, модификация или удаление защищенного файла, то система восстанавливает его из кэша DLL, который по умолчанию находиться в папке: %SYSTEMROOT%\system32\dllcache Этот путь можно изменить, изменив значение параметра реестра: HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SFCDllCacheDir Чтобы узнать, что был заменен какой-то из файлов, Windows просматривает каталоги безопасности и сверяет цифровые подписи. Если подпись какого-файла не соответствует подписи в каталоге безопасности, то Windows берет файлы из кэша. Потом Windows ищет эти файлы в сети, если была произведена установка оп сети. Если данный файл отсутствует в кэше и в сети, то Windows требует вставить оригинальный диск ОС. Можно включить принудительную проверку всех файлов ОС Windows с помощью утилиты sfc, которая доступна в стандартной комплектации ОС. Также при обнаружении исправленного или удаленного системного файл WFP записывает событие в лог событий, который можно посмотреть с помощью оснастки Event Log (%windir%\system32\eventvwr.msc). Следующие механизмы позволяют изменять системные файлы, не смотря на Windows File Protection:
• установка Windows Service Pack с использованием Update.exe • установка хотфиксов с использованием Hotfix.exe • Обновление ОС с использованием Winnt32.exe • Windows Update
Чтобы без шума добраться до системных файлов и отредактировать их мы должны отключить WFP. Есть несколько способов сделать это. Например, с помощью редактирования реестра или с помощью правки файла sfc.dll или sfc_os.dll. Но эти способы теряют свою актуальность, потому что они либо работали с какой-то конкретной ОС, либо требуют перезагрузки и/или входа в безопасный режим ОС. Но есть способ отключения WFP прямо при работе. Давайте его и рассмотрим. Отключение Windows File Protection на летуWFP держится на двух DLL – SFC.DLL, SFC_OS.DLL. А код, который использует эти DLL находиться в WINLOGON.EXE. Модуль SFC_OS.DLL экспортирует функцию, которая экспортируется не по имени, а по ординалу и имеет ординал 1. Эта функция запускает систему защиты файлов. Если покопаться в коде этой функции, то можно увидеть, что она вызывает функцию NTDLL.DLL!NtNotifyChangeDirectoryFile. Это недокументированная функция, но на ней основывается другая функция, которая называется KERNEL32.DLL!FindFirstChangeNotification. Эта функция возвращает описатель, который можно использовать в функциях ожидания, например KERNEL32.DLL!WaitForSingleObject. Т.е. WFP устанавливает систему нотификации на системные папки. Если файлы в папке изменяются, то WFP сразу на это реагирует. Все что нам требуется чтобы отключить WFP – это закрыть все описатели, которые были возвращены NTDLL.DLL!NtNotifyChangeDirectoryFile. Эти описатели типа «файл». Если мы захотим отключить WFP, когда система работает, и если мы не хотим писать код, можно просто запустить утилиту Process Explorer или подобную ей, чтобы закрыть хэндлы объектов «файл». Например, File Object – C:\WINDOWS\SYSTEM32\. Закрывая этот описатель, мы можем изменять файлы в папке C:\WINDOWS\SYSTEM32 и Windows ничего не скажет. При реализации кода процедуры отключения WFP необходимо знать, как получить хэндлы открытых описателей. Это делается с помощью функции NtQuerySystemInformation. В MSDN она документирована, но не полностью и того, что нам нужно там нет. Приходиться использовать справочник Гарри Нэббета “Windows NT 2000 Native API Reference”. Чтобы отключить таким образом WFP, необходимы отладочные привилегии, т.к. нам приходиться открывать процесс WINLOGON.EXE. А для того чтобы получить отладочные привилегии, необходимы привилегии администратора. Из этого следует, что этот способ будет работать только под учетной записью администратора или используя имперсонацию. Для начала получаем идентификатор процесса WINLOGON.EXE. Он нужен для того, чтобы отличать хэндлы процесса WINLOGON.EXE от всех остальных. Чтобы получить идентификатор по имени модуля, используем функцию GetPIDbyName:
В функции GetPIDbyName используем Toolhelp-функции для перечисления процессов в системе. Мы сравниваем имя полученного модуля со статической строкой “WINLOGON.EXE”. Сравнение идет с помощью API-функции lstrcmpi. Эта функция сравнивает строки не учитывая во внимание регистр символов. Далее нам необходимо получить список всех описателей процесса WINLOGON.EXE. Но в ОС Windows нет функции, которая позволила бы получить описатели для конкретного процесса. Однако, как Вы уже знаете описатели можно получить с помощью Native функции NtQuerySystemInformation. Часть описания этой функции доступно в MSDN, но этого нам не достаточно. Более того там написано неправильно!!! :( Посмотрите на прототип этой функции:
Давайте прочтем описание переменной ReturnLength: «ReturnLength [out, optional] Optional pointer to a location where the function writes the actual size of the information requested. If that size is less than or equal to the SystemInformationLength parameter, the function copies the information into the SystemInformation buffer; otherwise, it returns an NTSTATUS error code and returns in ReturnLength the size of buffer required to receive the requested information.» Вот здесь и есть ошибка в документации. На самом деле, если размер буфера меньше нужного, то параметр ReturnLength не заполняется. Так как размер буфера не перманентен, то нам приходиться инкрементно перебирать размеры. Если функция возвращает STATUS_INFO_LENGTH_MISMATCH, то размер буфера недостаточен. Вот код который находит нужный размер буфера:
После выполнения вышеприведенного кода, в pSystemHandleInfo содержится указатель на буфер. В буфере содержится количество описателей. А потом массив структур типа HandleInfo. Количество структур в этом буфере ровно соответствует первому двойному слову буфера. Эта структура определена следующим образом:
Pid мы используем, чтобы узнать какому процессу принадлежит описатель. Также мы будем использовать параметр HandleValue для дублирования хэндлов. После того как мы узнали, что данный описатель принадлежит процессу WINLOGON.EXE мы должны узнать имя объекта соответствующего данному описателю. Нас интересует имя \Device\HarddiskVolume1\WINDOWS\system32. А если точнее его часть WINDOWS\SYSTEM32. Закрывая эти описатели, мы отключаем Windows File Protection. Чтобы получить имя объекта по его описателю, мы вызываем функцию NtQueryObject. Эта Native функция полностью недокументированна. По крайней мере в MSDN VisualStudio .NET 2003 ее описание отсутствует. Но я знаю, что ее описание есть в DDK. Как бы то ни было, я взял прототип функции в книге Гарри Нэббета. Мы вызываем функцию NtQueryObject, чтобы получить имя объекта соответствующее описателю. Далее мы сравниваем UNICODE-строку «WINDOWS\SYSTEM32»или «WINNT\SYSTEM32» с полученным именем объекта. Сравниваем мы с конца, идя в начало. Сравнение идет с помощью функции CompareStringsBackwards. В ней используются цепочечные операции пересылки слов. Длина сравнения зависит от длины строки «WINDOWS\SYSTEM32» или «WINNT\SYSTEM32». А вот и функция CompareStringsBackwards:
Если строки равны и CompareStringsBackwards возвращает единицу, то мы переоткрываем описатель чтобы открыть его с правами DUPLICATE_CLOSE_SOURCE or DUPLICATE_SAME_ACCESS. Флаг DUPLICATE_CLOSE_SOURCE указывает, что функция DuplicateHandle закрывает указанный описатель в указанном процессе. А теперь посмотрите полные код программки, которая отключает Windows File Protection во время работы ОС. После перезагрузки WFP опять будет включена.
Глобальный перехватля установки в системе этого перехвата необходимо внедрить DLL в адресное пространство всех текущих процессов или просто скопировать код в Shell-код стиле (если мы не используем DLL), а также всех процессов, которые запустятся потом. Для внедрения во все текущие процессы используем Toolhelp-функции для перечисления процессов. Также можно использовать функцию NtQuerySystemInformation, которая является Native для Toolhelp-функций, а также и для функций Enum... Вот код, который устанавливает перехват для всех запущенных процессов:
Чтобы глобально перехватывать функции можно использовать функцию SetWindowsHook. Тогда мы будет перехватывать нужную функцию во всех текущих GUI-приложениях, а также новых, т.к. если мы вызываем функцию SetWindowsHook, то она внедряет DLL и для всех новых процессов. Другой способ в следующем. Необходимо перехватывать функции, которые создают процесс или которые вызываются при создании процесса. Т.о. мы будет устанавливать перехват и для всех новых процессов. В ОС Windows существует много функций, которые создают процессы – SHELL32.DLL!ShellExecute, KERNEL32.DLL!CreateProcess, NTDLL.DLL!NtCreateProcess. Нам необходимо выяснить какие действия происходят при создании любого процесса, используя любую из функций создания процессов в ОС. Какой бы функцией не был создан процесс, при создании процесса вызывается функция ZwCreateThread. Вот ее прототип:
В параметре ClientId содержиться указатель на структуру, которая называется CLIENTID. Она определена так:
UniqueProcess – это идентификатор процесса в котором создается поток. Делаем так: в обработчике ZwCreateThread после вызова нормальной функции ZwCreateThread проверяем UniqueProcess из структуры CLIENTID. Если это значение отличается от идентификатора нашего процесса, то заражаем процесс. Но не тут-то было!!! При заражении процесса вызов LoadLibrary окажется неудачным, потому что процесс еще не проинициализирован. Таким образом если идентификаторы нашего процесса и нового не совпали, то мы просто устанавливаем флажок NewProcess. А мы знаем, что при создании процесса основной поток приостановлен до тех пор, пока процесс не будет проинициализирован. После того как новый процесс будет проинициализирован для основного потока вызывается функция ZwResumeThread. Значит и ее тоже надо перехватывать. Я сделал 2 макроса, которые сохраняют и соответственно восстанавливают регистры ESI, EDI, EBX, EBP и регистр флагов. Вот эти макросы:
Взгляните на обработчик ZwCreateThread:
Теперь нам надо перехватить ZwResumeThread. Вот ее прототип:
Как видите нам передается описатель потока, работа которого возобновляется. Нам необходимо получить id процесса, которому принадлежит этот поток. Если этот id отличается от нашего id’а и установлен флаг NewProcess, то заражаем процесс. Id процесса по описателю потока можно получить с помощью функции NtQueryInformationThread. Вот ее прототип:
ThreadHandle – описатель потока, о котором мы хотим узнать информацию. ThreadInformation – указатель на структуру THREAD_BASIC_INFORMATION в случае ThreadInformationLength равным 0. Структура THREAD_BASIC_INFORMATION определена так:
Из вложенной структуры ClientId мы узнаем id процесса, которому принадлежит поток, т.к. при вызове функции ZwQueryInformationThread заполняется структура THREAD_BASIC_INFORMATION. А вот исходный код обработчика ZwResumeThread:
В архиве прилагаемой к статье в папке GlobalHooking находиться программа и ее исходный код, где перехватывается MessageBoxA и MessageBoxW во всех текущих процессах и в новых. Примеры использования перехвата вызовов функцийВот список, где можно использовать перехват вызовов функций. Но он конечно не исчерпывающий.
Контроль сетевого трафика Скрытие файлов Скрытие сетевых соединений Скрытие процессов Продвинутое заражение Обход брандмауэра Обход антивируса Эмуляция другой ОС Взлом программ Троянские программы
Использованные источники и источники для дальнейших исследованийSEH и VEH
Windows File Protection
API Hooking
ЗаключениеВ этой главе мы рассмотрели несколько очень важных техник, без которых далеко не уйдешь. Они используются не только при программировании вирусов, но и вообще в системном программировании. Теперь используя полученный материал, Вы можете программировать любые локальные вирусы. Я понимаю, что этот материал нельзя освоить за один наскок, но Вы должны стараться. Во всяком случае, Вы будете приближаться к истинному пониманию работы ОС Windows, ее идеологии, подводных камнях и т.д. И наша задача заключается именно в понимании тонкостей работы ОС Windows. Я надеюсь, что не будете никому вредить, используя полученные знания. Я категорически против деструкции в вирусах. Лучше напрягитесь и сделайте какую-нибудь красивую или оригинальную полезную нагрузку, чтобы ЮЗВЕРЬ упал со стула от удивления, например, когда его компьютер начнет пукать :) Если у Вас есть замечания по статье или вопросы, то свяжитесь со мной по адресу BILL_TPOC@MAIL.RU. The Passion Of Code ( TPOC ) LaboratoryЯ представляю лабораторию The Passion Of Code ( TPOC ) и заявляю: если у Вас есть желание вникать в тонкости ОС и Вы уже что-то умеете, то я прошу Вас связаться со мной по адресу BILL_TPOC@MAIL.RU. Но не беспокойте пожалуйста меня те люди, которых надо подгонять что-то делать – у Вас должен быть свой энтузиазм. Сайт нашей лаборатории http://tpoc.h15.ru. Спасибо…DayDream, BlackFox, _follower / TPOC, FreeMan / TPOC Также хотел бы сказать спасибо Ms-Rem за его замечательную статью “Перехват API функций в Windows NT (часть 2). Методы внедрения кода” [C] Bill Prisoner / TPOC | Наши новостиНовые события из жизни нашей лаборатории СтатьиСтатьи и переводы лаборатории TPOC ПрограммыПрограммы лаборатории TPOC РелизыЗдесь мы сообщаем Вам, какие творения скоро появятся СсылкиСсылки на сайты, где можно найти больше информации Наша лабораторияИстория нашей лаборатории и ее члены |
| У вас есть предложения по нашему сайту? Напишите сюда | Любимые сайты вирмейкеров: (WASM) (RSDN) |