眾所周知,所有運行的進程在操作系統里面分為兩類:一類擁有訪問全部硬件設備的權限(內核空間)而另一類無法直接訪問硬件設備(用戶空間)。
操作系統內核和驅動程序通常是屬于第一類的。
而應用程序通常是屬于第二類的。
舉個例子,Linux kernel運行于內核空間,而Glibc運行于用戶空間。
這種分離對與操作系統的安全性是至關重要的:它最重要的一點是,不給任何進程有破壞到其它進程甚至是系統內核的機會。另一方面,一個錯誤的驅動或系統內核錯誤都會造成系統崩潰或者藍屏。
保護模式下的x86處理器允許使用4個保護等級(ring)。但Linux和Windows兩個操作系統都只使用了兩個:ring0(內核空間)和ring3(用戶空間)。
系統調用(syscall-s)是兩個運行空間的連接點。可以說,這是提供給應用程序主要的API。
在Windows NT,系統調用表存在于SSDT。
通過系統調用實現shellcode在計算機病毒作者之間非常流行。因為很難確定所需函數在系統庫里面的地址,但系統調用很容易確定。然而,由于系統調用屬于比較底層的API,所以需要編寫更多的代碼。最后值得一提的是,在不同的操作系統版本里面,系統調用號是有可能不同的。
在Linux系統中,系統調用通常使用int 0x80中斷進行調用。通過EAX寄存器傳遞調用號,再通過其它寄存器傳遞所需參數。
Listing 66.1: A simple example of the usage of two syscalls
section .text
global _start
_start:
mov edx,len ; buf len
mov ecx,msg ; buf
mov ebx,1 ; file descriptor. stdout is 1
mov eax,4 ; syscall number. sys_write is 4
int 0x80
mov eax,1 ; syscall number. sys_exit is 4
int 0x80
section .data
msg db 'Hello, world!',0xa
len equ $ - msg
編譯:
nasm -f elf32 1.s
ld 1.o
Linux所有的系統調用在這里可以查看:http://go.yurichev.com/17319。
在Linux中可以使用strace(71章)對系統調用進行跟蹤或者攔截。
Windows系統使用int 0x2e中斷或x86下特有的指令SYSENTER調用用系統調用服務。
Windows所有的系統調用在這里可以查看:http://go.yurichev.com/17320。
擴展閱讀:“Windows Syscall Shellcode” by Piotr Bania
在分析Linux共享庫的時候(.so)的時候,可能會經常看到類似下面的代碼:
Listing 67.1: libc-2.17.so x86
.text:0012D5E3 __x86_get_pc_thunk_bx proc near ; CODE XREF: sub_17350+3
.text:0012D5E3 ; sub_173CC+4 ...
.text:0012D5E3 mov ebx, [esp+0]
.text:0012D5E6 retn
.text:0012D5E6 __x86_get_pc_thunk_bx endp
...
.text:000576C0 sub_576C0 proc near ; CODE XREF: tmpfile+73
...
.text:000576C0 push ebp
.text:000576C1 mov ecx, large gs:0
.text:000576C8 push edi
.text:000576C9 push esi
.text:000576CA push ebx
.text:000576CB call __x86_get_pc_thunk_bx
.text:000576D0 add ebx, 157930h
.text:000576D6 sub esp, 9Ch
...
.text:000579F0 lea eax, (a__gen_tempname - 1AF000h)[ebx] ; "__gen_tempname"
.text:000579F6 mov [esp+0ACh+var_A0], eax
.text:000579FA lea eax, (a__SysdepsPosix - 1AF000h)[ebx] ; "../sysdeps/posix/tempname.c"
.text:00057A00 mov [esp+0ACh+var_A8], eax
.text:00057A04 lea eax, (aInvalidKindIn_ - 1AF000h)[ebx] ; "! \"invalid KIND in __gen_tempname\""
.text:00057A0A mov [esp+0ACh+var_A4], 14Ah
.text:00057A12 mov [esp+0ACh+var_AC], eax
.text:00057A15 call __assert_fail
在每個函數開始處,所有指向字符串的指針都需要通過EBX和一些常量值來修正地址。這就是所謂的PIC(位置無關代碼),它的目的是讓這段代碼即使隨機地放在內存中某個位置都能正確地執行。這也是為什么不能使用絕對地址的原因。
PIC(位置無關代碼)對于早期的操作系統和現在那些沒有虛擬內存支持的嵌入式系統來說至關重要(所有進程都放在同一個連續的內存塊)。此外,它還用于*NIX系統的共享庫。這樣共享庫只需要加載一次到內存之后就可以讓所有需要的進程使用,而且這些進程可以把同一個共享庫映射到各自不同的內存地址上。這也是為什么共享庫不使用絕對地址也能夠正常地工作。
讓我們做一個簡單的實驗:
#!c
#include <stdio.h>
int global_variable=123;
int f1(int var)
{
int rt=global_variable+var;
printf ("returning %d\n", rt);
return rt;
};
用GCC 4.7.3編譯它并用IDA查看.so文件的反匯編代碼:
#!bash
gcc -fPIC -shared -O3 -o 1.so 1.c
.text:00000440 public __x86_get_pc_thunk_bx
.text:00000440 __x86_get_pc_thunk_bx proc near ; CODE XREF: _init_proc+4
.text:00000440 ; deregister_tm_clones+4 ...
.text:00000440 mov ebx, [esp+0]
.text:00000443 retn
.text:00000443 __x86_get_pc_thunk_bx endp
.text:00000570 public f1
.text:00000570 f1 proc near
.text:00000570
.text:00000570 var_1C = dword ptr -1Ch
.text:00000570 var_18 = dword ptr -18h
.text:00000570 var_14 = dword ptr -14h
.text:00000570 var_8 = dword ptr -8
.text:00000570 var_4 = dword ptr -4
.text:00000570 arg_0 = dword ptr 4
.text:00000570
.text:00000570 sub esp, 1Ch
.text:00000573 mov [esp+1Ch+var_8], ebx
.text:00000577 call __x86_get_pc_thunk_bx
.text:0000057C add ebx, 1A84h
.text:00000582 mov [esp+1Ch+var_4], esi
.text:00000586 mov eax, ds:(global_variable_ptr - 2000h)[ebx]
.text:0000058C mov esi, [eax]
.text:0000058E lea eax, (aReturningD - 2000h)[ebx] ; "returning %d\n"
.text:00000594 add esi, [esp+1Ch+arg_0]
.text:00000598 mov [esp+1Ch+var_18], eax
.text:0000059C mov [esp+1Ch+var_1C], 1
.text:000005A3 mov [esp+1Ch+var_14], esi
.text:000005A7 call ___printf_chk
.text:000005AC mov eax, esi
.text:000005AE mov ebx, [esp+1Ch+var_8]
.text:000005B2 mov esi, [esp+1Ch+var_4]
.text:000005B6 add esp, 1Ch
.text:000005B9 retn
.text:000005B9 f1 endp
如上所示:每個函數執行時都會矯正“returning %d\n”和global_variable的地址。__x86_get_pc_thunk_bx()函數通過EBX返回一個指向自身的指針(返回的是0x57C)。這是一種獲取程序計數器(EIP)的簡單方法。0x1A84常量是這個函數開始處到(Global Offset Table Procedure Linkage Table(GOT PLT))它們之間的距離差。IDA會把這些偏移處理成更容易理解后再顯示出來,所以實際上的代碼是:
.text:00000577 call __x86_get_pc_thunk_bx
.text:0000057C add ebx, 1A84h
.text:00000582 mov [esp+1Ch+var_4], esi
.text:00000586 mov eax, [ebx-0Ch]
.text:0000058C mov esi, [eax]
.text:0000058E lea eax, [ebx-1A30h]
這里的EBX指向了GOT PLT section。當計算global_variable(存儲在GOT)的地址時須減去0x0C偏移量。當計算"returning %d\n"字符串的地址時須減去0x1A30偏移量。
順便說一下,AMD64的指令支持使用RIP用于相對尋址,這使得它可以產生出更簡潔的PIC代碼。
讓我們用相同的GCC編譯器編譯相同的C代碼,但使用x64平臺。
IDA會簡化了反匯編代碼,造成我們無法看到使用RIP相對尋址的細節,所以我在這里使用了objdump來查看反匯編代碼:
0000000000000720 <f1>:
720: 48 8b 05 b9 08 20 00 mov rax,QWORD PTR [rip+0x2008b9] # 200fe0 <_DYNAMIC+0x1d0>
727: 53 push rbx
728: 89 fb mov ebx,edi
72a: 48 8d 35 20 00 00 00 lea rsi,[rip+0x20] #751 <_fini+0x9>
731: bf 01 00 00 00 mov edi,0x1
736: 03 18 add ebx,DWORD PTR [rax]
738: 31 c0 xor eax,eax
73a: 89 da mov edx,ebx
73c: e8 df fe ff ff call 620 <[email protected]>
741: 89 d8 mov eax,ebx
743: 5b pop rbx
744: c3 ret
0x2008b9是0x720處指令地址到global_variable地址的差,0x20是0x72a處指令地址到"returning %d\n"字符串地址的差。
你可能會看到,頻繁重新計算地址會導致執行效率變差(雖然在x64會更好)。所以如果你比較關心性能的話最好還是使用靜態鏈接。
Windows的DLL并沒有使用PIC機制。如果Windows加載器需加載DLL到另外一個基地址,它需要把DLL在內存中的“重定位段”(在固定的位置)里所有地址都調整為正確的。這意味著多個Windows進程不能在不同進程內存塊的不同地址共享一份DLL,因為每個實例加載在內存后只固定在這些地址工作。
Linux允許讓我們自己的動態鏈接庫加載在其它動態鏈接庫之前,甚至是系統庫(如 libc.so.6)。
反過來想,也就是允許我們用自己寫的函數去“代替”系統庫的函數。舉個例子,我們可以很容易地攔截掉time(),read(),write()等等這些函數。
來瞧瞧我們是如何愚弄uptime這個程序的。我們知道,該程序顯示計算機已經工作了多長時間。借助strace的幫助可以看到,該程序通過/proc/uptime文件獲取到計算機的工作時長。
#!c
$ strace uptime
...
open("/proc/uptime", O_RDONLY) = 3
lseek(3, 0, SEEK_SET) = 0
read(3, "416166.86 414629.38\n", 2047) = 20
...
/proc/uptime并不是存放在磁盤的真實文件。而是由Linux Kernel產生的一個虛擬的文件。它有兩個數值:
#!bash
$ cat /proc/uptime
416690.91 415152.03
我們可以用wikipedia來看一下它的含義:
第一個數值是系統運行總時長,第二個數值是系統空閑的時間。都以秒為單位表示。
我們來寫一個含open(),read(),close()函數的動態鏈接庫。
首先,我們的open()函數會比較一下文件名是不是我們所想要打開的,如果是,則將文件描述符記錄下來。然后,read()函數會判斷如果我們調用的是不是我們所保存的文件描述符,如果是則代替它輸出,否則調用libc.so.6里面原來的函數。最后,close()函數會關閉我們所保存的文件描述符。
在這里我們借助了dlopen()和dlsym()函數來確定原先在libc.so.6的函數的地址,因為我們需要控制“真實”的函數。
題外話,如果我們的程序想劫持strcmp()函數來監控每個字符串的比較,則需要我們自己實現一個strcmp()函數而不能用原先的函數。
#!c
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#include <stdbool.h>
#include <unistd.h>
#include <dlfcn.h>
#include <string.h>
void *libc_handle = NULL;
int (*open_ptr)(const char *, int) = NULL;
int (*close_ptr)(int) = NULL;
ssize_t (*read_ptr)(int, void*, size_t) = NULL;
bool inited = false;
_Noreturn void die (const char * fmt, ...)
{
va_list va;
va_start (va, fmt);
vprintf (fmt, va);
exit(0);
};
static void find_original_functions ()
{
if (inited)
return;
libc_handle = dlopen ("libc.so.6", RTLD_LAZY);
if (libc_handle==NULL)
die ("can't open libc.so.6\n");
open_ptr = dlsym (libc_handle, "open");
if (open_ptr==NULL)
die ("can't find open()\n");
close_ptr = dlsym (libc_handle, "close");
if (close_ptr==NULL)
die ("can't find close()\n");
read_ptr = dlsym (libc_handle, "read");
if (read_ptr==NULL)
die ("can't find read()\n");
inited = true;
}
static int opened_fd=0;
int open(const char *pathname, int flags)
{
find_original_functions();
int fd=(*open_ptr)(pathname, flags);
if (strcmp(pathname, "/proc/uptime")==0)
opened_fd=fd; // that's our file! record its file descriptor
else
opened_fd=0;
return fd;
};
int close(int fd)
{
find_original_functions();
if (fd==opened_fd)
opened_fd=0; // the file is not opened anymore
return (*close_ptr)(fd);
};
ssize_t read(int fd, void *buf, size_t count)
{
find_original_functions();
if (opened_fd!=0 && fd==opened_fd)
{
// that's our file!
return snprintf (buf, count, "%d %d", 0x7fffffff, 0x7fffffff)+1;
};
// not our file, go to real read() function
return (*read_ptr)(fd, buf, count);
};
把它編譯成動態鏈接庫:
gcc -fpic -shared -Wall -o fool_uptime.so fool_uptime.c -ldl
運行uptime,并讓它在加載其它庫之前加載我們的庫:
LD_PRELOAD=`pwd`/fool_uptime.so uptime
可以看到:
01:23:02 up 24855 days, 3:14, 3 users, load average: 0.00, 0.01, 0.05
如果LD_PRELOAD環境變量一直指向我們的動態鏈接庫文件名,其它程序在啟動的時候也會加載我們的動態鏈接庫。
更多的例子請看: