Linux系統中的init進程(pid=1)是除了idle進程(pid=0,也就是init_task)之外另一個比較特殊的進程,它是Linux內核開始建立起進程概念時第一個通過kernel_thread產生的進程,其開始在內核態執行,然后通過一個系統調用,開始執行用戶空間的/sbin/init程序,期間Linux內核也經歷了從內核態到用戶態的特權級轉變,/sbin/init極有可能產生出了shell,然后所有的用戶進程都有該進程派生出來(目前尚未閱讀過/sbin/init的源碼)...
目前我們至少知道在內核空間執行用戶空間的一段應用程序有兩種方法:
1. call_usermodehelper
2. kernel_execve
它們最終都通過int $0x80在內核空間發起一個系統調用來完成,這個過程我在《深入Linux設備驅動程序內核機制》第9章有過詳細的描述,對它的討論最終結束在 sys_execve函數那里,后者被用來執行一個新的程序。現在一個有趣的問題是,在內核空間發起的系統調用,最終通過sys_execve來執行用戶 空間的一個程序,比如/sbin/myhotplug,那么該應用程序執行時是在內核態呢還是用戶態呢?直覺上肯定是用戶態,不過因為cpu在執行 sys_execve時cs寄存器還是__KERNEL_CS,如果前面我們的猜測是真的話,必然會有個cs寄存器的值從__KERNEL_CS到 __USER_CS的轉變過程,這個過程是如何發生的呢?下面我以kernel_execve為例,來具體討論一下其間所發生的一些有趣的事情。?
start_kernel在其最后一個函數rest_init的調用中,會通過kernel_thread來生成一個內核進程,后者則會在新進程環境下調 用kernel_init函數,kernel_init一個讓人感興趣的地方在于它會調用run_init_process來執行根文件系統下的 /sbin/init等程序:?
?
static noinline?int?init_post(void)
{
...
run_init_process("/sbin/init");
run_init_process("/etc/init");
run_init_process("/bin/init");
run_init_process("/bin/sh");
panic("No init found. Try passing init= option to kernel. "
"See Linux Documentation/init.txt for guidance.");
}
run_init_process的核心調用就是kernel_execve,后者的實現代碼是:
int?kernel_execve(const?char?*filename,
const?char?*const?argv[],
const?char?*const?envp[])
{
long __res;
asm volatile?("int $0x80"
:?"=a"?(__res)
:?"0"?(__NR_execve),?"b"?(filename),?"c"?(argv),?"d"?(envp)?:?"memory");
return __res;
}
里面是段內嵌的匯編代碼,代碼相對比較簡單,核心代碼是int $0x80,執行系統調用,系統調用號__NR_execve放在AX里,當然系統調用的返回值也是在AX中,要執行的用戶空間應用程序路徑名稱保存在 BX中。int $0x80的執行導致代碼向__KERNEL_CS:system_call轉移(具體過程可參考x86處理器中的特權級檢查及Linux系統調用的實現一帖). 此處用bx,cx以及dx來保存filename, argv以及envp參數是有講究的,它對應著struct pt_regs中寄存器在棧中的布局,因為接下來就會涉及從匯編到調用C函數過程,所以匯編程序在調用C之前,應該把要傳遞給C的參數在棧中準備好。
system_call是一段純匯編代碼:
ENTRY(system_call)
RING0_INT_FRAME # can't unwind into user?space?anyway
pushl_cfi?%eax # save orig_eax
SAVE_ALL
GET_THREAD_INFO(%ebp)
# system?call?tracing?in?operation?/?emulation
testl $_TIF_WORK_SYSCALL_ENTRY,TI_flags(%ebp)
jnz syscall_trace_entry
cmpl $(nr_syscalls),?%eax
jae syscall_badsys
syscall_call:
call?*sys_call_table(,%eax,4)
movl?%eax,PT_EAX(%esp)?# store the return value
syscall_exit:
...
restore_nocheck:
RESTORE_REGS 4 # skip orig_eax/error_code
irq_return:
INTERRUPT_RETURN #iret instruction?for?x86_32
system_call首先會為后續的C函數的調用在當前堆棧中建立參數傳遞的環境(x86_64的實現要相對復雜一點,它會將系統調用切換到內核棧 movq PER_CPU_VAR(kernel_stack),%rsp),尤其是接下來對C函數sys_execve調用中的struct pt_regs *regs參數,我在上面代碼中同時列出了系統調用之后的后續操作syscall_exit,從代碼中可以看到系統調用int $0x80最終通過iret指令返回,而后者會從當前棧中彈出cs與ip,然后跳轉到cs:ip處執行代碼。正常情況下,x86架構上的int?n指 令會將其下條指令的cs:ip壓入堆棧,所以當通過iret指令返回時,原來的代碼將從int?n的下條指令繼續執行,不過如果我們能在后續的C代碼中改變regs->cs與regs->ip(也就是int?n執行時壓入棧中的cs與ip),那么就可以控制下一步代碼執行的走向,而 sys_execve函數的調用鏈正好利用了這一點,接下來我們很快就會看到。SAVE_ALL宏的最后為將ds, es, fs都設置為__USER_DS,但是此時cs還是__KERNEL_CS.
核心的調用發生在call *sys_call_table(,%eax,4)這條指令上,sys_call_table是個系統調用表,本質上就是一個函數指針數組,我們這里的系 統調用號是__NR_execve=11, 所以在sys_call_table中對應的函數為:?
?
ENTRY(sys_call_table)
.long sys_restart_syscall?/*?0?-?old?"setup()"?system?call,?used?for?restarting?*/
.long sys_exit
.long ptregs_fork
.long sys_read
.long sys_write
.long sys_open?/*?5?*/
.long sys_close
...
.long sys_unlink?/*?10?*/
.long ptregs_execve?//__NR_execve
...
ptregs_execve其實就是sys_execve函數:
#define ptregs_execve sys_execve
而sys_execve函數的代碼實現則是:
/*
*?sys_execve()?executes a new program.
*/
long sys_execve(const?char __user?*name,
const?char __user?*const?__user?*argv,
const?char __user?*const?__user?*envp,?struct pt_regs?*regs)
{
long?error;
char?*filename;
filename?=?getname(name);
error?=?PTR_ERR(filename);
if?(IS_ERR(filename))
return?error;
error?=?do_execve(filename,?argv,?envp,?regs);
#ifdef CONFIG_X86_32
if?(error?==?0)?{
/*?Make sure we don't return using sysenter..?*/
set_thread_flag(TIF_IRET);
}
#endif
putname(filename);
return?error;
}
注意這里的參數傳遞機制!其中的核心調用是do_execve,后者調用do_execve_common來干執行一個新程序的活,在我們這個例子中要執 行的新程序來自/sbin/init,如果用file命令看一下會發現它其實是個ELF格式的動態鏈接庫,而不是那種普通的可執行文件,所以 do_execve_common會負責打開、解析這個文件并找到其可執行入口點,這個過程相當繁瑣,我們不妨直接看那些跟我們問題密切相關的代 碼,do_execve_common會調用search_binary_handler去查找所謂的binary formats handler,ELF顯然是最常見的一種格式:
?
int?search_binary_handler(struct linux_binprm?*bprm,struct pt_regs?*regs)
{
...
for?(try=0;?try<2;?try++)?{
read_lock(&binfmt_lock);
list_for_each_entry(fmt,?&formats,?lh)?{
int?(*fn)(struct linux_binprm?*,?struct pt_regs?*)?=?fmt->load_binary;
...
retval?=?fn(bprm,?regs);
...
}
...
}
}
代碼中針對ELF格式的 fmt->load_binary即為load_elf_binary, 所以fn=load_elf_binary, 后續對fn的調用即是調用load_elf_binary,這是個非常長的函數,直到其最后,我們才找到所需要的答案:
?
static?int?load_elf_binary(struct linux_binprm?*bprm,?struct pt_regs?*regs)
{
...
start_thread(regs,?elf_entry,?bprm->p);
...
}
上述代碼中的elf_entry即為/sbin/init中的執行入口點, bprm->p為應用程序新棧(應該已經在用戶空間了),start_thread的實現為:
?
void
start_thread(struct pt_regs?*regs,?unsigned long new_ip,?unsigned long new_sp)
{
set_user_gs(regs,?0);
regs->fs?=?0;
regs->ds?=?__USER_DS;
regs->es?=?__USER_DS;
regs->ss?=?__USER_DS;
regs->cs?=?__USER_CS;
regs->ip?=?new_ip;
regs->sp?=?new_sp;
/*
*?Free the old FP?and?other extended state
*/
free_thread_xstate(current);
}
在這里,我們看到了__USER_CS的身影,在x86 64位系統架構下,該值為0x33. start_thread函數最關鍵的地方在于修改了regs->cs= __USER_CS, regs->ip= new_ip,其實就是人為地改變了系統調用int $0x80指令壓入堆棧的下條指令的地址,這樣當系統調用結束通過iret指令返回時,代碼將從這里的__USER_CS:elf_entry處開始執 行,也就是/sbin/init中的入口點。start_thread的代碼與kernel_thread非常神似,不過它不需要象 kernel_thread那樣在最后調用do_fork來產生一個task_struct實例出來了,因為目前只需要在當前進程上下文中執行代碼,而不是創建一個新進程。關于kernel_thread,我在本版曾有一篇帖子分析過,當時基于的是ARM架構。
所以我們看到,start_kernel在最后調用rest_init,而后者通過對kernel_thread的調用產生一個新進程(pid=1),新進程在其kernel_init()-->init_post()調用鏈中將通過run_init_process來執行用戶空間的/sbin /init,run_init_process的核心是個系統調用,當系統調用返回時代碼將從/sbin/init的入口點處開始執行,所以雖然我們知道 post_init中有如下幾個run_init_process的調用:
run_init_process("/sbin/init");
run_init_process("/etc/init");
run_init_process("/bin/init");
run_init_process("/bin/sh");
但是只要比如/sbin/init被成功調用,run_init_process中的kernel_execve函數將無法返回,因為它執行int $0x80時壓入堆棧中回家的路徑被后續的C函數調用鏈給改寫了,這樣4個run_init_process只會有一個有機會被成功執行,如果這4個函數都失敗 了,那么內核將會panic. 所以內核設計時必須確保用來改寫int $0x80壓入棧中的cs和ip的start_thread函數之后不會再有其他額外的代碼導致整個調用鏈的失敗,否則代碼將執行非預期的指令,內核進入不穩定狀態。
最后,我們來驗證一下,所謂眼見為實,耳聽為虛。再者,如果驗證達到預期,也是很鼓舞人好奇心的極佳方法。驗證的方法我打算采用“Linux設備驅動模型中的熱插拔機制及實驗” 中的路線,通過call_usermodehelper來做,因為它和kernel_execve本質上都是一樣的。我們自己寫個應用程序,在這個應用程序里讀取cs寄存器的值,程序很簡單:
?
#include?
#include?
#include?
#include?
int?main()
{
unsigned short ucs;
asm(
"movw %%cs, %0 "
:"=r"(ucs)
::"memory");
syslog(LOG_INFO,?"ucs = 0x%x ",?ucs);
return 0;
}
然后把這個程序打到/sys/kernel/uevent_help上面(參照Linux設備驅動模型中的熱插拔機制及實驗一文),之后我們往電腦里插個U盤,然后到/var/log/syslog文件里看輸出(在某些distribution上,syslog的輸出可能會到/var/log/messages中):
Mar 10 14:20:23 build-server main:?ucs = 0x33
0x33正好就是x86 64位系統(我實驗用的環境)下的__USER_CS.
所以第一個內核進程(pid=1)通過執行用戶空間程序,期間通過cs的轉變(從__KERNEL_CS到__USER_CS)來達到特權級的更替。
?
評論
查看更多