首頁>技術>

〇、背景知識

在開始之前我們先了解幾個簡單的背景知識:XNU 的 Process (程序)的組成是怎樣的?

我們知道 Process 這個抽象概念是指一個 Program (程式)加上它所持有的 Resources (資源)。資源包括物理的 CPU 時間和記憶體,或者抽象的檔案概念等等。

我們知道 XNU 核心主要由 BSD 和 Mach 兩個部分組成,BSD 作為 Unix 核心提供了 Unix Process,Mach 核心則把 Process 抽象為 Task 和 Thread,所以在 macOS 上,一個程序既是 Mach Task 也是 BSD Process。不過核心中比較多的 IPC 是通過 Mach 來完成的。

Mach Task 的定義在 osfmk/kern/task.h ,這個結構體非常大,持有 IPC space, memory address space, Mach threads, BSD info 等非常多程序相關資訊。

我們在使用者空間給自己的 App 新起執行緒的時候,無論是用 NSThread 還是其他上層介面,系統都用 pthread 介面實現了(POSIX Threads)。進入到核心空間,一個 pthread 對應的是一個 Mach Thread,結構體定義在 osfmk/kern/thread.h ,就是 struct thread 。機器相關的定義在 struct machine_thread ,不同的架構各有一個實現。 thread 帶有 struct task *task; 資訊指向對應的程序。這個 Mach Thread 裡也包含了 BSD 的 uthread 。

所以一個 pthread 既是 Mach thread 也是 Unix thread。所以核心在建立一個新程序的時候,就需要同時建立 Unix Process 和 Mach Task,以及他們需要的 threads, processors 等各種資訊。

0.1 核心是有程序數上限設定的

我們可以通過 sysctl 檢視:

➜  sysctl -a | grep -i prockern.maxproc: 4176

核心也在 bsd/conf/param.c hardcoded 了數字 NPROC :

#if CONFIG_EMBEDDED#define    NPROC 1000          /* Account for TOTAL_CORPSES_ALLOWED by making this slightly lower than we can. */#define    NPROC_PER_UID 950#else#define    NPROC (20 + 16 * 32)#define    NPROC_PER_UID (NPROC/2)#endif/* NOTE: maxproc and hard_maxproc values are subject to device specific scaling in bsd_scale_setup */#define HNPROC 2500    /* based on thread_max */int maxproc = NPROC;
一、傳統 Unix 方法 fork() 與 exec()

在傳統的 Unix 系統中, fork() 是唯一用來建立新程序的方法,該方法將復刻一個當前程序的完整結構,包括二進位制程式碼。所以負責啟動其他 App 的程序為了能跑其他人的程式,還需要配合 exec() 方法,把 fork 出來的程序的 image 覆蓋成新 App 的。

macOS 的 BSD 部分也提供了 fork() 方法,返回值是 pid_t ,為 0 即表示當前跑在子程序, -1 是失敗,其他就是父程序的 pid 。參考 MTU 課程的一個示例程式碼:

#include  <stdio.h>#include  <sys/types.h>#define   MAX_COUNT  200void  ChildProcess(void);                /* child process prototype  */void  ParentProcess(void);               /* parent process prototype */void  main(void){     pid_t  pid;     pid = fork();     if (pid == 0)           ChildProcess();     else           ParentProcess();}void  ChildProcess(void){     int   i;     for (i = 1; i <= MAX_COUNT; i++)          printf("   This line is from child, value = %d\\n", i);     printf("   *** Child process is done ***\\n");}void  ParentProcess(void){     int   i;     for (i = 1; i <= MAX_COUNT; i++)          printf("This line is from parent, value = %d\\n", i);     printf("*** Parent is done ***\\n");}

BSD 提供的 exec() 方法有很多,可以 參考這裡 :

execl, execlp, execle, exect, execv, execvp, execvP -- execute a file

但最終都會進入 execve() 系統呼叫,這是核心提供給使用者空間用於開啟其他程式的唯一介面。

1.1 fork()1.1.1 使用者空間的準備工作

在進入核心實現之前, fork() 在使用者空間還做了一大堆事情,這些是在 libSystem 裡面實現的, 原始碼可以在這裡找到 。

我們的示例程式碼在呼叫 fork() 函式之後,就會先進入 libSystem 呼叫 libSystem_atfork_prepare() 處理註冊的 hooks,接下來如果是動態庫就走 dyld 的 _dyld_fork_child() 方法,靜態庫就不走 dyld 了。(我找到了函式實現但是沒有找到判斷與呼叫的地方。)

在 dyld 43 版本還有對靜態庫的處理 _dyld_fork_parent() 但是最新的版本(655.1.1)已經只剩下 _dyld_fork_child() 了。

// Libsystem-1252.250.1 // init.c()static const struct _libc_functions libc_funcs = {    .version = 1,    .atfork_prepare = libSystem_atfork_prepare,    .atfork_parent = libSystem_atfork_parent,    .atfork_child = libSystem_atfork_child,};

接下來 libSystem , dyld 和 xnu 會有一系列複雜的互相呼叫。《Mac OS X Internals》書中介紹的版本比較舊,新的程式碼和書中所說的稍有不同,但是原理是差不多的。這一部分直接閱讀原始碼比較困難,所以我選擇放棄,直接閱讀書裡的結論就好。XD

大家可以到這裡 參考原文

voidlibSystem_atfork_child(void){    // first call hardwired fork child handlers for Libsystem components    // in the order of library initalization above    _dyld_fork_child();    _pthread_atfork_child();    _mach_fork_child();    _malloc_fork_child();    _libc_fork_child(); // _arc4_fork_child calls malloc    dispatch_atfork_child();#if defined(HAVE_SYSTEM_CORESERVICES)    _libcoreservices_fork_child();#endif    _asl_fork_child();    _notify_fork_child();    xpc_atfork_child();    _libtrace_fork_child();    _libSC_info_fork_child();    // second call client parent handlers registered with pthread_atfork()    _pthread_atfork_child_handlers();}
1.1.2 核心空間的實現

使用者控制元件準備完了就開始進入核心的 fork() 函數了,實現在 bsd/kern/kern_fork.c :

int fork(proc_t parent_proc, __unused struct fork_args *uap, int32_t *retval)

返回值 0 為成功,其他就是錯誤碼。

第一個引數 parent_proc 就是呼叫 fork 的那個 process,第二個引數 uap 已經棄置不用了,第三個引數就是返回的 pid 。父程序會收到 hardcoded 的 0 。

關鍵實現在 fork1() 函式:

intfork1(proc_t parent_proc, thread_t *child_threadp, int kind, coalition_t *coalitions)

這個函式上來先取父程序的 thread 和 uthread ,接著取當前使用者 ID kauth_getruid() ,也就是我們通過 ps 看到的當前程序由哪個使用者建立的資訊,我們在 shell 裡經常需要 sudo 也就是切換成 root 身份來跑一個程序,這個許可權就是通過 kauth 模組管理。

接下來判斷當前程序數是否超限,沒問題就繼續。

count = chgproccnt(uid, 1);

這裡把當前使用者程序數 + 1,我想到核心啟動的時候,也 hardcode 了一句 + 1 給 launchd 這個程序。接著會判斷使用者的程序數上限是否超限。

接下來是安全檢查,判斷當前使用者是否有許可權 fork 新的程序,沒問題就開始 switch kind 了,一共有三種類型:

/* process creation arguments */#define    PROC_CREATE_FORK    0   /* independent child (running) */#define    PROC_CREATE_SPAWN   1   /* independent child (suspended) */#define    PROC_CREATE_VFORK   2   /* child borrows context */

其中 vfork() 是 fork() 的變種,大部分 Unix-like 系統都有這兩種 fork,區別是 vfork 建立的子程序會 block 住父程序,一直等到子程序跑完 exit 然後父程序才會繼續,fork 則不會,可自行編譯執行我們上文的小 demo。

至於 spawn 則是給 posix_spawn() 用的,跟 fork() 類似,但是 fork 會繼承(或者說複製)父程序的很多資源比如記憶體,而 spawn 不會。可以 參考 Linxu 關於 POSIX Spawn 的文件 ,簡單理解為是給那些效能比較低的裝置(比如嵌入式裝置)用的。

我們繼續看 fork() :

cloneproc() // 建立新的 Mach Task (task_t), Unix Process (proc_t) 以及 thread_forkproc()主要作用是建立一個新的 proc_t 然後把父程序的資訊都塞給他查詢可用的 pid 然後賦值給新的 proc_t這裡引數 inherit_memory 如果為 true ,則 vm_map 也會 fork 一份,否則就是重新建立一個 vm_map 然後賦值。 fork() 進來的為 true , posix_spawn() 為 false 。fork_create_child() 建立新的執行緒 thread_tprocdup() 這個在書中有提但是新版核心已去掉thread_dup()machine_thread_dup() 不同的架構各有實現,主要是複製了當前執行緒的暫存器資訊,FPU 資訊等硬體相關的上下文資訊。task_clear_return_wait()thread_wakeup()thread_wakeup_with_result()`` #define thread_wakeup_with_result(x, z) \\ thread_wakeup_prim((x), FALSE, (z)) ```thread_wakeup_prim()

書中曰最終會進入 thread_resume() 但是我又沒找到從哪裡進入的 ‍♂️。

1.2 execve()

實現在 bsd/kern/kern_exec.c ,我們來個示例程式碼看看:

#include <stdio.h> #include <sys/types.h> #include <unistd.h>  #include <stdlib.h> #include <errno.h>   #include <sys/wait.h> int main() {    pid_t pid;    int status, died;    pid = fork();    if (pid == 0) {        printf("%s\\n", "parent");    } else {        int ret = execve("/bin/date",0,0);        printf("%d\\n", ret);    }}

輸出如下:

➜ ./a.outparentWed Nov  6 18:55:45 CST 2019

可以看到子程序已經被 /bin/date 覆蓋了。同樣的,這個函式也有使用者空間和核心空間實現,上面示例我們用的介面是 POSIX 定義的:

int  execve(const char * __file, char * const * __argv, char * const * __envp);

接受檔案路徑引數,引數列表和環境引數。

到了核心這個函式則是:

// bsd/kern/kern_exec.cintexecve(proc_t p, struct execve_args *uap, int32_t *retval)

p 是當前程序, uap 是使用者空間傳過來的引數,有三個:

uap->fnameuap->argpuap->envp

對應使用者空間裡我們傳的三個引數。最後 retval 是給上層的返回值,函式自身返回 0 則成功。

該函式的主要實現在 __mac_execve() 。

先組裝一個 image_params 資料結構:

struct image_params {    user_addr_t ip_user_fname;      /* argument */    user_addr_t ip_user_argv;       /* argument */    user_addr_t ip_user_envv;       /* argument */    int     ip_seg;         /* segment for arguments */    struct vnode    *ip_vp;         /* file */    struct vnode_attr   *ip_vattr;  /* run file attributes */    struct vnode_attr   *ip_origvattr;  /* invocation file attributes */    cpu_type_t  ip_origcputype;     /* cputype of invocation file */    cpu_subtype_t   ip_origcpusubtype;  /* subtype of invocation file */    char        *ip_vdata;      /* file data (up to one page) */    int     ip_flags;       /* image flags */    int     ip_argc;        /* argument count */    int     ip_envc;        /* environment count */    int     ip_applec;      /* apple vector count */    char        *ip_startargv;      /* argument vector beginning */    char        *ip_endargv;    /* end of argv/start of envv */    char        *ip_endenvv;    /* end of envv/start of applev */    char        *ip_strings;        /* base address for strings */    char        *ip_strendp;        /* current end pointer */    int         ip_argspace;    /* remaining space of NCARGS limit (argv+envv) */    int     ip_strspace;        /* remaining total string space */    user_size_t     ip_arch_offset;     /* subfile offset in ip_vp */    user_size_t     ip_arch_size;       /* subfile length in ip_vp */    char        ip_interp_buffer[IMG_SHSIZE];   /* interpreter buffer space */    int     ip_interp_sugid_fd;     /* fd for sugid script */    /* Next two fields are for support of architecture translation... */    struct vfs_context  *ip_vfs_context;    /* VFS context */    struct nameidata *ip_ndp;       /* current nameidata */    thread_t    ip_new_thread;      /* thread for spawn/vfork */    struct label    *ip_execlabelp;     /* label of the executable */    struct label    *ip_scriptlabelp;   /* label of the script */    struct vnode    *ip_scriptvp;       /* script */    unsigned int    ip_csflags;     /* code signing flags */    int     ip_mac_return;      /* return code from mac policy checks */    void        *ip_px_sa;    void        *ip_px_sfa;    void        *ip_px_spa;    void        *ip_px_smpx;        /* MAC-specific spawn attrs. */    void        *ip_px_persona;     /* persona args */    void        *ip_cs_error;       /* codesigning error reason */    uint64_t ip_dyld_fsid;    uint64_t ip_dyld_fsobjid;};

組裝完了之後就 active 一下 image:

static intexec_activate_image(struct image_params *imgp)

這個函式主要是分配記憶體,許可權檢查,通過 namei() 方法找到該二進位制檔案,使用 vn 介面(跟檔案系統無關的抽象介面)讀取檔案頭,最多讀一頁。

error = vn_rdwr(UIO_READ, imgp->ip_vp, imgp->ip_vdata, PAGE_SIZE, 0,        UIO_SYSSPACE, IO_NODELOCKED,        vfs_context_ucred(imgp->ip_vfs_context),        &resid, vfs_context_proc(imgp->ip_vfs_context));

讀到檔案頭資訊之後再迴圈走一遍,判斷是否如下三種:

{ exec_mach_imgact,     "Mach-o Binary" }, // 普通的單架構 Mach-o 二進位制檔案{ exec_fat_imgact,      "Fat Binary" }, // 多架構 Mach-o 二進位制檔案{ exec_shell_imgact,        "Interpreter Script" }, // 指令碼

找到了就使用對應 imgact 轉成函式指標然後呼叫它,傳入 imgp 引數。

error = (*execsw[i].ex_imgact)(imgp);

我們直接看 exec_mach_imgact() :

static intexec_mach_imgact(struct image_params *imgp)

這個函式最重要的地方是:

lret = load_machfile(imgp, mach_header, thread, ↦, &load_result);

load_machfile() 實現在 bsd/kern/mach_loader.c 裡面。負責分配實體記憶體和虛擬記憶體,如果有 ASLR (就是記憶體 offset 加個隨機偏移,預設開)就隨機一下,然後解析 Mach-o 檔案,根據 Mach-o 檔案的 load commands 資訊把二進位制資料裝進記憶體。

其中用到了 parse_machfile() 方法處理 Mach-o 檔案裡的 load commands。我們知道有了 ASLR 之後大家的入口都從 LC_UNIXTHREAD 變成了 LC_MAIN 。這個方法就把這些資訊都儲存到 load_result_t 裡面然後返回, load_result_t 裡包含了 threadstate ,裡面就有 entry_point 資訊。

load mach file 結束後 activate_exec_state()

static intactivate_exec_state(task_t task, proc_t p, thread_t thread, load_result_t *result)

這個函式會呼叫 thread_setentrypoint() 把之前函式入口 entry_point 地址塞進 eip 暫存器於是函式就愉快地被呼叫了。

thread_setentrypoint(thread, result->entry_point);
// i386 實現#define CAST_DOWN_EXPLICIT( type, addr )  ( ((type)((uintptr_t) (addr))) ) /* * thread_setentrypoint: * * Sets the user PC into the machine * dependent thread state info. */voidthread_setentrypoint(thread_t thread, mach_vm_address_t entry){    pal_register_cache_state(thread, DIRTY);    if (thread_is_64bit_addr(thread)) {        x86_saved_state64_t *iss64;        iss64 = USER_REGS64(thread);        iss64->isf.rip = (uint64_t)entry;    } else {        x86_saved_state32_t *iss32;        iss32 = USER_REGS32(thread);        iss32->eip = CAST_DOWN_EXPLICIT(unsigned int, entry);    }}

這裡涉及 i386 架構的暫存器設計,以底下的 32 位為例, eip 就是 PC 暫存器(Program Counter Register)。

#define REG_PC  EIP#define REG_FP  EBP#define REG_SP  UESP#define REG_PS  EFL#define REG_R0  EAX#define REG_R1  EDX

在 i386 或曰 x86 架構裡面,這個暫存器就是下一個指令會訪問到的記憶體地址。於是我們將它設定為函式入口,該函式就開始了。

1.3 LC_MAIN 的 entryoff

有了 ASLR 之後入口地址不再是靜態的偏移量而是每次都會隨機一下。如果是以前的入口在 LC_UNIXTHREAD 的,這時候取 entry point 就直接賦值。

但是 LC_MAIN 入口的卻會在 LC_LOAD_DYLINKER 段裡面指定使用的 dyld ,比如 macOS 上 Tweetbot.app 的如下:

Load command 7      cmd LC_LOAD_DYLINKER  cmdsize 32     name /usr/lib/dyld (offset 12)

那麼 parser_machinefile() 就會去呼叫 load_dylinker() ,初始化一些 dylddata 然後又回去呼叫 parse_machinefile() 一次。這一次,parse 的不是別人,而是 LC_LOAD_DYLINKER 裡指定的 dyld ,比如上面的 /usr/lib/dyld 。

這個傢伙當然不用 LC_MAIN 而是 LC_UNIXTHREAD 啦:

Load command 12        cmd LC_UNIXTHREAD    cmdsize 184     flavor x86_THREAD_STATE64      count x86_THREAD_STATE64_COUNT   rax  0x0000000000000000 rbx 0x0000000000000000 rcx  0x0000000000000000   rdx  0x0000000000000000 rdi 0x0000000000000000 rsi  0x0000000000000000   rbp  0x0000000000000000 rsp 0x0000000000000000 r8   0x0000000000000000    r9  0x0000000000000000 r10 0x0000000000000000 r11  0x0000000000000000   r12  0x0000000000000000 r13 0x0000000000000000 r14  0x0000000000000000   r15  0x0000000000000000 rip 0x0000000000001000rflags  0x0000000000000000 cs  0x0000000000000000 fs   0x0000000000000000

於是設定好 entry point,通過 dyld 起飛!

二、dyld 如何呼叫 App 入口

核心的 fork() 和 exec() 任務到給 thread 設定 entry point 之後就結束了。至於為什麼往暫存器裡塞一個函式指標地址它就開始跑起來,那就涉及到彙編,CPU 如何執行指令了。 阮一峰的科普文章《組合語言入門教程》 寫得很淺顯易懂可以參考一下。

接下來我們切換到 dyld 的原始碼。 dyld 在模擬器和真機上有不同的啟動入口:

// configs/dyld.xcconfigENTRY[sdk=*simulator*]     = -Wl,-e,_start_simENTRY[sdk=iphoneos*]       = -Wl,-e,__dyld_startENTRY[sdk=macosx*]         = -Wl,-e,__dyld_start

入口函式的實現是彙編,在 dyldStartup.s 檔案。我們可以搜尋關鍵詞 call :

// i386 實現    .text    .align  4, 0x90    .globl __dyld_start__dyld_start:    popl    %edx        # edx = mh of app    pushl   $0      # push a zero for debugger end of frames marker    movl    %esp,%ebp   # pointer to base of kernel frame    andl    $-16,%esp       # force SSE alignment    subl    $32,%esp    # room for locals and outgoing parameters    call    L__dyld_start_picbaseL__dyld_start_picbase:      popl    %ebx        # set %ebx to runtime value of picbase    movl    Lmh-L__dyld_start_picbase(%ebx), %ecx # ecx = prefered load address    movl    __dyld_start_static_picbase-L__dyld_start_picbase(%ebx), %eax    subl    %eax, %ebx      # ebx = slide = L__dyld_start_picbase - [__dyld_start_static_picbase]    addl    %ebx, %ecx  # ecx = actual load address    # call dyldbootstrap::start(app_mh, argc, argv, slide, dyld_mh, &startGlue)    movl    %edx,(%esp) # param1 = app_mh    movl    4(%ebp),%eax        movl    %eax,4(%esp)    # param2 = argc    lea     8(%ebp),%eax        movl    %eax,8(%esp)    # param3 = argv    movl    %ebx,12(%esp)   # param4 = slide    movl    %ecx,16(%esp)   # param5 = actual load address    lea 28(%esp),%eax    movl    %eax,20(%esp)   # param6 = &startGlue    call    __ZN13dyldbootstrap5startEPK12macho_headeriPPKclS2_Pm       movl    28(%esp),%edx    cmpl    $0,%edx    jne Lnew        # clean up stack and jump to "start" in main executable    movl    %ebp,%esp   # restore the unaligned stack pointer    addl    $4,%esp     # remove debugger end frame marker    movl    $0,%ebp     # restore ebp back to zero    jmp *%eax       # jump to the entry point    # LC_MAIN case, set up stack for call to main() Lnew:   movl    4(%ebp),%ebx    movl    %ebx,(%esp) # main param1 = argc    leal    8(%ebp),%ecx    movl    %ecx,4(%esp)    # main param2 = argv    leal    0x4(%ecx,%ebx,4),%ebx    movl    %ebx,8(%esp)    # main param3 = env

所以在我們的 App 的函式入口被呼叫之前, dyldbootstrap::start(app_mh, argc, argv, slide, dyld_mh, &startGlue) 函式會先被呼叫,它的返回值是真正 App 的函式入口,比如說 main() 。

uintptr_t start(const struct macho_header* appsMachHeader, int argc, const char* argv[],                 intptr_t slide, const struct macho_header* dyldsMachHeader,                uintptr_t* startGlue)

這個函式呼叫了 dyld::_main() 這個函式才是重點,上面不同架構的彙編都會進這裡,只是引數各有不同。這個函式會 load 所有的動態庫 image,初始化,最後再拿到真正的 App 入口,然後返回。最後彙編程式碼裡就會 jmp 到 App 入口,於是 App 就愉快地啟動了。

三、launchd

如果你在 Activity Monitor App 裡選中一個程序,點左上角的感嘆號,你可以看到當前程序的 Parent Process。然後你就會發現基本上所有你通過 Finder, Launchpad 之類的方式啟動的 App(命令列的 open 也是),它們的 parent process 都是 launchd (當然 App 自行建立的子程序就不是,比如 Google Chrome Helper)。在 iOS 的 Crash Log 裡,App 的 parent process 也是 launchd 。

在 macOS 上我們可以使用系統提供的 Launch Service 來啟動其他 App,最終也是由 launchd 來完成 fork() 和 execve() 。

launchd 的 parent process 是 kernel_task 。 kernel_task 程序就是核心程序本程了,在核心啟動時自行建立,實現在 bsd/kern/bsd_init.c 的 bsd_init(void) 函式。

launchd 是 Mac OS X Tiger 10.4 開始引入的特性,在 Kernel 啟動時建立,然後它負責建立其他系統守護程序(Daemons),也負責建立系統登入介面。

還有另一個服務是 launchctl ,可以跟 launchd 進行 IPC 通訊,經常被用來做開機啟動任務。 LaunchControl.app 就是非常好的 launchctl / launchd 圖形介面。

四、小結

Unix 的 fork() 和 execve() 方法在上學的時候學校曾經教過。但是一則當時的講解還比較偏高階抽象,二則年代久遠已經記不太清了,所以回顧學習這一段的時候還是費了點力氣去了解諸如彙編、暫存器之類的概念。Apple 開源的程式碼還是很多的,除了核心,大量的系統服務也都開源了,非常有助學習。最近學習核心程式碼,一邊看程式碼一邊跟著書本理解,總讓我有一種“原始碼在手,天下我有”的錯覺。XD

最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • Node.JS實戰58:寫一套反爬蟲系統