Как да създадете зъл компилатор

Знаете ли, че има тип задна врата на компилатор, срещу която е невъзможно да се защитите? В тази публикация ще ви покажа как да реализирате такава атака в по-малко от 100 реда код. Кен Томпсън, създателят на операционната система Unix, обсъди атаката през 1984 г. по време на речта си за приемане на наградата Тюринг. Тази атака все още е реална заплаха днес и няма известни решения, които да осигуряват пълен имунитет. XcodeGhost е вирус, открит през 2015 г., който използва техниката за атака на задната врата, въведена от Томпсън. Ще демонстрирам атаката Thompson с помощта на C++, но можете лесно да адаптирате моя пример към всеки език. След като прочетете тази публикация, ще си тръгнете, чудейки се дали някога отново можете да се доверите на своя компилатор.

Предполагам, че сте скептични към моите твърдения и може да имате някои въпроси. Следващият диалог може да помогне за справяне с някои съмнения и да предостави същината на атаката на Томпсън.

Аз: Как можеш да си сигурен, че компилаторът ти компилира честно кода ти, без да се промъква в задни вратички?

Вие: Изходният код на компилатора обикновено е с отворен код, така че някой е длъжен да забележи и да докладва за злонамерени компилатори, които инжектират задни вратички.

Аз: Но в крайна сметка изходният код на вашия доверен компилатор ще трябва да бъде компилиран с друг компилатор B. Как можете да сте сигурни, че B не се промъква през задни вратички във вашия компилатор по време на компилация?

Вие: Ще трябва да проверя и изходния код на Б. Хм... всъщност проверката на изходния код на B би довела до същия проблем, защото също трябва да се доверя на всичко, което компилира B. Може би бих могъл да разглобя компилирания изпълним файл и да проверя дали не са добавени задни вратички.

Аз: Но дизасемблера също е просто още една програма, която трябва да бъде компилирана в крайна сметка, така че е възможно дизасемблера да има инжектирани задни вратички в него. Компрометиран дизасемблер може да реши да скрие задните врати на програмата, която се разглобява.

Вие: Какви са шансовете това наистина да се случи? Нападателят трябва да е създал моя компилатор и след това да го е използвал, за да компилира моя дизасемблер.

Аз: След като създаде езика за програмиране C, Денис Ричи обедини усилията си с Кен Томпсън, за да създаде Unix (написан на C). Така че, ако сте на Unix, цялата ви операционна система и CLI инструментална верига са уязвими за атаката Thompson.

Вие: Вероятно е доста трудно да се създаде този зъл компилатор, така че се надяваме, че тази атака е малко вероятна.

Аз: Всъщност е много лесно за изпълнение. Ще ви покажа как да го направите в ‹100 реда код.

Демонстрация

Можете да видите атаката Thompson в действие, като клонирате това репродукция и следвате стъпките по-долу:

  1. Първо се уверете сами, че тази проста програма Login.cpp приема само паролата test123
  2. Използвайте злия компилатор, за да компилирате програмата за влизане с ./Compiler Login.cpp -o Login
  3. Стартирайте програмата за влизане с ./Login и след това въведете паролата 'backdoor'. Трябва да сте влезли успешно.

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

  1. Уверете се, че Compiler.cpp е чист (не се притеснявайте, това е само обвивка от 10 реда върху g++)
  2. Прекомпилирайте компилатора от източника, като използвате ./Compiler Compiler.cpp -o cleanCompiler
  3. Използвайте чистия компилатор, за да компилирате програмата за влизане с ./cleanCompiler Login.cpp -o Login
  4. Стартирайте програмата за влизане с ./Login и проверете, че паролата за 'backdoor' все още работи

Нека проучим различните стъпки, необходими за създаването на този зъл компилатор и скриването на лошото му поведение.

1. Създаване на чист компилатор

Тъй като писането на нашия собствен компилатор от нулата не е необходимо за демонстриране на Thompson атаката, нашият „компилатор“ просто ще бъде обвивка върху g++, както е показано по-долу.

Можем да генерираме нашия двоичен файл на компилатора, като изпълним g++ Compiler.cpp -o Compiler, което създава изпълним файл с име „Компилатор“. По-долу е нашата примерна програма за влизане, която ви позволява да влезете като root, ако въведете правилната парола „test123“. По-късно ще инжектираме задни врати в тази програма, така че да приема и паролата „backdoor“.

Можем да използваме нашия честен компилатор, за да компилираме и стартираме нашата програма за влизане с ./Compiler Login.cpp -o Login && ./Login.

Обърнете внимание, че нашият компилатор може да компилира свой собствен изходен код с ./Compiler Compiler.cpp -o newCompiler, защото самият наш C++ компилатор е написан на C++. Това позволява „самостоятелно хостване“, което означава, че новите версии на нашия компилатор се компилират с помощта на предишна версия. Това е много често срещана практика - всички Python, C++ и Java имат самостоятелно хоствани компилатори. Самостоятелното хостване ще стане важно в Стъпка 3, когато скрием нашия зъл компилатор.

2. Инжектиране на задни врати

Сега нека накараме нашия компилатор да инжектира задна врата в програмата за влизане, която позволява на всеки да влезе успешно с паролата „задна врата“. За да постигне това, нашият компилатор ще направи следното винаги, когато бъде помолен да компилира Login.cpp:

  1. Копирайте Login.cpp във временен файл LoginWithBackdoor.cpp
  2. Променете LoginWithBackdoor.cpp, за да приеме и паролата „задна врата“, като направите find-and-replace, което променя if-условието, проверяващо паролата
  3. Компилирайте LoginWithBackdoor.cpp вместо Login.cpp
  4. Изтрийте файла LoginWithBackdoor.cpp

Ето изходния код за нашия зъл компилатор, който изпълнява тези 4 стъпки — не се колебайте да го пропуснете, ако разбирате цялостната идея.

Въпреки че изходният код на програмата за влизане приема само паролата „test123“, компилираният изпълним файл сега ще приеме допълнително паролата „backdoor“, ако е изграден с нашия зъл компилатор.

> g++ EvilCompiler.cpp -o EvilCompiler
> ./EvilCompiler Login.cpp -o Login
> ./Login
Enter password:
backdoor
Successfully logged in as root
>

Може би сте забелязали, че задната атака може лесно да бъде разбита чрез преименуване на Login.cpp на нещо друго. Но нашият зъл компилатор може също толкова лесно да реши да инжектира задни врати въз основа на съдържанието на файла вместо на имената на файловете.

Никой всъщност няма да използва нашия зъл компилатор, защото всеки, който чете изходния код, е длъжен да забележи неговата измама и да го докладва. За да избегнем този проблем, ще се възползваме от самостоятелното хостване, за да скрием задната врата на нашия компилатор.

3. Скриване на Backdoor Injection

Можем да модифицираме нашия зъл компилатор EvilCompiler.cpp, за да се клонира всеки път, когато бъде помолен да компилира нашия чист компилатор Compiler.cpp от Стъпка 1. След това можем да разпространим двоичния файл EvilCompiler (разбира се преименуван) като първа версия на нашия самостоятелно хостван компилатор и да твърдим, че Compiler.cpp е съответния изходен код. Сега всеки, който използва нашия компилатор, е податлив на нашата атака, дори ако е проверил, че нашият компилатор е чист, преди да реши да го използва. Дори ако са изтеглили чистия изходен код Compiler.cpp и са го компилирали сами с EvilCompiler, генерираният изпълним файл ще бъде просто копие на EvilCompiler. Диаграмата по-долу очертава как нашият зъл компилатор и неговите задни инжекции са скрити от очите на обществеността.

По-долу са модификациите, необходими, за да може злият компилатор да се клонира сам. Можете да пропуснете подробностите за изпълнението, ако идеята на високо ниво има смисъл.

Изходният код на Compiler.cpp и Login.cpp са напълно чисти, но компилираният двоичен файл за влизане все още ще има задна врата, дори когато използваният компилатор е възстановен от чисти източници.

> g++ EvilCompiler.cpp -o FirstCompilerRelease
> ./FirstCompilerRelease Compiler.cpp -o cleanCompiler
> ./cleanCompiler Login.cpp -o Login
> ./Login
Enter password:
backdoor
Successfully logged in as root
>

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

4. Избягване на по-нататъшно откриване

Най-честата техника за проверка на целостта на програмата е да се вземе нейният SHA-256 и да се гарантира, че съответства на очакваната стойност, докладвана от доверен субект. Но не забравяйте, че програмата, която използваме за изчисляване на SHA-256, може да има задна врата, която показва на потребителя това, което иска да види. С други думи, възможно е нашата помощна програма за хеширане да има инжектирана задна вратичка, която скрива задни вратички, живеещи в други изпълними файлове. Ако смятате, че това звучи пресилено, имайте предвид, че и gcc (най-популярният C компилатор), и sha256 са компилирани с помощта на gcc. Така че със сигурност е възможно gcc да инжектира задни вратички в други програми и след това да постави задна вратичка в sha256, за да прикрие следите си. Само за да демонстрираме това, нека модифицираме нашия зъл компилатор, за да инжектира задна врата в инструмента sha256sum, така че винаги да връща правилната стойност за нашата програма за влизане. Нека също да пренебрегнем, че това би било по-трудно в реалния свят, защото не можем да кодираме твърдо очаквания хеш, тъй като двоичният файл за влизане може да се промени по време на надграждане на версията.

Ето чиста версия на нашия sha256sum, която просто извиква съществуващата реализация на CLI:

Сега модифицираме нашия зъл компилатор, както е показано по-долу, за да добавим задна врата в sha256sum по време на компилация.

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

> g++ Login.cpp -o Login        # Build a truly clean Login binary
> sha256sum Login
90047d934442a725e54ef7ffa5c3d9291f34d8a30a40a6c0503b43a10607e3f9 Login
> rm Login
> ./Compiler Login.cpp -o Login # Build a compromised Login binary
> ./Compiler sha256sum.cpp -o sha256sum
> ./sha256sum Login
90047d934442a725e54ef7ffa5c3d9291f34d8a30a40a6c0503b43a10607e3f9 Login
> ./Login
Enter password:
backdoor
Successfully logged in as root
>

Можем да използваме същата техника, за да скрием задните врати в дизасемблера или всеки друг инструмент за проверка.

Уроци за вкъщи

Това, което Томпсън направи по време на благодарствената си реч, беше невероятно. След няколко минути той показа на публиката си реалната възможност, че може да е промъкнал неоткриваема задна врата в същия софтуер, който беше изведен на сцената, за да създаде. Ето два извода от речта на Томпсън:

Наистина не можете да се доверите на код, който не сте създали изцяло сами. Никаква проверка или проверка на ниво източник няма да ви предпази от използване на ненадежден код.

Това се отнася за всички преходни зависимости, компилатори, операционни системи или всяка друга програма, изпълнявана на вашия процесор. Атаката на Томпсън демонстрира, че не можем да се доверим напълно на програма, дори ако лично прекомпилираме тази програма, нашата операционна система и цялата ни верига от инструменти от идеално чист изходен код. Можете да гарантирате 100% безопасност само като пренапишете всичко на ниво компилатор и по-долу в двоичен код. И дори да сте се заели с такава задача, единственият човек, който може да се довери на пренаписването ви, сте вие.

Когато нивото на програмата става по-ниско, тези бъгове [задни вратички инжекции] ще бъдат все по-трудни за откриване.

Бихте могли лесно да разкриете основните инжекции на задната вратичка в тази публикация в блога, като използвате дизасемблер или истинската помощна програма sha256sum вместо нашата компрометирана. Нашият зъл C++ компилатор е сравнително лесен за откриване, защото не се използва широко и следователно не може да повлияе на инструментите за проверка, за да скрие грешките си. За съжаление атаката Thompson става по-трудна за откриване, ако злият компилатор е широко разпространен или ако атаката е насочена към слоеве под компилатора. Представете си, че се опитвате да откриете инжектиране на задната врата във вашия асемблер, който е отговорен за компилирането на инструкции за асемблиране в машинен код. Нападателят може също така да създаде зъл линкер, който инжектира задни врати, докато сплита различни обектни файлове и техните символи. Би било изключително трудно да се открие зъл асемблер или линкер. Най-лошата част е, че зъл асемблер/линкер има потенциала да засегне множество компилатори, тъй като различните компилатори вероятно споделят тези компоненти.

Тези заключения са плашещи и може би се чудите дали има нещо, което можете да направите, за да се защитите. За съжаление няма решения, които да осигуряват пълна защита, но има някои достойни противодействия. Сегашната най-известна защита е „Разнообразно двойно компилиране“ (DDC), въведено от Дейвид Уилър през 2009 г. За да обобщим накратко, DDC използва различни компилатори на един и същи език, за да тества целостта на избрания компилатор. За да премине този тест, атакуващият трябва да е модифицирал всички избрани компилатори предварително, за да вмъкне задни вратички един в друг, което е прилична работа. DDC е добра идея, но има 2 недостатъка, които ми идват на ум. Първият е, че DDC изисква всички избрани компилатори да имат възпроизводими компилации, което означава, че всеки компилатор винаги генерира точно същия изпълним файл, като се дава един и същ изходен код. Възпроизводимите компилации не са много често срещани, тъй като компилаторите по подразбиране включват неща като времеви отпечатъци и уникални идентификатори в своите компилации. Вторият недостатък е, че DDC става по-малко ефективен за езици, които имат само няколко компилатора. Освен това DDC дори не може да се приложи към по-нови езици като Rust само с един компилатор. В обобщение, DDC не е сребърен куршум и атаката на Томпсън все още се счита за открит проблем.

Така че ще попитам отново: Все още ли вярвате на своя компилатор?

Намерете ме в Twitter и вижте моя личен уебсайт.

Първоначално публикувано на https://www.awelm.com на 18 март 2022 г.