Страница с сайта Владислава Пирогова Ассемблер и не только.

Как реализовать самомодифицирующийся код в современных операционных системах

(статья была опубликована в журнале "Программист")

Лет десять-двадцать тому назад, в эпоху рассвета MS-DOS, программистами широко использовался самомодифицирующийся код, без которого не обходилась практически ни одна мало-мальски серьезная защита. Да и не только защита, - он встречался в компиляторах, компилирующих код в память, распаковщиках исполняемых файлов, полиморфных генераторах и т.д. и т.п.

В середине девяностых началась массовая миграция пользователей с MS-DOS на Windows 95\Windows NT, и разработчиком пришлось задуматься о переносе накопленного опыта и приемов программирования на новую платформу - от бесконтрольного доступа к "железу", памяти, компонентам операционной системы и связанными с ними хитроумными трюками программирования пришлось отвыкать. В частности, стала невозможна непосредственная модификация исполняемого кода приложений, поскольку Windows защищает его от непреднамеренных изменений. Это привело к рождению нелепого убеждения, дескать, под Windows создание самомодифицирующегося кода вообще невозможно, по крайней мере, без использования VxD и недокументированных возможностей операционной системы.

На самом деле существует по крайней мере два документированных способа изменения кода приложений, одинаково хорошо работающих как под управлением Windows 95\Windows 98\Windows Me, так и под Windows NT\Windows 2000, и вполне удовлетворяющихся привилегиями гостевого пользователя.

Во-первых, kernel32.dll экспортирует функцию WriteProcessMemory, предназначенную, как и следует из ее названия, для модификации памяти процесса. Во-вторых, практически все операционные системы, включая Windows и LINUX, разрешают выполнение и модификацию кода, размещенного в стеке.

В принципе, задача создания самомодифицирующегося кода может быть решена исключительно средствами языков высокого уровня, таких, например, как Си, Си++, Паскаль без применения ассемблера (хотя, знание ассемблера очень, очень желательно).

Материал, изложенный в настоящей главе, большей частью ориентирован на компилятор Microsoft Visual C++ и 32-разрядный исполняемый код. Под Windows 3.x приведенные примеры работать не будут. Но это вряд ли представляет существенную проблему - доля машин с Windows 3.x на рынке очень невелика, поэтому, ими можно полностью пренебречь.

Архитектура памяти Windows

Создание самомодифицирующегося кода требует знания некоторых тонкостей архитектуры Windows, не очень-то хорошо освященных в документации. Точнее, совсем не освященных, но от этого отнюдь не приобретающих статус "недокументированных особенностей", поскольку, во-первых, они одинаково реализованы на всех Windows-платформах, а во-вторых, их активно использует компилятор Visual C++ от Microsoft. Отсюда следует, что никаких изменений даже в отдаленном будущем компания не планирует; в противном случае код, сгенерированный этим компилятором, откажет в работе, а на это Microsoft не пойдет (вернее, не должна пойти, если верить здравому смыслу).

Для адресации четырех гигабайт виртуальной памяти, выделенной в распоряжение процесса, Windows используют два селектора, один из которых загружается в сегментный регистр CS, а другой - в регистры DS, ES и SS. Оба селектора ссылаются на один и тот же базовый адрес памяти, равный нулю, и имеют идентичные лимиты, равные четырем гигабайтам. (Замечание: помимо перечисленных сегментных регистров, Windows еще использует и регистр FS, в который загружает селектор сегмента, содержащего информационный блок потока TIB).

Фактически существует всего один сегмент, вмещающий в себя и код, и данные, и стек процесса. Благодаря этому передача управления коду, расположенному в стеке, осуществляется близким (near) вызовом или переходом, и для доступа к содержимому стека использование префикса "SS" совершенно необязательно. Несмотря на то, что значение регистра CS не равно значению регистров DS, ES и SS, команды MOV dest,CS:[src]; MOV dest,DS:[src] и MOV dest,SS:[src] в действительности обращаются к одной и той же ячейке памяти.

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

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

Манипулировать атрибутами страниц, равно как и ассоциировать страницы с линейными адресами, может только операционная система или код, исполняющийся в нулевом кольце. В защите Windows 95\Windows 98 имеются люки, позволяющие прикладному коду повысить свои привилегии до супервизора, но выгода от их использования сомнительна, поскольку "привязывает" пользователя к этой операционной системе и не дает возможности проделать тот же трюк на Windows NT\Windows 2000.

Замечание: среди начинающих программистов ходит совершенно нелепая байка о том, что, дескать, если обратится к коду программы командой, предваренной префиксом DS, Windows якобы беспрепятственно позволит его изменить. На самом деле это в корне неверно - обратиться-то она позволит, а вот изменить - нет, каким бы способом ни происходило обращение, т. к., защита работает на уровне физических страниц, а не логических адресов.

Использование WriteProcessMemory

Если требуется изменить некоторое количество байт своего (или чужого) процесса, самый простой способ сделать это - вызвать функцию WriteProcessMemory. Она позволяет модифицировать существующие страницы памяти, чей флаг супервизора не взведен, т.е., все страницы, доступные из кольца 3, в котором выполняются прикладные приложения. Совершенно бесполезно с помощью WriteProcessMemory пытаться изменить критические структуры данных операционной системы (например, page directory или page table) - они доступны лишь из нулевого кольца. Поэтому, эта функция не представляет никакой угрозы для безопасности системы и успешно вызывается независимо от уровня привилегий пользователя (автору этих строк доводилось слышать утверждение, дескать, WriteProcessMemory требует прав отладки приложений, но это не так).

Процесс, в память которого происходит запись, должен быть предварительно открыт функцией OpenProcess с атрибутами доступа "PROCESS_VM_OPERATION" и "PROCESS_VM_WRITE". Часто программисты, ленивые от природы, идут более коротким путем, устанавливая все атрибуты - "PROCESS_ALL_ACCESS". И это вполне законно, хотя справедливо считается дурным стилем программирования.

Простейший пример использования функции WriteProcessMemory для создания самомодифицирующегося кода, приведен в листинге 3. Данная программа заменяет инструкцию бесконечного цикла "JMP short $-2" на условный переход "JZ $-2", который продолжает нормальное выполнение программы. Неплохой способ затруднить взломщику изучение программы, не правда ли? (Особенно, если вызов WriteMe расположен не возле изменяемого кода, а помещен в отдельный поток; кроме того, модифицируемый код должен быть вполне естественен сам по себе и внешне не вызывать никаких подозрений - в этом случае хакер может долго блуждать в той ветке кода, которая при выполнении программы вообще не получает управления).

  int WriteMe(void *addr, int wb)
  {
    HANDLE h=OpenProcess(PROCESS_VM_OPERATION
          |PROCESS_VM_WRITE,
          true,GetCurrentProcessId());
    return WriteProcessMemory(h, addr,&wb,1,NULL);
  }

  int main(int argc, char* argv[])
  {
    _asm {
      push 0x74    ; JMP --> > JZ
      push offset Here
      call WriteMe
      add esp,8
  Here:    JMP short here
    }

    printf("#JMP SHORT $-2  was changed to JZ $-2\n");
    return 0;
  }

Листинг 3 Пример, иллюстрирующий использования функции WriteProcessMemory для создания самомодифицирующегося кода

Поскольку Windows для экономии оперативной памяти разделяет код между процессами, возникает вопрос: а что произойдет, если запустить вторую копию самомодифицирующейся программы? Создаст ли операционная система новые страницы или отошлет приложение к уже модифицируемому коду? В документации на Windows NT и Windows 2000 сказано, что они поддерживают копирование при записи (copy on write), т. е. автоматически дублируют страницы кода при попытке их модификации. Напротив, Windows 95 и Windows 98 не поддерживают такую возможность. Означает ли это то, что все копии самомодифицирующегося приложения будут вынуждены работать с одними и теми же страницами кода, что неизбежно приведет к конфликтам и сбоям?

Нет, и вот почему - несмотря на то, что копирование при записи в Windows 95 и Windows 98 не реализовано, эту заботу берет на себя сама функция WriteProcessMemory, создавая копии всех модифицируемых страниц, распределенных между процессами. Благодаря этому, самомодифицирующийся код одинаково хорошо работает как под Windows 95\Windows 98\Windows Me, так и под Windows NT\Windows 2000. Однако следует учитывать, что все копии приложения, модифицируемые любым иным путем (например, командой mov, вызванной из нулевого кольца) будучи запущенными под Windows 95\Windows 98 будут разделять одни и те же страницы кода со всеми вытекающими отсюда последствиями.

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

Другое ограничение WriteProcessMemory заключается в невозможности создания новых страниц - ей доступны лишь уже существующие страницы. А как быть в том случае, если требуется выделить некоторое количество памяти, например, для кода, динамически генерируемого "на лету"? Вызов функций, управления кучей, таких как malloc, не поможет, поскольку выполнение кода в куче запрещено. И вот тогда-то на помощь приходит возможность выполнения кода в стеке

Выполнение кода в стеке

Разрешение на выполнение кода в стеке объясняется тем, что исполняемый стек необходим многим программам, в том числе и самой операционной системе для выполнения некоторых системных функций. Благодаря этому упрощается генерация кода компиляторами и компилирующими интерпретаторами.

Однако вместе с этим увеличивается и потенциальная угроза атаки - если выполнение кода в стеке разрешено, и ошибки реализации при определенных обстоятельствах приводят к передаче управления на данные, введенные пользователем, злоумышленник получает возможность передать и выполнить на удаленной машине свой собственный зловредный код. Для операционных систем Solaris и Linux существуют "заплатки", установка которых приводит к запрету исполнения кода в стеке, но они не имеют большого распространения, поскольку, делают невозможной работу множества программ, и большинству пользователей легче смириться с угрозой атаки, чем остаться без необходимых приложений. Поэтому, использование стека для выполнения самомодифицирующегося кода, вполне законно и системно независимо, т. е. универсально. Помимо этого, такое решение устраняет оба недостатка функции WriteProcessMemory:

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

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

Замечательно, что для программ, выполняющихся в стеке, справедлив принцип Фон Неймана - в один момент времени текст программы может рассматриваться как данные, а в другой - как исполняемый код. Именно это необходимо для нормальной работы всех распаковщиков и расшифровщиков исполняемого кода.

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

"Подводные камни" перемещаемого кода

При разработке кода, выполняющегося в стеке, следует учитывать, что в операционных системах Windows 9x, Windows NT и Windows 2000 местоположение стека различно, и, чтобы сохранить работоспособность при переходе от одной системы к другой, код должен быть безразличен к адресу, по которому он будет загружен. Такой код называют перемещаемым, и в его создании нет ничего сложного, достаточно следовать нескольким простым соглашениям - вот и все.

Замечательно, что у микропроцессоров серии Intel 80x86 все короткие переходы (short jump) и близкие вызовы (near call) относительны, т. е. содержат не линейный целевой адрес, а разницу целевого адреса и адреса следующей выполняемой инструкции. Это значительно упрощает создание перемещаемого кода, но вместе с этим накладывает на него некоторые ограничения.

Что произойдет, если следующую функцию "void Demo() { printf("Demo\n");}" скопировать в стек и передать ей управление? Поскольку, инструкция call, вызывающая функцию pritnf, "переехала" на новое место, разница адресов вызываемой функции и следующей за call инструкции станет совсем иной, и управление получит отнюдь не printf, а не имеющий к ней никакого отношения код! Вероятнее всего, им окажется "мусор", порождающий исключение с последующим аварийным закрытием приложения.

Программируя на ассемблере, такое ограничение можно легко обойти, используя регистровую адресацию. Перемещаемый вызов функции printf упрощенно может выглядеть, например, так:"lea eax, printf\ncall eax" В регистр eax (или любой другой регистр общего назначения на выбор) заносится абсолютный линейный, а не относительный адрес и, независимо от положения инструкции call, управление будет передано функции printf, а не чему-то еще.

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

Для решения данной задачи исключительно средствами языка высокого уровня, - необходимо передать стековой функции указатели на вызываемые ее функции как аргументы. Это несколько неудобно, но более короткого пути, по-видимому, не существует. Простейшая программа, иллюстрирующая копирование и выполнение функций в стеке, приведена в листинге 4.

void Demo(int (*_printf) (const char *,...) )
{
  _printf("Hello, Word!\n");
  return;
}

int main(int argc, char* argv[])
{
  char buff[1000];
  int (*_printf) (const char *,...);
  int (*_main) (int, char **);
  void (*_Demo) (int (*) (const char *,...));  
  _printf=printf;

  int func_len = (unsigned int) _main
                   - (unsigned int) _Demo;
  for (int a=0;a<func_len;a++)
    buff[a]= ((char *) _Demo)[a];
  _Demo = (void (*) (int (*) (const char *,...)))
            &buff[0];

  _Demo(_printf);
  return 0;
}

Листинг 4 Программа, иллюстрирующая копирование и выполнение функции в стеке

Елей и деготь оптимизирующих компиляторов

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

Так происходит потому, что на чистом языке высокого уровня, таком как Си или Паскаль, скопировать код функции в стек (или куда-то еще) принципиально невозможно, поскольку, стандарты языка не оговаривают, каким именно образом должна осуществляется компиляция. Программист может получить указатель на функцию, но стандарт не оговаривает, как следует ее интерпретировать - с точки зрения программиста она представляет "магическое число" в назначение которого посвящен один лишь компилятор.

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

В частности, программа, приведенная в листинге 4, молчаливо полагает, что указатель на функцию совпадает с точкой входа в эту функцию, а все тело функции расположено непосредственно за точкой входа. Именно такой код (наиболее очевидный с точки зрения здравого смысла) и генерирует подавляющее большинство компиляторов. Большинство, но не все! Тот же Microsoft Visual C++ в режиме отладки вместо функций вставляет "переходники", а сами функции размешает совсем в другом месте. В результате, в стек копируется содержимое "переходника", но не самой функции! Заставить Microsoft Visual C++ генерировать "правильный" код можно сбросом флажка "Link incrementally". У других компиляторов название этой опции может значительно отличаться, а в худшем случае - вообще отсутствовать. Если это так - придется отказаться либо от самомодифицирующегося кода, либо от данного компилятора.

Еще одна проблема - как достоверно определить длину тела функции? Язык Си не дает никакой возможности узнать значение этой величины, а оператор sizeof возвращает размер указателя на функцию, но не размер самой функции. Одно из возможных решений опирается на тот факт, что компиляторы, как правило располагают функции в памяти согласно порядку их объявления в исходной программе, и, следовательно, длина тела функции равна разности указателей на следующую за ней функцию и указателя на данную функцию. Поскольку Windows-компиляторы представляют указатели 32-разрядными целыми числами, их можно безболезненно преобразовывать в тип "unsigned int" и выполнять над ними различные математические операции. К сожалению, оптимизирующие компиляторы не всегда располагают функции в таком простом порядке, а в некоторых случаях даже "разворачивают" их, подставляя содержимое функции на место вызова. Поэтому, соответствующие опции оптимизации (если они есть) придется отключить.

Другое коварство оптимизирующих компиляторов заключается в выкидывании ими всех, не используемых (с их точки зрения) переменных. Например, в программе, приведенной в листинге 4, в буфер buff что-то пишется, но ничто оттуда не читается! А передачу управления на буфер большинство компиляторов (в том числе и Microsoft Visual C++) распознать не в состоянии, вот они и опускают копирующий код, отчего происходит передача управления на неинициализированный буфер с очевидными последствиями. Если возникнут подобные проблемы, попробуйте сбросить флажок "Global optimization", а лучше отключите оптимизацию вообще (плохо, конечно, но надо).

Откомпилированная программа по-прежнему не работает? Вероятнее всего, причина в том, что компилятор вставляет в конец каждой функции вызов процедуры, контролирующий состояние стека. Именно так ведет себя Microsoft Visual C++, помещая в отладочные проекты вызов функции __chkesp (не ищите ее описания в документации - его там нет). А вызов этот, как нетрудно догадаться, относительный! К сожалению, никакого документированного способа это запретить, по-видимому, не существует, но в финальных (release) проектах Microsoft Visual C++ не контролирует состояние стека при выходе из функции, и все работает нормально.

Самомодифицирующийся код как средство защиты приложений

И вот после стольких мытарств и ухищрений злополучный пример запущен и победно выводит на экран "Hello, World!". Резонный вопрос - а зачем, собственно, все это нужно? Какая выгода оттого, что функция будет исполнена в стеке? Ответ: код функции, исполняющееся в стеке, можно прямо "на лету" изменять, например, расшифровывать ее.

Шифрованный код чрезвычайно затрудняет дизассемблирование и усиливает стойкость защиты, а какой разработчик не хочет уберечь свою программу от хакеров? Разумеется, одна лишь шифровка кода - не очень-то серьезное препятствие для взломщика, снабженного отладчиком или продвинутым дизассемблером, наподобие IDA Pro, но антиотладочные приемы (а они существуют и притом в изобилии) - тема отдельного разговора, выходящего за рамки настоящей статьи.

Простейший алгоритм шифрования заключается в последовательной обработке каждого элемента исходного текста операцией "ИЛИ исключающее И" (XOR). Повторное применение XOR к шифротексту позволяет вновь получить исходный текст.

Следующий пример (см. листинг 5) читает содержимое функции Demo, зашифровывает его и записывает полученный результат в файл.

void _bild()
{
  FILE *f;
  char buff[1000];
  void (*_Demo) (int (*) (const char *,...));  
  void (*_Bild) ();
  _Demo=Demo;
  _Bild=_bild;

  int func_len = (unsigned int) _Bild
                  - (unsigned int) _Demo;
  f=fopen("Demo32.bin","wb");
  for (int a=0;a<func_len;a++)
    fputc(((int) buff[a]) ^ 0x77,f);
  fclose(f);
}

Листинг 5 Шифрование функции Demo

Теперь из исходного текста программы функцию Demo можно удалить, взамен этого, разместив ее зашифрованное содержимое в строковой переменной (впрочем, не обязательно именно строковой). В нужный момент оно может быть расшифровано, скопировано в локальный буфер и вызвано для выполнения. Один из вариантов реализации приведен в листинге 6.

Обратите внимание, как функция printf в листинге 4 выводит приветствие на экран. На первый взгляд ничего необычного, но, задумайтесь, где размещена строка "Hello, World!". Разумеется, не в сегменте кода - там ей не место (хотя некоторые компиляторы фирмы Borland помещают ее именно туда). Выходит, в сегменте данных, - там, где ей и положено быть? Но если так, то одного лишь копирования тела функции окажется явно недостаточно - придется скопировать и саму строковую константу. А это - утомительно. Но существует и другой способ - создать локальный буфер и инициализировать его по ходу выполнения программы, например, так: buf[666]; buff[0]='H'; buff[1]='e'; buff[2]='l'; buff[3]='l';buff[4]='o', - не самый короткий, но, ввиду своей простоты, широко распространенный путь.

  int main(int argc, char* argv[])
  {
    char buff[1000];
    int (*_printf) (const char *,...);
    void (*_Demo) (int (*) (const char *,...));
    char code[]= 
        "\x22\xFC\x9B\xF4\x9B\x67\xB1\x32\x87"\
        "\x3F\xB1\x32\x86\x12\xB1\x32\x85\x1B\xB1"\
        "\x32\x84\x1B\xB1\x32\x83\x18\xB1\x32\x82"\
        "\x5B\xB1\x32\x81\x57\xB1\x32\x80\x20\xB1"\
        "\x32\x8F\x18\xB1\x32\x8E\x05\xB1\x32\x8D"\
        "\x1B\xB1\x32\x8C\x13\xB1\x32\x8B\x56\xB1"\
        "\x32\x8A\x7D\xB1\x32\x89\x77\xFA\x32\x87"\
        "\x27\x88\x22\x7F\xF4\xB3\x73\xFC\x92\x2A"\
        "\xB4";

    _printf=printf;
    int code_size=strlen(&code[0]);
    strcpy(&buff[0],&code[0]);

    for (int a=0;a<code_size;a++)
      buff[a] = buff[a] ^ 0x77;
    _Demo = (void (*) (int (*) (const char *,...)))
              &buff[0];
    _Demo(_printf);
    return 0;
  }

Листинг 6 Зашифрованная программа

Теперь (см. листинг 6) даже при наличии исходных текстов алгоритм работы функции Demo будет представлять загадку! Этим обстоятельством можно воспользоваться для сокрытия некоторой критической информации, например, процедуры генерации ключа или проверки серийного номера.

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

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

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

С этим связана одна проблема - чтобы модифицировать такой-то байт, инструкции MOV требуется передать его абсолютный линейный адрес, а он, как было показано выше, заранее неизвестен. Однако его можно узнать непосредственно в ходе выполнения программы. Наибольшую популярность получила конструкция "CALL $+5/POP reg/MOV [reg + relative_addres], xx" - т. е. вызов следующей за инструкцией call команды и извлечению из стека адреса возврата - абсолютного адреса этой команды, который в дальнейшем используется в качестве базы для адресации кода стековой функции. Вот, пожалуй, и все премудрости.

  MyFunc:
      push  esi  ; сохранение регистра esi в стеке
      mov  esi, [esp+8]
          ; ESI = &username[0]
      push  ebx  ; сохранение прочих регистров в стеке
      push  ecx
      push  edx
      xor  eax, eax; обнуление рабочих регистров
      xor  edx, edx

  RepeatString: ; цикл обработки строки байт-за-байтом

      lodsb    ; читаем очередной байт в AL
      test  al, al  ; ?достигнут конец строки
      jz  short Exit

  ; Значение счетчика для обработки одного байта
  ; строки. Значение счетчика следует выбирать так,
  ; чтобы с одной стороны все биты полностью
  ; перемешались, а с другой - была обеспечена
  ; четность(нечетность) преобразований операции xor
      mov  ecx, 21h 
  RepeatChar:
      xor  edx, eax; циклически меняется с xor на adc
      ror  eax, 3
      rol  edx, 5
      call  $+5       ; ebx = eip
      pop  ebx    ; /
      xor  byte ptr [ebx-0Dh], 26h
          ; ^^ Эта команда обеспечивает цикл.
          ; изменение инструкции xor на adc
      loop  RepeatChar
      jmp  short RepeatString

  Exit:
      xchg  eax, edx
          ; результат работы (ser.num) в eax
      pop  edx  ; восстановление регистров
      pop  ecx
      pop  ebx
      pop  esi
      retn    ; возврат из функции

Листинг 7 Процедура генерации серийного номера, предназначенная для выполнения в стеке

Приведенный алгоритм интересен тем, что повторный вызов функции с передачей тех же самых аргументов может возвращать либо той же самый, либо совершенно другой результат - если длина имени пользователя нечетна, то при выходе из функции XOR меняется на ADC с очевидными последствиями. Если же длина имени четна - ничего подобного не происходит.

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

Но назначение данной статьи состоит не в том, чтобы предложить готовую к употреблению защиту (да и, зачем? чтобы хакерам ее было бы легче изучать?), а доказать (и показать!) принципиальную возможность создания самомодифицирующегося кода под управлением Windows 95/Windows NT/Windows 2000. Как именно предоставленной возможностью можно воспользоваться - надлежит решать читателю.

Пара слов в заключении

Многие считают использование самомодифицирующегося кода "дурным" примером программирования, обвиняя его в отсутствии переносимости, плохой совместимости с различными операционными системами, необходимости обязательных обращений к ассемблеру и т.д. С появлением Windows 95/Windows NT этот список пополнился еще одним умозаключением, дескать "самомодифицирующийся код - только для MS-DOS, в нормальных же операционных системах он невозможен (и поделом!)".

Как показывает настоящая статья, все эти притязания, мягко выражаясь, неверны. Другой вопрос - так ли необходим самомодифицирующийся код, и можно ли без него обойтись? Низкая эффективность существующих защит (обычно программы ломаются быстрее, чем успевают дойти до легального потребителя) и огромное количество программистов, стремящихся "топтанием клавиш" заработать себе на хлеб, свидетельствует в пользу необходимости усиления защитных механизмов любыми доступными средствами, в то числе и рассмотренным выше самомодифицирующимся кодом.