在Linux中使用C語言實現(xiàn)控制流保護(CFG)
一、前言
最近版本的Windows有一個新的緩解措施叫做控制流保護(CFG)。在一個非直接調(diào)用之前——例如,函數(shù)指針和虛函數(shù)——針對有效調(diào)用地址的表檢查目標地址。如果地址不是一個已知函數(shù)的入口,程序?qū)K止運行。
如果一個程序有一個緩沖區(qū)溢出漏洞,攻擊者可以利用它覆蓋一個函數(shù)地址,并且通過調(diào)用那個指針來控制程序執(zhí)行流。這是ROP攻擊的一種方法,攻擊者構建一系列配件地址鏈,一個配件是一組包含ret指令的指令序列,這些指令都是原始程序中的,可以用來作為非直接調(diào)用的起點。執(zhí)行過程會從一個配件跳到另一個配件中以便做攻擊者想做的事,卻不需要攻擊這提供任何代碼。
兩種非常廣的緩解ROP攻擊的技術是地址空間布局隨機化(ALSR)和棧保護。前者是隨機化模塊的加載基址以便達到不可預料的結果。在ROP攻擊中的地址依賴實時內(nèi)存布局,因此攻擊者必須找到并利用信息泄漏來繞過ASLR。
關于棧保護,編譯器在其他棧分配之上分配一個值,并設置為每個線程的隨機值。如果過緩沖區(qū)溢出覆蓋了函數(shù)返回地址,這個值將也被覆蓋。在函數(shù)返回前,將校驗這個值。如果不能與已知值匹配,程序?qū)⒔K止運行。
CFG原理類似,在將控制傳送到指針地址前做一個校驗,只是不是校驗一個值,而是校驗目標地址本身。這個非常復雜,不像棧保護,需要平臺協(xié)調(diào)。這個校驗必須在所有的可靠的調(diào)用目標中被通知,不管是來自主程序還是動態(tài)庫。
雖然沒有廣泛部署,但是值得一提的是Clang’s SafeStack。每個線程有兩個棧:一個“安全棧”用來保存返回指針和其他可安全訪問的值,另一個“非安全棧”保存buffer之類的數(shù)據(jù)。緩沖區(qū)溢出將破環(huán)其他緩沖區(qū),但是不會覆蓋返回地址,這樣限制了破環(huán)的影響。
二、利用例子
使用一個小的C程序,demo.c:
- int
- main(void)
- {
- char name[8];
- gets(name);
- printf("Hello, %s.\n", name);
- return 0;
- }
它讀取一個名字存到緩沖區(qū)中,并且以換行結尾打印出來。麻雀雖小五臟俱全。原生調(diào)用gets()不會校驗緩沖區(qū)的邊界,可以用來緩沖區(qū)溢出漏洞利用。很明顯編譯器和鏈接器都會拋出警告。
簡單起見,假設程序包含危險函數(shù)。
- void
- self_destruct(void)
- {
- puts("**** GO BOOM! ****");
- }
攻擊者用緩沖區(qū)溢出來調(diào)用這個危險函數(shù)。
為了使攻擊簡單,假設程序不使用ASLR(例如,在GCC/Clang中不使用-fpie和-pie編譯選項)。首先,找到self_destruct()函數(shù)地址。
- $ readelf -a demo | grep self_destruct
- 46: 00000000004005c5 10 FUNC GLOBAL DEFAULT 13 self_destruct
因為在64位系統(tǒng)上面,所以是64位的地址。Name緩沖區(qū)的大小事8字節(jié),在匯編我看到一個額外的8字節(jié)分配上面,所以有16個字節(jié)填充,然后8字節(jié)覆蓋self_destruct的返回指針。
- $ echo -ne 'xxxxxxxxyyyyyyyy\xc5\x05\x40\x00\x00\x00\x00\x00' > boom
- $ ./demo < boom
- Hello, xxxxxxxxyyyyyyyy?@.
- **** GO BOOM! ****
- Segmentation fault
使用這個輸入我已經(jīng)成功利用了緩沖區(qū)溢出來控制了執(zhí)行。當main試圖回到libc時,它將會跳轉(zhuǎn)到威脅代碼,然后崩潰。打開堆棧保護可以阻止這種利用。
- $ gcc -Os -fstack-protector -o demo demo.c
- $ ./demo < boom
- Hello, xxxxxxxxaaaaaaaa?@.
- *** stack smashing detected ***: ./demo terminated
- ======= Backtrace: =========
- ... lots of backtrace stuff ...
棧保護成功阻止了利用。為了繞過過這個,我將不得不猜canary值或者發(fā)現(xiàn)可以利用的信息泄漏。
棧保護轉(zhuǎn)化為程序看起來就是如下這樣:
- int
- main(void)
- {
- long __canary = __get_thread_canary();
- char name[8];
- gets(name);
- printf("Hello, %s.\n", name);
- if (__canary != __get_thread_canary())
- abort();
- return 0;
- }
然而,實際上不可能在C中實現(xiàn)堆棧保護,緩沖區(qū)溢出是不確定行為,并且canary僅對緩沖區(qū)溢出有效,還允許編譯器優(yōu)化它。
三、函數(shù)指針和虛函數(shù)
在攻擊者成功上述利用后,上層管理加入了密碼保護措施??雌饋砣缦拢?/p>
- void
- self_destruct(char *password)
- {
- if (strcmp(password, "12345") == 0)
- puts("**** GO BOOM! ****");
- }
這個密碼是硬編碼的,它是比較愚蠢,但是假設它不為攻擊者所知。上層管理已經(jīng)要求堆棧保護,因此假設已經(jīng)開啟。
另外,程序也做一點改變,現(xiàn)在用一個函數(shù)指針實現(xiàn)多態(tài)。
- struct greeter {
- char name[8];
- void (*greet)(struct greeter *);
- };
- void
- greet_hello(struct greeter *g)
- {
- printf("Hello, %s.\n", g->name);
- }
- void
- greet_aloha(struct greeter *g)
- {
- printf("Aloha, %s.\n", g->name);
- }
現(xiàn)在有一個greeter對象和函數(shù)指針來實現(xiàn)運行時多態(tài)。把他想想為手寫的C的虛函數(shù)。下面是新的main函數(shù):
- int
- main(void)
- {
- struct greeter greeter = {.greet = greet_hello};
- gets(greeter.name);
- greeter.greet(&greeter);
- return 0;
- }
(在真實的程序中,其他東西會提供greeter并挑選它自己的函數(shù)指針)
而不是覆蓋返回指針,攻擊者有機會覆蓋結構中的函數(shù)指針。讓我們重新像之前一樣利用。
- $ readelf -a demo | grep self_destruct
- 54: 00000000004006a5 10 FUNC GLOBAL DEFAULT 13 self_destruct
我們不知道密碼,但是我們確實知道密碼校驗是16字節(jié)。攻擊應該跳過16字節(jié),即跳過校驗(0x4006a5+16=0x4006b5)。
- $ echo -ne 'xxxxxxxx\xb5\x06\x40\x00\x00\x00\x00\x00' > boom
- $ ./demo < boom
- **** GO BOOM! ****
不管堆棧保護還是密碼保護都么有幫助。堆棧保護僅僅保護返回指針,而不保護結構中的函數(shù)指針。
這就是CFG起作用的地方。開啟了CFG,編譯器會在調(diào)用greet()之前插入一個校驗。它必須指向一個已知函數(shù)的開頭,否則將想堆棧保護一樣終止程序運行。因為self_destruct()不是函數(shù)的開頭,但是利用后程序還是會終止。
然而,linux還沒有CFG機制。因此我打算自己實現(xiàn)它。
四、函數(shù)地址表
正如文中頂端PDF鏈接中描述的,Windows上面的CFG使用bitmap實現(xiàn)。每個位代表8字節(jié)內(nèi)存。如果過8字節(jié)包含了函數(shù)開頭,這個位設置為1。校驗一個指針意味著校驗在bitmap中它關聯(lián)的位。
關于我的CFG,我決定保持相同的8字節(jié)解決方案:目標地址的低3位將舍棄。其余24位用來作為bitmap的索引。所有指針中的其他位被忽略。24位的索引意味著bitmap最大只能是2MB。
24位對于32位系統(tǒng)已經(jīng)足夠了,但是在64位系統(tǒng)上面是不夠的:一些地址不能代表函數(shù)的開頭,但是設置他們的位為1.這是可以接受的,尤其是只有已知函數(shù)作為非直接調(diào)用的目標,降低了不利因素。
注意:根據(jù)指針轉(zhuǎn)化為整數(shù)的位是未指定的且不可移植,但是這個實現(xiàn)不管在哪里都能工作良好。
下面是CFG的參數(shù)。我將他們封裝為宏以便編譯是方便。這個cfg_bits是支持bitmap數(shù)組的整數(shù)類型。CFG_RESOLUTION是舍棄的位數(shù),一次“3”是8字節(jié)的一個粒度。
- typedef unsigned long cfg_bits;
- #define CFG_RESOLUTION 3
- #define CFG_BITS 24
給一個函數(shù)指針f,下面的宏導出bitmap的索引。
- #define CFG_INDEX(f) \
- (((uintptr_t)f >> CFG_RESOLUTION) & ((1UL << CFG_BITS) - 1))
CFG bitmap只是一個整形數(shù)組。初始值為0。
- struct cfg {
- cfg_bits bitmap[(1UL << CFG_BITS) / (sizeof(cfg_bits) * CHAR_BIT)];
- };
使用cfg_register()在bitmap中手動注冊函數(shù)。
- void
- cfg_register(struct cfg *cfg, void *f)
- {
- unsigned long i = CFG_INDEX(f);
- size_t z = sizeof(cfg_bits) * CHAR_BIT;
- cfg->bitmap[i / z] |= 1UL << (i % z);
- }
因為在運行時注冊函數(shù),需要與ASLR一致。如果ASLR開啟了,bitmap每次運行都會不同。將bitmap的每個元素與一個隨機數(shù)異或是值得的,加大攻擊者的難度。在完成注冊后,bitmap也需要調(diào)整為只讀權限(mprotect())。
最后,校驗函數(shù)被用于非直接調(diào)用之前。它確保了f先被傳遞給cfg_register()。因為它調(diào)用頻繁,所以需要盡量快和簡單。
- void
- cfg_check(struct cfg *cfg, void *f)
- {
- unsigned long i = CFG_INDEX(f);
- size_t z = sizeof(cfg_bits) * CHAR_BIT;
- if (!((cfg->bitmap[i / z] >> (i % z)) & 1))
- abort();
- }
完成了,現(xiàn)在在main中使用它:
- struct cfg cfg;
- int
- main(void)
- {
- cfg_register(&cfg, self_destruct); // to prove this works
- cfg_register(&cfg, greet_hello);
- cfg_register(&cfg, greet_aloha);
- struct greeter greeter = {.greet = greet_hello};
- gets(greeter.name);
- cfg_check(&cfg, greeter.greet);
- greeter.greet(&greeter);
- return 0;
- }
現(xiàn)在再次利用:
- $ ./demo < boom
- Aborted
正常情況下self_destruct()不會被注冊,因為它不是一個非直接調(diào)用的合法目標,但是利用依然不能起作用是因為它在self_destruct()中間被調(diào)用,在bitmap中它不是一個可靠的地址。校驗將在利用前終止程序。
在真實的應用程序中,我將使用一個全局的CFG bitmap,在頭文件中使用inline函數(shù)定義cfg_check()。
盡管不使用工具直接在C中實現(xiàn)是可能的,但是這將變得更加繁瑣和意出錯。正確的是該在編譯器中實現(xiàn)CFG。