Фундаментальные основы хакерства. Боремся с дизассемблерами и затрудняем реверс программ

МЕНЮ


Главная страница
Поиск
Регистрация на сайте
Помощь проекту
Архив новостей

ТЕМЫ


Новости ИИРазработка ИИВнедрение ИИРабота разума и сознаниеМодель мозгаРобототехника, БПЛАТрансгуманизмОбработка текстаТеория эволюцииДополненная реальностьЖелезоКиберугрозыНаучный мирИТ индустрияРазработка ПОТеория информацииМатематикаЦифровая экономика

Авторизация



RSS


RSS новости


Крис Касперски, Юрий Язев

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

Фундаментальные основы хакерства

Пят­надцать лет назад эпи­чес­кий труд Кри­са Кас­пер­ски «Фун­дамен­таль­ные осно­вы хакерс­тва» был нас­толь­ной кни­гой каж­дого начина­юще­го иссле­дова­теля в области компь­ютер­ной безопас­ности. Одна­ко вре­мя идет, и зна­ния, опуб­ликован­ные Кри­сом, теря­ют акту­аль­ность. Редак­торы «Хакера» попыта­лись обно­вить этот объ­емный труд и перенес­ти его из вре­мен Windows 2000 и Visual Studio 6.0 во вре­мена Windows 10 и Visual Studio 2019.

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

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

САМОМОДИФИЦИРУЮЩИЙСЯ КОД В СОВРЕМЕННЫХ ОПЕРАЦИОННЫХ СИСТЕМАХ

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

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

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

Во?пер­вых, kernel32.dll экспор­тиру­ет фун­кцию WriteProcessMemory, пред­назна­чен­ную, как и сле­дует из ее наз­вания, для модифи­кации памяти про­цес­са. Во?вто­рых, прак­тичес­ки все опе­раци­онные сис­темы, вклю­чая Windows и Linux, раз­реша­ют выпол­нение и модифи­кацию кода, раз­мещен­ного в сте­ке. Меж­ду тем сов­ремен­ные вер­сии ука­зан­ных опе­раци­онных сис­тем нак­ладыва­ют на стек огра­ниче­ния, мы под­робно погово­рим об этом чуть поз­днее.

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

Архитектура памяти 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]

в дей­стви­тель­нос­ти обра­щают­ся к одной и той же ячей­ке памяти.

Это точ­ный про­образ реали­зован­ной в про­цес­сорах на архи­тек­туре x86-64 RIP-отно­ситель­ной адре­сации памяти, в которой не исполь­зуют­ся сег­менты.

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

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

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

Использование функции WriteProcessMemory

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

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

Да­лее при­веден прос­той при­мер self-modifying_code, иллюс­три­рующий исполь­зование фун­кции WriteProcessMemory для соз­дания самомо­дифи­циру­юще­гося кода:

#include <iostream>
#include <Windows.h>
using namespace std;
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
}
cout << "#JMP SHORT $-2 was changed to JZ $-2 ";
return 0;
}

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

Для ком­пиляции это­го при­мера уста­нови 32-бит­ный режим резуль­тиру­юще­го кода.

Ре­зуль­тат выпол­нения при­ложе­ния self-modifying_code

Ес­ли из ассем­блер­ной встав­ки убрать вызов фун­кции WriteMe, которая переза­писы­вает инс­трук­цию JMP на JZ, прог­рамма выпадет в бес­конеч­ный цикл.

Вы­зов фун­кции заком­менти­рован

Прог­рамма зацик­лена

Об устройстве Windows: исторический нюанс

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

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

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

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

ВЫПОЛНЕНИЕ КОДА В СТЕКЕ

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

Од­нако вмес­те с этим уве­личи­вает­ся и потен­циаль­ная угро­за ата­ки. Если выпол­нение кода в сте­ке раз­решено и при опре­делен­ных обсто­ятель­ствах из?за оши­бок реали­зации управле­ние переда­ется на вве­ден­ные поль­зовате­лем дан­ные, зло­умыш­ленник получа­ет воз­можность передать и выпол­нить на уда­лен­ной машине свой собс­твен­ный злов­редный код. Для опе­раци­онных сис­тем Solaris и Linux мож­но уста­новить «зап­латки», которые зап­ретят исполне­ние кода в сте­ке, но они не име­ют боль­шого рас­простра­нения, пос­коль­ку дела­ют невоз­можной работу мно­жес­тва прог­рамм. Боль­шинс­тву поль­зовате­лей лег­че сми­рить­ся с угро­зой ата­ки, чем остать­ся без необ­ходимых при­ложе­ний.

Не все глад­ко с исполне­нием кода в сте­ке в ОС Windows. Начиная со вто­рого пакета обновле­ния для Windows XP, в сис­теме появи­лась фун­кция безопас­ности DEP (Data Execution Prevention). Во вклю­чен­ном сос­тоянии она зап­реща­ет выпол­нение кода на опре­делен­ных стра­ницах памяти, в том чис­ле и в сте­ке. Но, как в слу­чае с *.nix-сис­темами, ее час­то отклю­чают, что­бы поль­зовать­ся компь­юте­ром по пол­ной.

По­это­му исполь­зование сте­ка для выпол­нения самомо­дифи­циру­юще­гося кода впол­не закон­но и сис­темно?незави­симо, то есть уни­вер­саль­но. Помимо это­го, такое решение устра­няет оба недос­татка фун­кции WriteProcessMemory:

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

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

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

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

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

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

Что про­изой­дет, если вот такую фун­кцию ско­пиро­вать в стек и передать ей управле­ние?

void Demo() {   printf("Demo "); }

Пос­коль­ку инс­трук­ция call, вызыва­ющая фун­кцию printf, «пере­еха­ла» на новое мес­то, раз­ница адре­сов вызыва­емой фун­кции и сле­дующей за call инс­трук­ции ста­нет сов­сем иной и управле­ние получит отнюдь не printf, а не име­ющий к ней никако­го отно­шения код! Веро­ятнее все­го, им ока­жет­ся «мусор», порож­дающий исклю­чение с пос­леду­ющим ава­рий­ным зак­рыти­ем при­ложе­ния.

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

lea eax, printfcall eax

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

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

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

#include <stdio.h>
void Demo(int (*_printf) (const char*, ...))
{
_printf("Hello, World! ");
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;
_main = main;
_Demo = Demo;
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;
}

Но не спе­ши ком­пилиро­вать и запус­кать при­ложе­ние. Для пос­тро­ения прог­раммы надо выб­рать плат­форму x86 и режим выпус­ка Release. В про­тив­ном слу­чае, хотя при­ложе­ние будет успешно пос­тро­ено, оно не выведет при­ветс­твен­ную стро­ку на экран, так как при уста­нов­ке режима Release авто­мати­чес­ки отклю­чают­ся отла­доч­ные механиз­мы ком­пиляции, которые в дан­ном слу­чае могут испортить кар­тину.

Вдо­бавок надо отклю­чить DEP. Ког­да он вклю­чен, как мы зна­ем, Windows нак­ладыва­ет зап­рет на исполне­ние кода в сте­ке, сле­дова­тель­но, прог­рамма stack_execute ничего не выведет на экран и сра­зу завер­шит свой про­цесс, потому что, про­изве­дя опре­делен­ные манипу­ляции, она копиру­ет фун­кцию Demo в стек и запус­кает ее уже отту­да. И толь­ко пос­ледняя выводит стро­ку на кон­соль.

В нашем слу­чае вклю­чение прог­раммы в спи­сок исклю­чений DEP не увен­чалось успе­хом, stack_execute в таком слу­чаем по?преж­нему не выводи­ла стро­ку.

Спи­сок прог­рамм?исклю­чений, на которые DEP вли­ять не дол­жен

По­это­му приш­лось отклю­чить DEP гло­баль­но, на уров­не всей сис­темы. В запущен­ной от име­ни адми­нис­тра­тора кон­соли надо ввес­ти:

  • bcdedit.exe /set {current} nx AlwaysOff — что­бы вык­лючить DEP;
  • bcdedit.exe /set {current} nx AlwaysOn — что­бы вклю­чить DEP.

Совет

До­пол­нитель­но нелиш­ним будет отклю­чить опти­миза­цию: во?пер­вых, так удоб­нее отла­живать прог­рамму, пос­коль­ку опти­миза­тор «съеда­ет» ненуж­ные, на его взгляд, перемен­ные; во?вто­рых, они могут быть про­ини­циали­зиро­ваны, но, по мне­нию ком­пилято­ра, не исполь­зованы, из?за чего они опять будут уда­лены из бинар­ника. А это, в свою оче­редь, может ска­зать­ся на пра­виль­ной работе при­ложе­ния. Да, быва­ет и такое. Поэто­му надо сле­дить за работой ком­пилято­ра, что­бы он не уда­лил чего?нибудь лиш­него!

Пос­ле перезаг­рузки опе­раци­онной сис­темы stack_execute будет работать как надо и выводить при­ветс­твен­ную стро­ку.

Кор­рек­тный вывод при­ветс­твен­ной стро­ки фун­кци­ей, выз­ванной из сте­ка

Кро­ме того, обра­ти вни­мание, как фун­кция printf в пре­дыду­щем лис­тинге выводит при­ветс­твие на экран. На пер­вый взгляд, ничего необыч­ного, но задумай­ся, где раз­мещена стро­ка «Hello, World!». Разуме­ется, не в сег­менте кода — там ей не мес­то (хотя некото­рые ком­пилято­ры помеща­ют ее имен­но туда). Выходит, в сег­менте дан­ных, там, где ей и положе­но быть? Но если так, то одно­го лишь копиро­вания тела фун­кции ока­жет­ся явно недос­таточ­но, при­дет­ся ско­пиро­вать и саму стро­ковую кон­стан­ту. А это уто­митель­но. Но сущес­тву­ет и дру­гой спо­соб — соз­дать локаль­ный буфер и ини­циали­зиро­вать его по ходу выпол­нения прог­раммы, нап­ример так:

...buf[666]; buff[0] = 'H'; buff[1] = 'e'; buff[2] = 'l'; buff[3]= 'l'; buff[4]= 'o';

Не самый корот­кий, но из?за его прос­тоты широко рас­простра­нен­ный путь.

Плюсы и минусы оптимизирующих компиляторов

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

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

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

В час­тнос­ти, прог­рамма, рас­смот­ренная ранее, мол­чаливо полага­ет, что ука­затель на фун­кцию сов­пада­ет с точ­кой вхо­да в эту фун­кцию, а все тело фун­кции рас­положе­но непос­редс­твен­но за точ­кой вхо­да. Имен­но такой код (наибо­лее оче­вид­ный с точ­ки зре­ния здра­вого смыс­ла) и генери­рует подав­ляющее боль­шинс­тво ком­пилято­ров. Боль­шинс­тво, но не все! Тот же Microsoft Visual C++ в режиме отладки вмес­то фун­кций встав­ляет «переход­ники», а сами фун­кции раз­меша­ет сов­сем в дру­гом мес­те. В резуль­тате в стек копиру­ется содер­жимое «переход­ника», но не само тело фун­кции! Из?за это­го при ком­пиляции нашего при­мера был выб­ран режим Release.

У дру­гих ком­пилято­ров спо­соб перек­лючения этой опции может зна­читель­но отли­чать­ся, а в худ­шем слу­чае — вооб­ще отсутс­тво­вать. Если это так, при­дет­ся отка­зать­ся либо от самомо­дифи­циру­юще­гося кода, либо от дан­ного ком­пилято­ра.

Еще одна проб­лема: как дос­товер­но опре­делить дли­ну тела фун­кции? Язык C/C++ не дает никакой воз­можнос­ти узнать зна­чение этой величи­ны, а опе­ратор sizeof воз­вра­щает раз­мер ука­зате­ля на фун­кцию, но не раз­мер самой фун­кции. Одно из воз­можных решений опи­рает­ся на тот факт, что ком­пилято­ры, как пра­вило, рас­полага­ют фун­кции в памяти сог­ласно поряд­ку их объ­явле­ния в исходной прог­рамме. Сле­дова­тель­но, дли­на тела фун­кции рав­на раз­ности ука­зате­ля на сле­дующую за ней фун­кцию и ука­зате­ля на дан­ную фун­кцию.

Пос­коль­ку Windows-ком­пилято­ры в режиме x86 пред­став­ляют ука­зате­ли 32-раз­рядны­ми целыми чис­лами, их мож­но без­болез­ненно пре­обра­зовы­вать в тип unsigned int и выпол­нять над ними раз­личные матема­тичес­кие опе­рации. К сожале­нию, опти­мизи­рующие ком­пилято­ры не всег­да рас­полага­ют фун­кции в таком прос­том поряд­ке, а в некото­рых слу­чаях даже «раз­ворачи­вают» их, под­став­ляя содер­жимое фун­кции на мес­то ее вызова. Поэто­му соот­ветс­тву­ющие режимы опти­миза­ции (если они есть) при­дет­ся отклю­чить.

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

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

САМОМОДИФИЦИРУЮЩИЙСЯ КОД КАК СРЕДСТВО ЗАЩИТЫ ПРИЛОЖЕНИЙ

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

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

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

Сле­дующий при­мер save_demo_to_file чита­ет содер­жимое фун­кции Demo, зашиф­ровыва­ет его и записы­вает получен­ный резуль­тат в файл, пос­ле чего откры­вает файл с дис­ка, заг­ружа­ет дан­ные, рас­шифро­выва­ет пос­ледова­тель­ность бай­тов и исполня­ет их, как ни в чем не бывало:

#include <stdio.h>
#include <memory.h>
void Demo(int (*_printf) (const char*, ...))
{
_printf("Hello, World! ");
return;
}
int write_file(const char* filename, unsigned char* buff, const int func_len)
{
FILE* f;
if (fopen_s(&f, filename, "wb") == 0) {
for (int a = 0; a < func_len; a++) {
unsigned char c = buff[a] ^ 0x77;
buff[a] = c;
fputc(c, f);
}
fclose(f);
}
else return -1;
return 0;
}
int read_file(const char* filename, unsigned char* buff, const int func_len)
{
FILE* f;
if (fopen_s(&f, "Data.bin", "rb") == 0) {
int bc = 0;
while (!feof(f)) {
unsigned char c = fgetc(f);
buff[bc] = c ^ 0x77;
bc++;
}
fclose(f);
}
else return -1;
return 0;
}
int main(int argc, char* argv[])
{
unsigned char buff[1000];
void (*_Demo) (int (*) (const char*, ...));
int(*_main) (int, char**);
int (*_printf) (const char*, ...);
_Demo = Demo;
_main = main;
_printf = printf;
int func_len = (unsigned int)_main - (unsigned int)_Demo;
for (int a = 0; a < func_len; a++)
buff[a] = ((unsigned char*)_Demo)[a];
const char* fname = "Data.bin";
// Выводим последовательность байтов на экран
printf("%s ", buff);
// Зашифровываем последовательность байтов и пишем в файл
write_file(fname, buff, func_len);
// Выводим измененную последовательность байтов на экран
printf("%s ", buff);
// Очищаем массив байтов
memset(buff, 0, 1000);
// Выводим обнуленную последовательность байтов на экран
printf("%s ", buff);
// Читаем байты из файла, одновременно расшифровывая их
read_file(fname, buff, func_len);
// Выводим итоговую последовательность байтов на экран
printf("%s ", buff);
_Demo = (void (*) (int (*) (const char*, ...))) &buff[0];
_Demo(_printf);
return 0;
}

Что­бы ском­пилиро­вать прог­рамму, уста­нови для сре­ды раз­работ­ки те же парамет­ры, что были в прош­лом про­екте: плат­форма — x86, режим — Release. Так­же можешь отклю­чить опти­миза­цию.

Для наг­ляднос­ти выпол­няемые прог­раммой опе­рации помеще­ны в отдель­ные фун­кции. Как уже было ска­зано выше, фун­кция Demo выс­тупа­ет объ­ектом экспе­римен­та: сна­чала она чита­ется из основной фун­кции main, тем самым ее тело сох­раня­ется в мас­сиве бай­тов buff. Затем имя фай­ла для сох­ранения, этот буфер и его дли­на переда­ются фун­кции write_file, которая побай­тно записы­вает содер­жимое буфера в файл, одновре­мен­но шиф­руя каж­дый байт. При этом в буфере зашиф­рован­ный байт заменя­ет исходный. Закон­чив свое выпол­нение, фун­кция write_file воз­вра­щает в получен­ном парамет­ре ука­затель на модифи­циро­ван­ный буфер.

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

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

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

Плюс в пап­ке с прог­раммой появ­ляет­ся файл Data.bin, содер­жащий дво­ичный зашиф­рован­ный код фун­кции Demo.

Зашифрованный код — следующий уровень защиты приложений

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

А что, если из исходно­го тек­ста прог­раммы нап­рочь уда­лить фун­кцию Demo, а вза­мен помес­тить ее зашиф­рован­ное содер­жимое в стро­ковой перемен­ной (впро­чем, необя­затель­но имен­но стро­ковой)? Затем в нуж­ный момент это содер­жимое может быть рас­шифро­вано, ско­пиро­вано в локаль­ный буфер и выз­вано для выпол­нения. Один из вари­антов реали­зации зашиф­рован­ной прог­раммы при­веден в сле­дующем лис­тинге — cipher_program:

#include <stdio.h> #include <string.h> #include <cstdlib> int main(int argc, char* argv[]) {     char buff[1000] = "";     int (*_printf) (const char*, ...);     void (*_Demo) (int (*) (const char*, ...));     // Эта последовательность байтов должна быть записана в одну строку     char code[] = "x22xFCx9BxF4x9Bx67xB1x32x87x3FxB1x32x86x12xB1x32x85x1BxB1x32x84x1BxB1x32x83x18xB1x32x82x5BxB1x32x81x57xB1x32x80x20xB1x32x8Fx18xB1x32x8Ex05xB1x32x8Dx1BxB1x32x8Cx13xB1x32x8Bx56xB1x32x8Ax7DxB1x32x89x77xFAx32x87x27x88x22x7FxF4xB3x73xFCx92x2AxB4";     _printf = printf;     int code_size = _countof(code);     if (strcpy_s(buff, code_size, 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; }

Что­бы пос­тро­ить прог­рамму, нуж­но, как в прош­лый раз, выб­рать плат­форму x86, режим — Release. И, воз­можно, отклю­чить опти­миза­цию.

По­бед­ный вывод при­ветс­твен­ной строч­ки зашиф­рован­ной про­цеду­рой

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

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

ЗАКЛЮЧЕНИЕ

Мно­гие счи­тают исполь­зование самомо­дифи­циру­юще­гося кода «дур­ным» при­мером прог­рамми­рова­ния и обви­няют его в том, что он не перено­сим, пло­хо сов­местим с раз­ными опе­раци­онны­ми сис­темами, тре­бует обя­затель­но обра­щать­ся к ассем­бле­ру и так далее. С появ­лени­ем Windows NT этот спи­сок попол­нился еще одним умо­зак­лючени­ем, дес­кать, самомо­дифи­циру­ющий­ся код — толь­ко для MS-DOS, в нор­маль­ных же опе­раци­онных сис­темах он невоз­можен.

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

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


Источник: teletype.in

Комментарии: