前言
最近完成了一个需要修改和编译linux内核源码的操作系统实验,个人感觉这个实验还是比较有意思的。这次实验总共耗时4天,从对linux实现零基础,通过查阅资料和不断尝试,直到完成实验目标,在这过程中确实也收获颇丰,特此记录
实验内容
- 实现系统调用int hide(pid_t pid, int on),在进程pid有效的前提下,如果on置1,进程被隐藏,用户无法通过ps或top观察到进程状态;如果on置0且此前为隐藏状态,则恢复正常状态(考虑权限问题,只有root用户才能隐藏进程)
- 设计一个新的系统调用int hide_user_processes(uid_t uid, char *binname),参数uid为用户ID号,当binname参数为NULL时,隐藏该用户的所有进程;否则,隐藏二进制映像名为binname的用户进程
- 在/proc目录下创建一个文件/proc/hidden,该文件可读可写,对应一个全局变量hidden_flag,当hidden_flag为0时,所有进程都无法隐藏,即便此前进程被hide系统调用要求隐藏。只有当hidden_flag为1时,此前通过hide调用要求被屏蔽的进程才隐藏起来
- 在/proc目录下创建一个文件/proc/hidden_process,该文件的内容包含所有被隐藏进程的pid,各pid之间用空格分开
实现思路
对于要求1,首先要修改PCB,对应到源码里面就是task_struct,在其中添加一个属性hide,用来表示该进程是否需要隐藏;然后修改复制进程的系统调用,用于给hide属性设置默认值0;最后修改列举所有进程的系统调用,在其中加入一个判断,如果进程的hide是1则不展示这个进程
(注:也有方法说是可以通过把pid设置为0来达到隐藏的效果,但是实测下来,在5.15.60的kernel里面,这样做不能隐藏,所以只能通过劫持系统调用来实现)
对于要求2,则可以遍历所有进程,把符合条件的进程的hide设置为1即可
对于要求3,最开始以为可以通过用户态的文件操作来实现,结果后来发现/proc是个虚拟文件系统,所以需要在初始化proc文件系统时,添加一个hide条目,然后设置这个条目的write函数,来达到创建该文件的目的
对于要求4,也是用和要求3一样的思路,只是这里需要设置read函数,然后遍历所有进程,把hide为1的pid全部返回
实验环境
操作系统使用的ubuntu 22.04
linux kernel代码版本是5.15.60
虚拟机使用的是VM Ware Workstation Pro 16
注意:虚拟机硬盘大小建议为60GB,编译内核代码非常吃硬盘,本人在实验中前前后后扩容了几次硬盘,最终发现60GB是个比较合适的大小,内核源码编译安装之后还能剩15GB左右(下一次编译安装还需要一些硬盘空间做缓存,所以剩15GB是比较合适的)
实验流程
编译与安装内核
参考https://www.cnblogs.com/robotech/p/16152269.html 即可,如果这部分出错了,网上可以找到的资料很多,这里不再赘述了
不过这一步一定要有耐心,源码编译很慢,第一次全量编译估计会耗时一个多小时,可以用这个闲暇时间玩玩原神
完成要求一
把编译和安装的流程跑通以后,就开始进行源代码的修改了
修改PCB
linux的PCB结构体是task_struct
,这个定义位于include/linux/sched.h
Tips:如果想在linux源码里找东西,可以用https://elixir.bootlin.com/ 这个网站,左边选择版本,右边输入关键字,即可查询到
在linux使用vim打开这个文件,往下翻,看到这段注释
按照提示添加属性即可
修改fork
这部分的源码是在kernel/fork.c
中的copy_process
函数里面
阅读源码,在合适的地方插入初始化hide属性的代码即可,这里我选择的位置是复制完进程信息之后,即下图所示的位置
添加系统调用
这部分我是看网上的各种文章,东拼西凑,进行多次实验之后才跑通的,事后想想,我应该最先去看linux kernel的官方手册
https://docs.kernel.org/process/adding-syscalls.html (附上官方手册)
以下是我自己添加系统调用的过程,这里用添加一个输出Hello World的简单系统调用来举例子
首先找到kernel/sys.c
,在文件末尾使用SYSCALL_DEFINE
宏来定义系统调用的函数体
SYSCALL_DEFINE0(hello)
{
printk("hello world.114514\n");
return 0;
}
解释一下,SYSCALL_DEFINE0(hello)
表示定义一个含有0个参数的系统调用,名字是hello,通过查看sys.c
里面其它函数的定义代码可以得知,如果想要添加一个只有一个参数的系统调用,那么应该使用SYSCALL_DEFINE1(hide,pid_t,pid)
,其中hide是系统调用的名字,pid_t是第一个参数类型,pid是第一个参数的名字,2个参数的同理
printk是输出日志,这个日志可以在sudo dmesg
里面看到,printk支持使用%d,%s等对输出进行格式化,用法类似于printf
接下来修改系统调用表,在arch/x86/entry/syscalls/syscall_64.tbl
中合适的地方添加刚才写的系统调用,这里我是添加在了334号系统调用之后的
仿照上面334写即可,其中hello是自己随便起的名字,而sys_hello是系统调用的函数名,这个函数名是上面SYSCALL_DEFINE0
里写的函数名前面加上前缀sys_得到的
添加完成后可以尝试编译运行一下新的内核
可以使用uname -a
查看当前内核是不是最新编译的(看时间即可)
下面将使用一段代码来测试一下新添加的335号系统调用
#include <linux/kernel.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <stdio.h>
int main(int argc,char **argv)
{
printf("System call return %ld\n",syscall(335));
return 0;
}
运行程序
然后使用sudo dmesg
查看
编写hide系统调用
hide系统调用的实现有2个思路,一个是遍历所有进程,找到pid相符的进程,然后设置hide,另一个思路是通过pid找到进程,然后直接设置,这里采取后者
通过查找资料,可以得知,根据pid查找进程是用这段代码
pid_task(find_vpid(pid),PIDTYPE_PID);
最终完整的系统调用代码如下
SYSCALL_DEFINE2(hide,pid_t,pid,int,on)
{
struct task_struct * me = NULL;
me=pid_task(find_vpid(pid),PIDTYPE_PID);
if(current->uid != 0){
//User is not root
return 0;
}
if(me == NULL){
return 0;
}
if( on == 1 )
{
me->hide = 1;
}
else
{
if( me->hide == 1 )
{
me->hide = 0;
}
}
return 0;
}
接下来再修改系统调用表即可完成系统调用的添加
劫持获取所有进程的函数
现在已经可以通过系统调用来设置PCB里面的hide,下一步就是修改列举所有进程的函数,让它在列举时判断一下,如果hide==1就不列举
proc文件系统
在劫持之前,需要简单介绍一下proc文件系统。在linux根目录下,有一个/proc文件夹,这其实并不是在磁盘上真实存在的文件,而是一个虚拟文件系统。
proc文件夹里面有很多个以pid为名字的文件夹,这些文件夹里面又有若干个文件,读取这些文件就可以获取这个进程的相关信息,例如想查看pid为1的程序的名字可以使用sudo cat /proc/1/comm
这一系列操作在系统底层的实现是:系统在启动的时候就挂载了一个proc虚拟文件系统,当用户访问proc文件夹下的文件时,系统会调用proc文件系统里面相关的函数,而不是常规文件系统的函数,例如在执行ls /proc
时,实际上系统会调用位于s/proc/base.c
里面的proc_pid_readdir
函数,这个函数会获取当前系统中所有的进程,随后会有函数把这个函数的返回值写入到读取文件操作的缓冲区中
修改代码
所以,我们的突破口就是proc_pid_readdir
函数,在阅读这个函数的代码之后,可以找到突破口是一个put_task_struct
函数的调用,如下图
那么只需要在这个if里面加上一个条件,即必须这个进程不被隐藏才能put,即可完成劫持
结果验证
在修改完源代码之后,重新编译和安装内核,启动新的内核
使用下面这段代码来测试
#include <linux/kernel.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main(int argc,char **argv)
{
int pid;
int hide;
scanf("%d %d",&pid,&hide);
printf("System call return %ld\n",syscall(336,pid,hide));
return 0;
}
编译运行程序
从图中可以看出,进程顺利隐藏,并且能够重新展示,要求一顺利实现
完成要求二
要求二是在要求一的基础上进行一些简单的扩展,这里可以使用一个比较暴力的思路,就是遍历所有进程,然后挨个判断uid和进程名称,把符合要求的进程的hide设置为1即可
这里只有三点需要注意一下
1.遍历所有进程可以使用for_each_process
这个宏来完成,这个宏有类似于for循环的作用,用法如下
struct task_struct* p;
for_each_process(p){
//Do something.....
}
这个宏的定义在include/linux/sched/signal.h
里面,定义如下
#define for_each_process(p) \
for (p = &init_task ; (p = next_task(p)) != &init_task ; )
2.用户态的字符串不能在内核态直接使用,需要调用strncpy_from_user
把用户态的字符串复制到内核态的缓冲区才能使用,方法如下
char tmp_buf[256];
if(binname != NULL)
strncpy_from_user(tmp_buf,binname,256);
最终的系统调用代码如下
SYSCALL_DEFINE2(hide_user_processes,uid_t,uid,char*,binname)
{
uid_t curr_uid=current->uid;
if(curr_uid != 0){
//User is not root
return 0;
}
char tmp_buf[256];
if(binname != NULL)
strncpy_from_user(tmp_buf,binname,256);
struct task_struct* p=NULL;
for_each_process(p){
if(p->real_cred->uid.val == uid){
if(binname == NULL){
p->hide=1;
}else{
char* s=p->comm;
int identical=1;
int i=0;
for(i=0;tmp_buf[i]!='\0' && s[i] != '\0';i++){
if(tmp_buf[i] != s[i]){
identical=0;
break;
}
}
if(tmp_buf[i] != s[i])
identical=0;
if(identical == 1){
p->hide=1;
}
}
}
}
return 0;
}
在编译和安装完成之后可以写一段测试代码来验证一下代码的正确性
#include <linux/kernel.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <stdbool.h>
int main(int argc,char **argv)
{
int uid;
char binname[20];
scanf("%d %s",&uid,binname);
printf("%s\n",binname);
bool noBinname=false;
if(strcmp(binname,"no") == 0){
printf("Bin name set null\n");
noBinname=true;
}
printf("System call return %ld\n",syscall(337,uid,noBinname?NULL:binname));
return 0;
}
编译运行该程序
要求二完成
完成要求三
思路分析
之前提到过的,proc文件系统是一个虚拟文件系统,读取和写入proc文件夹下的文件的操作会交给一些特定的内核函数来执行,那么我们只需要添加一个/proc/hide条目,并配置这个条目的write函数,当write被调用的时候就根据写入的值设置一个全局变量,然后再修改proc_pid_readdir函数,添加一个判断,如果这个全局变量为0就不隐藏任何进程,这样就可以达到设置全局开关的目的
全局变量的定义和使用
全局变量可以跨文件被使用,在需要使用全局变量的地方使用extern
关键字声明全局变量即可
需要注意的是,全局变量需要进行一次初始化,并且仅可以进行一次初始化
具体而言,可以这样操作:在需要使用全局变量hidden_flag的c文件里面使用下面这条语句进行声明
extern int hidden_flag;
然后在某个c文件中对hidden_flag变量进行定义
extern int hidden_flag;
int hidden_flag=1;
注意:声明是告诉编译器我这里有一个名叫hidden_flag的变量,我接下来会用这个变量,这个变量具体在哪需要编译器自己去找;而定义则是告诉编译器我新建了一个名为hidden_flag的变量,相当于真正为这个变量分配了内存空间
添加proc条目
大体流程
通过查阅资料和反复实验,我找到了在5.15.60版本添加proc条目的方法
proc文件系统的初始化函数在fs/proc/root.c
里面,名叫proc_root_init
网上很多教材是要修改一个名叫proc_misc_init
函数,但是在这个版本的内核源码里面找不到这个函数,所以索性就在proc_root_init
函数里面添加条目了(因为看网上的代码,root_init是会调用misc_init的,所以猜测直接在root_init里面添加应该也是可以的,最后实践证明确实可行)
添加proc条目需要调用proc_create
函数,该函数的定义如下:
struct proc_dir_entry *proc_create(const char *name, umode_t mode, struct proc_dir_entry *parent, const struct proc_ops *proc_ops);
可以看到,这个函数需要4个参数,第一个是文件名,这里要创建一个/proc/hidden,所以这个参数传hidden;第二个参数是权限,为了防止后续因为权限问题导致实验翻车,这里就给666了;第三个是parent,传NULL即可;第四个是这个条目操作的配置项的指针,可以在这里配置该条目的read和write函数
下面开始添加proc条目
首先要实现该条目的read和write函数,当用户态程序读取和写入/proc/hidden时,这两个函数就会被调用
read函数
下面是read函数的定义
ssize_t hidden_read_proc(struct file *filp,char *buf,size_t count,loff_t *offp);
第一个参数是文件;第二个参数是用户态的读取缓冲区,我们需要往这个缓冲区里面写数据来完成读取操作;第三个参数是这个用户态缓冲区的大小;第四个参数是上一次读取的位置,因为可能出现缓冲区不够等情况,用户态程序在读文件时通常是用下面的方式进行多次读取的
char buf[256];
int len;
while((len=read(buf))!=0){
//此时buf中读取了len字节的数据,进行相应处理
}
所以read函数要做的事情就是往缓冲区中写入数据,修改offp,然后返回已经读入的字节数,下面是/proc/hidden条目的read函数的实现
ssize_t hidden_read_proc(struct file *filp,char *buf,size_t count,loff_t *offp )
{
if(*offp > 0)
return 0;
char msg[256];
int len=sprintf(msg,"Current flag is %d\n",hidden_flag);
copy_to_user(buf,msg,len);
*offp=len;
return len;
}
需要注意的是,如果,没有最开始判断offp这一行,那么会出现读取/proc/hidden文件读不完的情况,具体而言,如果使用指令cat /proc/hidden
,那么它会一直源源不断地蹦出字符,不会停,这是因为read函数始终不会返回0,导致那个while循环不会停
此外,同样的,内核态的内存和用户态的内存是不互通的,需要使用copy_to_user
函数来完成内存的拷贝
write函数
write函数的定义如下
ssize_t hidden_write_proc(struct file *filp,const char *buf,size_t count,loff_t *offp);
参数的意义和read是类似的,第二个参数是用户即将写入的数据缓冲区地址,第三个则是数据量,以下是write函数的具体实现
ssize_t hidden_write_proc(struct file *filp,const char *buf,size_t count,loff_t *offp)
{
char msg[2056];
copy_from_user(msg,buf,count);
hidden_flag=msg[0]-'0';
return count;
}
需要注意的是,同样的,需要进行从用户态到内核态的内存拷贝
proc_ops结构体
接下来新建一个proc_ops结构体的对象,传入我们写的read和write函数
struct proc_ops hidden_proc_fops = {
proc_read: hidden_read_proc,
proc_write: hidden_write_proc
};
当然,这个结构体还支持我们配置更多的内容,具体可以看这个结构体的定义,这里不再赘述了
调用proc_create
最后调用proc_create,传入参数,即可完成条目的创建
proc_create("hidden",666,NULL,&hidden_proc_fops);
结果验证
我们重新编译安装内核,然后隐藏一个进程,然后再向/proc/hidden里面写入0
可见,hidden_flag起效果了,要求三完成
完成要求四
有了要求三的铺垫,要求四就显得比较简单了,只需要实现一个read函数,在其中遍历所有进程,把hide为1的进程pid返回即可
read函数的实现如下
ssize_t pid_read_proc(struct file *filp,char *buf,size_t count,loff_t *offp )
{
if(*offp > 0)
return 0;
char msg[1024];
int len=0;
struct task_struct* p;
for_each_process(p){
if(p->hide == 1){
len += sprintf(msg+len,"%d ",p->pid);
}
}
copy_to_user(buf,msg,len);
*offp=len;
return len;
}
可以把1000用户所有进程隐藏了,然后查看/proc/hidden_process文件来检查效果
可以在里面看到所有被隐藏的进程的pid,要求四完成
总结与心得
这次实验的代码量并不多,操作步骤也不复杂,主要的时间都花在了学习linux内核编程上面了。从零开始学习proc文件系统,linux源码,并建立临时知识体系,然后根据学到的东西进行开发实践,这是一个充满挑战性但也非常有意思的过程。在这过程中,我学到了linux内核编程的技术,跑通了从内核源码修改到最终运行的全流程,并对proc虚拟文件系统进行了更深入的自学,完成了四个实验要求。
个人感觉这过程中查资料自学的效率有点低,下次遇到此类问题应该首先查找官方的手册和教程,而不是在网上胡乱找相关的文章。
总的而言,收获很多,这是一次非常有意思的经历。
Anyway,写这篇博客也是记录一下这次实验的经历,感悟和收获,同时也为其他做这个实验的同学提供一点过来人的经验,希望能起到避坑的效果。