Lab2-system calls && MIT6.1810操作系统工程【持续更新】
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+数学所以会更新很慢。
有什么错误问题可以联系我,我也会持续维护这些内容。

浙公网安备 33010602011771号