天天看點

記一個linux核心記憶體提權問題

前些天,linux核心曝出了一個記憶體提權漏洞。通過駭客的精心構造,suid程式将print的輸出資訊寫到了自己的/proc/$pid/mem檔案裡面,進而修改了自己的可執行代碼,為普通使用者開啟了一個帶root權限的shell。這個過程還是挺有意思的,不得不佩服駭客們的聰明才智,故在此分享一下,以表崇敬之情。

首先,破解過程使用到了suid程式。suid并不是一個程式,而是可執行檔案的一種屬性。當你執行一個帶有suid屬性的程式時,在執行suid程式期間,你啟動的程序的user将被臨時改為suid程式的owner,程序将擁有程式owner所擁有的權限。這一特性經常用于讓普通使用者臨時獲得root權限。

在linux系統中,很多功能是需要root權限才能使用的。使用者如果想要用到這些功能,可以有兩個辦法:一是使用root使用者登入。但是很可能你沒有root密碼。就算有,這樣做也不安全(誤操作是緻命的);二是執行一個owner是root的suid程式。這樣就可以在不使用root使用者登入的情況下,被允許使用一些需要root權限的功能,既友善又安全。

比如我們經常用到的ping指令,它就是這樣的一個suid程式。

$ ll /bin/ping

-rwsr-xr-x 1 root root 37312 aug 6 2008 /bin/ping

(注意s屬性,這就是suid标志。)

ping使用了icmp協定,通過發送icmp封包來探測網絡的連通性。但是因為icmp是ip層協定,其行為代表的是整個機器(而不是像傳輸層協定那樣,代表機器上的某個應用),故隻有root使用者才能建立icmp封包。而我們之是以不使用root使用者登入也可以執行ping指令,其原因正是suid。

那麼,怎麼保證suid程式提供的root權限不被濫用呢?換句話說,通過suid得到root權限跟使用root使用者登入有什麼不同呢?一般來說,suid程式都是封閉的,隻幹某件事情(并且幹的事情都确定不會有危害),幹完就退出(不會節外生枝)。再以ping為例,普通使用者可以執行ping指令來發送icmp封包。但是ping指令隻會發送網絡探測相關的icmp封包,普通使用者無法利用它來建立任意的icmp封包,更不可能利用它來完成其他需要root權限才能做的事情,比如删除使用者。

能不能通過修改suid程式,使其去做一些越權的事情呢?比如修改ping程式,使其能夠删除使用者?這也是不可以的。suid程式跟其他檔案一樣,受通路權限的保護,一般隻有其owner才能有權限修改它,其他使用者隻能讀或者執行。

不過,在這次的破解中,駭客卻真的修改了suid程式。怎麼辦到的呢?利用/proc/$pid/mem。

proc檔案/proc/$pid/mem是$pid程序的一份記憶體鏡像,能夠通過它來讀寫到程序的所有記憶體,包括可執行代碼(它們已經映射到記憶體中)。在2.6.39版本以前,這份記憶體鏡像是不可寫的,不過後來這個限制被取消了。當然,對/proc/$pid/mem檔案的操作也并不是任意的,如果是$pid程序自己寫自己的/proc/$pid/mem檔案,那麼可以允許;如果是調試程序寫被調試的程序,也允許。其他情況就不行了。

而駭客的想法是:當我們執行suid程式的時候,它不是會有些輸出麼?對于有些程式,它輸出的内容正好會包含你傳遞給它的參數。于是,如果我們将suid程式的stderr(或stdout)重定向到/proc/$pid/mem,它在輸出資訊的時候不就會将你輸入的資訊改寫到自己的記憶體裡去了麼!

比如駭客利用的su指令:

$ ll /bin/su

-rwsr-xr-x 1 root root 28336 oct 31 2008 /bin/su

$ su hahahaha

su: user hahahaha does not exist

輸入參數"hahahaha"是一個不存在的使用者,su指令會通過stderr輸出錯誤資訊,并且資訊裡面就包含我們的輸入參數"hahahaha"。如果輸入參數是一段二進制代碼,那麼它同樣也會出現在輸出資訊中!

然後,跟其他可執行程式一樣,su的輸出是可以重定向的,比如:

$ su hahahaha 2> ttt

$ cat ttt

那麼,如果将輸出重定向到執行su的程序自己的/proc/$pid/mem呢,不就可以達到修改可執行代碼的目的了麼!

比如這樣:

$ su hahahaha 2> /proc/self/mem

不過現在還有兩個問題要解決……

第一個還是權限問題。現在已經讓執行su的程序自己修改自己的/proc/$pid/mem,不過還不夠。再來看看具體還有哪些權限檢查。

1、open操作:

static int mem_open(struct inode* inode, struct file* file)

{

file->private_data = (void*)((long)current->self_exec_id);

......

}

沒有權限檢查,但是會将current->self_exec_id記錄下來,後面會對其做校驗。

2、write操作:

static ssize_t mem_write(struct file * file, const char __user *buf,size_t count, loff_t *ppos)

struct task_struct *task = get_proc_task(file->f_path.dentry->d_inode);

mm = check_mem_permission(task);

copied = ptr_err(mm);

if (is_err(mm))

goto out_free;

if (file->private_data != (void *)((long)current->self_exec_id))

goto out_mm;

有兩處檢查,一是通過check_mem_permission()檢查目前程序是否可以操作該檔案,這就是前面所提到的,隻會允許本程序或者調試程序的操作。現在這一關已經過了。

另一處檢查是對self_exec_id的檢查,要求程序在對/proc/$pid/mem進行open()和write()的時候擁有相同的self_exec_id(注意,前面在open的時候已經把當時的self_exec_id記錄在了file->private_data中)。另一方面,每當一個程序調用exec()來執行程式時,程序的self_exec_id會自增:

void setup_new_exec(struct linux_binprm * bprm)

current->self_exec_id++;

而我們之前的那句shell指令(su hahahaha 2> /proc/self/mem)大緻是這樣實作的:

int fd = open("/proc/self/mem", o_wronly);

dup2(fd, 2);

close(fd);

execve("/bin/su", {"su", "hahahaha"}, {...});

注意,雖然suid程式的錯誤輸出被重定向到了/proc/self/mem,但是由于open()和write()分别發生于execve()的之前和之後,兩次的self_exec_id是不同的,是以write()操作無法通過權限檢查……核心正是利用self_exec_id來確定/proc/$pid/mem是程式自己打開的,而不是在程式執行之前就被打開的。

不過這裡的檢查不夠健壯,還是有辦法突破的。一個辦法是一個勁地exec(),直到self_exec_id溢出。不過這樣搞就是耗時太長。

另外一個辦法是:

1、fork()一下,生成的子程序會擁有跟父程序相同的self_exec_id;

2、在子程序中exec()一下,執行一個自己寫的程式,并在程式中open()父程序的/proc/$pid/mem(注意open的時候沒有權限檢查,是以能夠open成功);

3、通過諸如unix socket的方法,将子程序中打開的fd傳回給父程序(沒想到unix socket還有這麼一招吧~ man一下cmsg,看看關于scm_rights的内容);

4、由于子程序是exec()之後再open()的,記錄在file中的self_exec_id會自增一次。是以父程序exec()執行suid程式之後,write()時的self_exec_id剛好就跟open()時一樣了;

ok!之前提到的兩個問題,第一個權限問題已經解決了,現在我們已經能讓suid程式在自己的記憶體空間中寫一些我們想要的可執行代碼。第二個問題,這些可執行代碼該寫到什麼地方去?随便亂寫顯然是沒有意義的。

首先,我們能控制suid程式寫檔案的位置嗎?

可以!比如這樣:

lseek64(fd, pos_what_we_want, seek_set);

然後su就會順着我們lseek64()設定的位置開始寫。

其次,應該選擇哪個位置呢?有兩個條件:

1、在我們期望的write()之後的必經之路上;

2、程式流程是跳轉到這個位置來的,而不是順序執行下來的。因為像su的輸出那樣("su: user hahahaha does not exist"),在我們輸入的内容前面會有一些其他的資訊("su: user "),這些資訊肯定會把可執行代碼寫壞的,唯一的辦法就是讓程式流程不要執行到它們,而是直接跳轉到我們的輸入上;

比如駭客選擇了exit()函數的入口,就能滿足以上兩個條件:程式最後都會調用libc庫函數exit()來退出、而作為函數的入口點,程式流程是通過call指令跳轉過來的。而lseek64()所需要指定的位置,就是exit()入口點減去strlen("su: user ")的位置。

再次,怎麼找到exit()的入口點呢?

最簡單的辦法就是objdump,如:

$ objdump -d /bin/su | awk '$2 == "<exit@plt>:"{print}'

0000000000001c90 <exit@plt>:

還有一點就是要求suid程式被載入記憶體的時候位置是不能随機的,否則objdump看到的exit()入口點就不是運作時真正的入口點(并且每次運作的入口點都還可能不一樣)。

通過"readelf -h $bin"檢視輸出的"type"字段可以知道可執行檔案$bin是否是按位置無關來編譯的(dyn表示位置無關、exec則相反)。如果不是位置無關,那麼objdump看到的位址就是運作時的位址。駭客使用了su程式來進行破解,正是因為在他的系統上,/bin/su的type是exec。

在别的系統上這個未必成立,比如我的系統:

$ readelf -h /bin/su | grep type

type: dyn (shared object file)

在我的系統中,可以選用umount來進行破解,也是同樣的道理。

$ ll /bin/umount

-rwsr-xr-x 1 root root 40208 nov 26 2008 /bin/umount

$ umount hahahaha

umount: hahahaha is not mounted (according to mtab)

$ readelf -h /bin/umount | grep type

type: exec (executable file)

最後,就是要在exit()的入口點寫什麼内容的問題了。很簡單,寫一段代碼,使用execve()系統調用運作一個shell就行了。suid程式已經帶來了root權限,以後想幹什麼都交給這個shell吧~

不過注意,這裡要寫的不是c代碼、不是彙編代碼、而是二進制的機器代碼。

駭客的原文見:http://blog.zx2c4.com/749,裡面包含了破解代碼的連結。還有,linus的更新檔也已經出來了,在駭客的那篇文章中也能找到連結。如果本文所讨論的問題你都已經了解了,駭客的blog原文對于你來說也就不會有難度。

繼續閱讀