Linux kernel basics

本文主要参考ctf-wiki,记录一下自己的学习历程

内核态与用户态

Linux内核把系统分成两个空间:用户空间和内核空间。CPU既可以运行在用户空间(这时候就是我们所说的用户态),也可以运行在内核空间(内核态)。一些架构的实现还有多种执行模式,intel x86体系结构有ring0~ring3 四种执行级别。但Linux内核只使用了ring0 和 ring3两种级别来实现内核态和用户态。

内核态与用户态之间的状态转换

Linux内核为内核态和用户态之间的切换设置了软件抽象层,叫做系统调用层。通过一些指令即可实现内核态和用户态之间的切换。

用户态切换到内核态

当发生 系统调用产生异常外设产生中断等事件时,会发生用户态到内核态的切换,具体的过程为:

  1. 通过 swapgs 切换 GS 段寄存器,将 GS 寄存器值和一个特定位置的值进行交换,目的是保存 GS 值,同时将该位置的值作为内核执行时的 GS 值使用。
  2. 将当前栈顶(用户空间栈顶)记录在 CPU 独占变量区域里,将 CPU 独占区域里记录的内核栈顶放入 rsp/esp。
  3. 通过 push 保存各寄存器值(具体代码在5下面)。
  4. 通过汇编指令判断是否为 x32_abi
  5. 通过系统调用号,跳到全局变量 sys_call_table 相应位置继续执行系统调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
 ENTRY(entry_SYSCALL_64)
/* SWAPGS_UNSAFE_STACK是一个宏,x86直接定义为swapgs指令 */
SWAPGS_UNSAFE_STACK

/* 保存栈值,并设置内核栈 */
movq %rsp, PER_CPU_VAR(rsp_scratch)
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp

/* 通过push保存寄存器值,形成一个pt_regs结构 */
/* Construct struct pt_regs on stack */
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(rsp_scratch) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
pushq %rax /* pt_regs->orig_ax */
pushq %rdi /* pt_regs->di */
pushq %rsi /* pt_regs->si */
pushq %rdx /* pt_regs->dx */
pushq %rcx tuichu /* pt_regs->cx */
pushq $-ENOSYS /* pt_regs->ax */
pushq %r8 /* pt_regs->r8 */
pushq %r9 /* pt_regs->r9 */
pushq %r10 /* pt_regs->r10 */
pushq %r11 /* pt_regs->r11 */
sub $(6*8), %rsp /* pt_regs->bp, bx, r12-15 not saved */

内核态切换到用户态

该过程也是我们在做题中经常用到的

  1. 通过 swapgs 恢复 GS 值。

    • 通常使用swapgs; popfq; ret这条gadget
  2. 通过 sysretq 或者 iretq 恢复到用户控件继续执行。如果使用 iretq 还需要给出用户空间的一些信息(CS, eflags/rflags, esp/rsp , SS等)

    • payload一般这么构造
      1
      2
      3
      4
      5
      6
      rop[i++] = iretq_ret_addr; // iretq; ret; 
      rop[i++] = rip; // rip
      rop[i++] = user_cs;
      rop[i++] = user_rflags;
      rop[i++] = user_sp;
      rop[i++] = user_ss;

因为iretq需要寄存器信息,所以需要在exp的开头保存一下寄存器信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// intel flavor assembly
size_t user_cs, user_ss, user_rflags, user_sp;
void save_status()
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
puts("[*]status has been saved.");
}

// at&t flavor assembly
void save_stats() {
asm(
"movq %%cs, %0\n"
"movq %%ss, %1\n"
"movq %%rsp, %3\n"
"pushfq\n"
"popq %2\n"
:"=r"(user_cs), "=r"(user_ss), "=r"(user_eflags),"=r"(user_sp)
:
: "memory"
);
}

内核模块(LKM)

Linux内核在发展过程中很早就引入了内核模块这个机制,内核模块全称是Loadable Kernel Module (LKM)。在内核运行时加载一组目标代码来实现某个特定的功能,这样在使用Linux的时候不需要重新编译内核代码来实现动态的扩展(文件系统、驱动等)。

模块可以被单独编译,但不能单独运行。它在运行时被链接到内核作为内核的一部分在内核空间运行,这与运行在用户控件的进程不同。

Linux操作LKM主要有下面几条指令:

  • lsmod: 列出已经加载的模块
  • insmod: 将指定模块加载到内核中
  • rmmod: 从内核中卸载指定模块

ioctl

ioctl 也是一个系统调用,用于与设备通信。

1
2
#include <sys/ioctl.h>
int ioctl(int fd, unsigned long request, ...);

第一个参数为打开设备 (open) 返回的文件描述符,第二个参数为用户程序对设备的控制命令,再后边的参数则是一些补充参数,与设备有关。调用过程大概如下:

1
2
3
4
5
6
7
8
                   |                              |
+-----------+ | +--------------+ | +-------------------+
| | | | | | | |
| ioctl() +------+-----> | sys_ioctl() | +-----+------> | babydriver_ioctl()|
| | | | | | | |
+-----------+ | +--------------+ | +-------------------+
| |
user space + vfs + driver

cred 结构体

每个进程中都有一个 cred 结构,这个结构保存了该进程的权限等信息(uidgid 等),如果能修改某个进程的 cred,那么也就修改了这个进程的权限。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
struct cred {
atomic_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* number of processes subscribed */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */
unsigned securebits; /* SUID-less security management */
kernel_cap_t cap_inheritable; /* caps our children can inherit */
kernel_cap_t cap_permitted; /* caps we're permitted */
kernel_cap_t cap_effective; /* caps we can actually use */
kernel_cap_t cap_bset; /* capability bounding set */
kernel_cap_t cap_ambient; /* Ambient capability set */
#ifdef CONFIG_KEYS
unsigned char jit_keyring; /* default keyring to attach requested
* keys to */
struct key __rcu *session_keyring; /* keyring inherited over fork */
struct key *process_keyring; /* keyring private to this process */
struct key *thread_keyring; /* keyring private to this thread */
struct key *request_key_auth; /* assumed request_key authority */
#endif
#ifdef CONFIG_SECURITY
void *security; /* subjective LSM security */
#endif
struct user_struct *user; /* real user ID subscription */
struct user_namespace *user_ns; /* user_ns the caps and keyrings are relative to. */
struct group_info *group_info; /* supplementary groups for euid/fsgid */
struct rcu_head rcu; /* RCU deletion hook */
} __randomize_layout;

内核态函数

相比用户态库函数,内核态的函数有了一些变化

  • printf() -> printk(),但需要注意的是 printk() 不一定会把内容显示到终端上,但一定在内核缓冲区里,可以通过 dmesg 查看效果

  • memcpy() -> copy_from_user()/copy_to_user()

    • copy_from_user() 实现了将用户空间的数据传送到内核空间
    • copy_to_user() 实现了将内核空间的数据传送到用户空间
  • malloc() -> kmalloc(),内核态的内存分配函数,和 malloc() 相似,但使用的是 slab/slub 分配器

  • free() -> kfree(),同 kmalloc()

另外要注意的是,kernel 管理进程,因此 kernel 也记录了进程的权限。kernel 中有两个可以方便的改变权限的函数:

  • int commit_creds(struct cred *new)
  • struct cred* prepare_kernel_cred(struct task_struct* daemon)

从函数名也可以看出,执行 commit_creds(prepare_kernel_cred(0)) 即可获得 root 权限,0 表示 以 0 号进程作为参考准备新的 credentials。

执行 commit_creds(prepare_kernel_cred(0)) 也是最常用的提权手段,两个函数的地址都可以在 /proc/kallsyms 中查看,需要root权限

参考

ctf-wiki