Lab: system calls
在這個lab當中6.1810 / Fall 2025 它要求你在xv6當中添加一個新的系統調用,以此來幫助你理解在操作系統當中,系統調用的底層實現邏輯和調用鏈條;
之後該lab當中會告訴你一個故意留下來的系統漏洞,要求你利用該漏洞獲取之前的進程(已經被清理的進程)的私有數據,通過此lab你可以學到操作系統是如何隔離每個進程的,同時也會告訴你在回收進程的資源時如果處理不當會導致原本應該被清理的進程,它的私有數據可能會被其他進程竊取,從而打破了操作系統的進程隔離機制。
1.Using gdb
這一部分涉及gdb的調試,所以我們暫時跳過,更多GDB的調試技巧可以去網上搜索一下,這裏就不再闡述了。
2.Sandbox a command(中等難度)
在這一小節當中,我們需要給xv6操作系統引入一種進程級系統調用限制機制(sandbox)。具體而言,允許用户進程通過一個新的系統調用 interpose(mask, path),為當前進程及其子進程設置一組“被禁止的系統調用”,使得後續執行中一旦觸發這些系統調用,就會被內核拒絕。官網當中告訴我們interpose接收兩個參數,一個是是屏蔽掩碼mask,另一個是路徑path (當前用不到)。
來自官網的提示(個人解析版):
- 在 Makefile 中向 UPROGS 添加
$U/_sandbox,以保證編譯器會編譯該源文件。 - 由於
interpose沒有任何聲明和實現,所以要在user/user.h中添加一個interpose原型(不要遺漏參數) 。 - 在
user/usys.pl當中增加一個新的項,該文件是用户態系統調用接口的生成腳本,它會幫助生成一個彙編文件user/usys.S,該文件中指定了每一個系統調用的參數,陷入指令和返回指令。 - 因為
interpose是一個新的系統調用,所以我們要在kernel/syscall.h當中添加一個新的系統調用碼,用於之後syscall函數的使用。 - 因為要添加一個新的系統調用,所以我們要嚴格按照xv6關於系統調用函數聲明的規範進行命名,我們可以參考xv6當中已有的函數聲明,所以我們在
kernel/sysproc.c當中實現一個名為:sys_interpose(void)的函數,它就是最終的調用實現。 - 按照官網的要求,我們的屏蔽掩碼需要父進程傳遞給子進程(或者説是子進程繼承了父進程的屏蔽掩碼),所以這就代表了這個屏蔽掩碼需要被持久存儲於進程中,於是我們需要在進程的結構體當中添加一個字段,用於記錄屏蔽掩碼,同時因為子進程是父進程通過調用fork創造出來的,所以一定存在一個函數用於將父進程當中某些狀態/屬性紋絲不動地賦值給子進程當中對應的字段,因此根據官網的提示,我們可以在
kernel/proc.c當中找到一個名為:kfork的函數,這裏就是父子進行狀態/屬性繼承的地方,我們需要在這裏修改一下,使得其可以將父進程新添加的“屏蔽掩碼”字段同樣賦值給子進程。 - 因為每個系統調用都是一個函數指針,所以在
kernel/syscall.c當中,有一個數組: syscalls,裏面存放的是每一個系統調用的入口地址,我們需要在該數組當中添加一項新的數據,同時需要在此文件中添加sys_interpose的聲明(可以參考已有的xv6代碼,照葫蘆畫瓢)。 - 因為我們要實現的是系統調用的屏蔽機制,所以在xv6當中,任何系統調用最終都會通過內核態函數syscall進行調用號的識別和分發調用,所以我們可以在此函數當中添加某些判斷邏輯,通過將當前請求系統調用的進程當中的屏蔽掩碼與當前進程請求的系統調用的調用碼向比對來得到是否要屏蔽該系統調用。
以下是代碼相關內容:
##user/user.h中新增的內容(用户態函數聲明):
int interpose(int,char *path);
##user/usys.pl中新增的內容:
entry("interpose");
##kernel/syscall.h中新增的內容(系統調用號):
#define SYS_interpose 22 //interpose的系統調用碼
##kernel/proc.c/kfork函數體內,中新增的內容(子進程繼承父進程的mask):
... ...
/*修改點,父進程的狀態mask傳遞給子進程
* 父進程的mask已經被修改,此時若創建新
* 的子進程則mask也要一併傳遞。
*/
np->mask = p->mask;
... ...
##kernel/syscall.c中新增/修改的內容(系統調用聲明,添加新的的項到函數指針數組,修改syscall函數):
extern uint64 sys_interpose(void); //新添加的系統調用聲明
// An array mapping syscall numbers from syscall.h
// to the function that handles the system call.
static uint64 (*syscalls[])(void) = {
[SYS_fork] sys_fork,
[SYS_exit] sys_exit,
[SYS_wait] sys_wait,
[SYS_pipe] sys_pipe,
[SYS_read] sys_read,
[SYS_kill] sys_kill,
[SYS_exec] sys_exec,
[SYS_fstat] sys_fstat,
[SYS_chdir] sys_chdir,
[SYS_dup] sys_dup,
[SYS_getpid] sys_getpid,
[SYS_sbrk] sys_sbrk,
[SYS_pause] sys_pause,
[SYS_uptime] sys_uptime,
[SYS_open] sys_open,
[SYS_write] sys_write,
[SYS_mknod] sys_mknod,
[SYS_unlink] sys_unlink,
[SYS_link] sys_link,
[SYS_mkdir] sys_mkdir,
[SYS_close] sys_close,
[SYS_interpose] sys_interpose, //新添加的系統調用
};
//這裏是syscall函數修改後的樣子:
void
syscall(void)
{
int num;
struct proc *p = myproc();
// 取出在a7中存放的調用號
num = p->trapframe->a7;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
//將調用屏蔽掩碼和系統調用碼進行相與的操作,判斷當前調用是否被屏蔽/禁止
if(p->mask & (1 << num) ){
p->trapframe->a0 = -1;
return;
}
// Use num to lookup the system call function for num, call it,
// and store its return value in p->trapframe->a0
p->trapframe->a0 = syscalls[num]();
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}
##kernel/proc.h當中修改和新增的內容(屏蔽掩碼):
/ Per-process state
struct proc {
struct spinlock lock;
// p->lock must be held when using these:
enum procstate state; // Process state
void *chan; // If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
int xstate; // Exit status to be returned to parent's wait
int pid; // Process ID
int mask; // (新)進程的系統調用屏蔽掩碼
... ...
##kernel/syscall.c當中新增的內容(sys_interpose的實現):
uint64
sys_interpose(void){
//獲取參數
int n;
argint(0, &n);
//修改狀態
struct proc *p = myproc();
p->mask = n;
return 0;
}
驗收成果:
- 按照官網給出的輸入進行輸入,如果輸出的和官網結果一致則代表成功。
- 在ubuntu的shell當中(xv6目錄下)輸入
./grade-lab-syscall sandbox_mask後,如果出現以下提示則代表成功!
== Test sandbox_mask == sandbox_mask: OK (1.5s)
3.Sandbox with allowed pathnames(簡單難度)
這一小節是對上一階段 系統調用屏蔽 的擴展,上一小節只是簡單粗暴地屏蔽了某個系統調用(一棒子打死的那種),在本小節,我們用到了interpose的第二個參數Path,這個參數的具體意思是:“允許訪問的路徑”。
假設我們的屏蔽掩碼屏蔽了 open 和 exec 這個兩個系統調用,但是這兩個系統調用在調用時都需要向其傳入一個路徑(我們假設該路徑的名字為:pathA),當我們調用 open 和 exec時,如果向其傳入的路徑 pathA和之前的Path一致,則代表 open 和 exec正常進行,不會被屏蔽,反之則直接返回,不再執行 open 和 exec。
個人的一些解析:
- 由於用到了
interpose的第二次參數,因此我們需要在進程結構體當中添加新的字段用於存放允許訪問的路徑。 - 由於在進程結構體當中添加了新成員,因此父子進程繼承狀態/屬性時需要傳遞剛才添加的新成員。
- 官網説了,如果屏蔽碼屏蔽的是
open和exec,則會繼續判斷PathA和進程結構體當中的特點字段是否一致,一致則代表open和exec可以正常執行,所以結合前面的例子,屏蔽掩碼具體屏蔽了誰,應該在syscall當中進行判斷,而進一步地判斷需要在sys_open和sys_exec兩個調用的具體實現當中。
以下是代碼相關內容:
##kernel/proc.h當中修改和新增的內容(屏蔽掩碼):
// Per-process state
struct proc {
struct spinlock lock;
// p->lock must be held when using these:
enum procstate state; // Process state
void *chan; // If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
int xstate; // Exit status to be returned to parent's wait
int pid; // Process ID
int mask; // 進程的系統調用屏蔽字
char allowPathName[MAXPATH]; // 被允許的路徑名
... ...
##kernel/proc.c/kfork函數體內,中新增的內容(子進程繼承父進程的mask):
... ...
/*修改點,父進程的狀態mask傳遞給子進程
* 父進程的mask已經被修改,此時若創建新
* 的子進程則mask也要一併傳遞。
* 子進程也要繼承父進程的allowPathName。
*/
np->mask = p->mask;
strncpy(np->allowPathName,p->allowPathName,MAXPATH);
... ...
##kernel/syscall.c當中修改的內容(遇到open和exec則“放行”,在open和exec中再次判斷):
void
syscall(void)
{
int num;
struct proc *p = myproc();
// 取出在a7中存放的調用號
num = p->trapframe->a7;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
//修改前:將調用屏蔽掩碼和系統調用碼進行相與的操作,判斷當前調用是否被屏蔽/禁止
//修改後:如果屏蔽的是open和exec則在open或者exec當中再次判斷
if(p->mask & (1 << num) ){
if(num == SYS_open || num == SYS_exec){
p->trapframe->a0 = syscalls[num]();
return;
}
p->trapframe->a0 = -1;
return;
}
// Use num to lookup the system call function for num, call it,
// and store its return value in p->trapframe->a0
p->trapframe->a0 = syscalls[num]();
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}
##kernel/sysfile.c/sys_open函數體內,新增的內容(添加判斷邏輯):
... ...
// 只有當 open 被 mask 掉時,才檢查路徑
if(p->mask & (1 << SYS_open)){
if(strncmp(path,p->allowPathName,MAXPATH) != 0){
return -1;
}
}
... ...
##kernel/sysfile.c/sys_exec函數體內,新增的內容(添加判斷邏輯):
... ...
// 只有當 exec 被 mask 掉時,才檢查路徑
if(p->mask & (1 << SYS_exec)){
if(strncmp(path, p->allowPathName,MAXPATH) != 0){
return -1;
}
}
... ...
驗收成果:
- 按照官網給出的輸入進行輸入,如果輸出的和官網結果一致則代表成功。
- 在ubuntu的shell當中(xv6目錄下)輸入
make grade後,如果出現以下提示則代表成功!
== Test sandbox_mask ==
$ make qemu-gdb
sandbox_mask: OK (3.0s)
== Test sandbox_fork ==
$ make qemu-gdb
sandbox_fork: OK (1.1s)
== Test sandbox_path ==
$ make qemu-gdb
sandbox_path: OK (1.2s)
== Test sandbox_most ==
$ make qemu-gdb
sandbox_most: OK (0.7s)
== Test sandbox_minus ==
$ make qemu-gdb
sandbox_minus: OK (1.0s)
== Test attack ==
$ make qemu-gdb
attack: OK (1.1s)
4、Attack xv6 (中等難度)
這一小節,我們將利用系統漏洞打破進程之間的屏障,從而中進程B當中訪問到進程A(已被回收但未徹底重置該進程使用過的內存)當中的私有數據。
xv6 通過虛擬內存和系統調用機制實現了進程之間、用户態與內核態之間的隔離,在正常情況下,一個用户進程不可能直接訪問另一個進程的內存數據。正常情況下進程在被銷燬時,其使用過的內存空間也要被清理一下(例如全部置為0或者其他值),但是xv6當中負責回收進程內存的邏輯沒有對進程使用過的內存進行清理,這就導致新的進程被創建後,其私有的內存空間很可能與之前的進程相重疊(方便理解先這麼説,後面會給出具體的解釋),導致新進程可能訪問到舊進程的私有數據。
所以,在本次小節,xv6會先通過secret程序創建進程,並且向該進程的私有內存空間當中存放一些數據,最後銷燬進程(注意:存放的數據沒有被銷燬),之後我們通過實現attack這個程序,來讓一個新的進程嘗試從自己的私有內存空間當中尋找secret進程遺留下來的蛛絲馬跡,找到後輸出它。
官網的一些提示和本人的解析:
user/secret.c是secret的源文件。- 我們在
user/attack.c當中實現本小節讓我們做的內容。 - 官網説通過
sbrk()這個系統調用來請求分配一塊內存空間(堆區),然後在該堆區當中尋找蛛絲馬跡。 - 因為secret會向內存當從存放字符串,所以我們在遍歷堆區時需要判斷當前訪問的內存當中存放的內容是否符合字符串的特徵,同時字符串的字符應該是大於等於2個字符,連續並且以'\0'結尾。
- 要為進程分配合適大小的堆區,並且檢測字符串時,存放字符串的容器長度也要設計合理。
相關代碼:
##user/attack.c
#include "kernel/types.h"
#include "kernel/fcntl.h"
#include "user/user.h"
#include "kernel/riscv.h"
#define DATASIZE (8*4096) //heap的大小為8頁,共32k,一頁4kB
int
main(int argc, char *argv[])
{
// Your code here.
//分配heap
char* buf = sbrk(DATASIZE); //堆的大小為8頁
char ch[DATASIZE/4]; //字符串大小為8k
int j = 0;//
for(int i = 0; i < DATASIZE; i++){
char c = buf[i];
if(c == '\0' && (j >= 2 && j < DATASIZE/4)){
//遇到/0,並且j大於2且在合法範圍內則代表可能找到了想要的東西,截斷字符串
ch[j++] = '\0';
//打印
printf("%s",ch);
printf("\n");
//j置為0繼續找剩餘符合條件的字符串(假設還沒有掃碼到heap的盡頭的情況下)
j = 0;
}
//符合字符條件並且j的範圍合理
if( ((c >='a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) && j < DATASIZE/4 - 1){
//符合條件則賦值給字符串數組
ch[j++] = c;
}
else{
//不連續或者不是字符則將j值為0
j=0;
}
}
exit(1);
}
驗收成果:
- 按照官網給出的輸入進行輸入,如果輸出的和官網結果一致則代表成功。
- 在ubuntu的shell當中(xv6目錄下)輸入
./grade-lab-syscall attack後,如果出現以下提示則代表成功!
== Test attack == attack: OK (1.1s)
關於內存方面的解釋:
在 xv6 中,物理內存被劃分為固定大小的物理頁(每頁 4096 字節)。當一個進程創建時,內核會為其建立頁表,用於將進程的虛擬頁映射到具體的物理頁上。
假設進程 A 通過頁表映射,將數據寫入某個物理頁(例如編號為 P 的物理頁)。當進程 A 退出時,其頁表會被銷燬,並且該物理頁會被歸還到空閒頁鏈表中,但由於本實驗中內核沒有對該物理頁執行清零操作,該物理頁中的內容仍然保留。
隨後,當進程 B 創建並調用 sbrk() 分配內存時,內核可能會將該物理頁重新分配給進程 B,並通過新的頁表項將其映射到進程 B 的虛擬地址空間中。此時,進程 B 只要訪問對應的虛擬地址,就能夠讀取到此前進程 A 遺留下來的數據,從而造成信息泄露。
為什麼官方文檔中提到“第一次攻擊可能失敗,需要第二次”?
當 secret 進程退出後,其使用過的物理頁會被歸還到內核的空閒頁鏈表中,但這些物理頁未被清零。隨後 attack 進程通過 sbrk() 申請新的內存頁時,內核會從空閒頁鏈表中分配物理頁。
由於空閒頁的分配順序取決於內核內部狀態(例如此前的內存分配和釋放順序),attack 進程在第一次運行時未必能恰好獲得 secret 進程曾使用過的物理頁,因此可能無法讀取到殘留數據。
當 attack 程序再次運行時,物理頁分配狀態可能發生變化,此時更有可能分配到此前包含 secret 的物理頁,從而成功讀取到敏感信息。因此,攻擊的成功具有一定的概率性。
5、寫在最後
接下來要開始研究6.1810 / Fall 2025了。由於還要複習408+數學所以會更新很慢。
有什麼錯誤問題可以聯繫我,我也會持續維護這些內容。