前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >合法修改只读数据

合法修改只读数据

作者头像
用户7244416
发布2021-08-06 16:01:46
1.1K0
发布2021-08-06 16:01:46
举报
文章被收录于专栏:Linux内核远航者Linux内核远航者
  • 1.开场白

环境:

处理器架构:arm64

内核源码:linux-5.11

ubuntu版本:20.04.1

代码阅读工具:vim+ctags+cscope

对于Linux程序员来说,我们都知道一个事实:程序不能写只读数据,一旦去写就会发生段错误。但是可能大多数人并不清楚为什么会发生段错误,那么本篇文章就来说说:从只读数据被映射到进程的虚拟地址空间到写访问发生段错误的整个过程,力求让大家搞清楚这里面的底层内核原理,讲完整个过程之后我们来通过一个示例代码让修改只读数据变得合法,那么我们现在开始吧!

  • 2. 这个段错误好眼熟

下面我们看一个简单的测试代码:

代码语言:javascript
复制
 1 #include <unistd.h>
 2 #include <stdio.h>
 3 
 4 
 5 int main(int argc, char **argv)
 6 {
 7         char *buf = "hello";
 8 
 9         printf("buf:%p buf[0]:%c\n", buf, buf[0]);
10        
11 #if 0   
12         pause();
13 #endif
14         buf[0] = 'a';
15  
16         return 0;
17 }
                                    

我们编译执行:

代码语言:javascript
复制
# ./test
buf:0xaaaad0600860 buf[0]:h
Segmentation fault

当我们读访问只读数据时,能够正常访问;写只读数据时会发生段错误;我们分析代码可以发现程序中第14行写只读数据导致的段错误。那么你是否知道,究竟段错误是如何产生的?那么下面几节我们就来分析下段错误产生的整个过程。

  • 3. 要从exec说起

我们首先打开第11行的宏,让发生写访问之前,程序睡眠,然后编译后台运行。

代码语言:javascript
复制
后台运行测试程序
# ./test&
# buf:0xaaaaae2108a0 buf[0]:h



查看进程pid
# pidof test
1727

查看内存映射
# cat /proc/1727/maps 
aaaaae210000-aaaaae211000 r-xp 00000000 00:19 8666193                    /mnt/test
aaaaae220000-aaaaae221000 r--p 00000000 00:19 8666193                    /mnt/test
aaaaae221000-aaaaae222000 rw-p 00001000 00:19 8666193                    /mnt/test
aaaae0918000-aaaae0939000 rw-p 00000000 00:00 0                          [heap]
ffffbb761000-ffffbb8a2000 r-xp 00000000 fe:00 152                        /lib/libc-2.27.so
ffffbb8a2000-ffffbb8b1000 ---p 00141000 fe:00 152                        /lib/libc-2.27.so
ffffbb8b1000-ffffbb8b5000 r--p 00140000 fe:00 152                        /lib/libc-2.27.so
ffffbb8b5000-ffffbb8b7000 rw-p 00144000 fe:00 152                        /lib/libc-2.27.so
ffffbb8b7000-ffffbb8bb000 rw-p 00000000 00:00 0 
ffffbb8bb000-ffffbb8d8000 r-xp 00000000 fe:00 129                        /lib/ld-2.27.so
ffffbb8e2000-ffffbb8e4000 rw-p 00000000 00:00 0 
ffffbb8e4000-ffffbb8e6000 r--p 00000000 00:00 0                          [vvar]
ffffbb8e6000-ffffbb8e7000 r-xp 00000000 00:00 0                          [vdso]
ffffbb8e7000-ffffbb8e8000 r--p 0001c000 fe:00 129                        /lib/ld-2.27.so
ffffbb8e8000-ffffbb8ea000 rw-p 0001d000 fe:00 129                        /lib/ld-2.27.so
fffffa3f4000-fffffa415000 rw-p 00000000 00:00 0                          [stack]

可以看到关于test可执行文件的映射有3个段:第一个为可读可执行、第二个为只读、第三个为可读可写;按照我们的直觉:第一个应该是代码段、第二个应该是只读数据段、第三个数据段,但是实际上真的是这样吗?

我们查看打印的buf的地址为0xaaaaae2108a0,正好落在第一个映射中,所以我们知道了.text和.rodata放在了一个段中了 。

我们查看test可执行文件的程序头表:

代码语言:javascript
复制
$ aarch64-linux-gnu-readelf -l test


Elf file type is DYN (Shared object file)
Entry point 0x6a0
There are 9 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
...
 LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x00000000000009e4 0x00000000000009e4  R E    0x10000
...

Section to Segment mapping:
  Segment Sections...
   00     
    ...
    02     .interp .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .text .fini .rodata .eh_frame_hdr .eh_frame 
   ...
    

也可以发现编译器将.text和.rodata 放在一个段中,然后用一个程序头表项来描述,段的类型为load(需要加载到进程地址空间), Flags为只读可执行

进程访问用户空间的地址,需要首先要获得一块虚拟内存,可以通过mmap获得。下面我们来看如何将这个段映射为一个vma的,这个工作是在exec的时候来做的:

代码语言:javascript
复制
...
do_execve/do_execveat   //fs/exec.c
-> do_execveat_common
    -> bprm_execve
        -> exec_binprm
            -> search_binary_handler
                -> fmt->load_binary(bprm)
                    -> load_elf_binary   //fs/binfmt_elf.c
                    1030         for(i = 0, elf_ppnt = elf_phdata;                   
                    1031         ¦   i < elf_ex->e_phnum; i++, elf_ppnt++) { 
                    ...
                    1037                 if (elf_ppnt->p_type != PT_LOAD)   //映射load类型的段
                    1038                         continue;                  
                    ...
                    1067                 elf_prot = make_prot(elf_ppnt->p_flags, &arch_state,   
                    1068                                 ¦    !!interpreter, false); //根据elf文件的程序头表项获得vma的读写执行权限
                                                            ->573         if (p_flags & PF_R)                
                                                            574                 prot |= PROT_READ;
                                                            575         if (p_flags & PF_W)     
                                                            576                 prot |= PROT_WRITE;        
                                                            577         if (p_flags & PF_X) 
                                                            578                 prot |= PROT_EXEC;         

                    ...                        
                    1138                 error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,   
                    1139                                 elf_prot, elf_flags, total_size); //通过mmap映射vma

在load_elf_binary中会解析可执行文件的文件头,找到程序头表,然后解析程序头表的每一个表项,对每一个可加载的段进行映射,这里有两个段(上面通过readelf -l工具可以看到), 分配映射为只读可执行和可读可写不可执行,然后我们就可以通过cat /proc/1727/maps 看到我们映射的段(还有一个只读的段,实际上是映射GNU_RELRO类型的段,这是动态链接器通过mprotect来做的映射)。

  • 4. 我写只读数据试试

映射好了vma只能说明我们有一段虚拟内存关联了可执行文件的一个段,并没有分配物理内存,实际上这个过程发生在第一次访问只读数据或者访问.text的时候发生预读等操作的时候。这里当我们写只读数据的时候,即是执行buf[0] = 'a'语句的时候,假如buf[0] 地址所在的虚拟页还没有映射物理页(没有填写相关页表), 那么arm64处理器将发生转换表错误的异常(实际上,如果先读只读数据,就像代码中写那样,那么就首先建立了只读数据的虚拟页和物理页的页表映射,然后再次进程写访问的时候,就会发生访问权限错误的异常),将进入linux内核的异常处理的路径中:

代码语言:javascript
复制
el0_sync  //arch/arm64/kernel/entry.S
  ->el0_sync_handler
      ->el0_da //发生el0数据访问异常
          ->do_mem_abort
              ->inf->fn()
                  ->do_translation_fault
                      ->do_page_fault
                       
                            538         } else if (is_write_abort(esr)) {       //写导致的异常         
                            539                 vm_flags = VM_WRITE;                      
                            540                 mm_flags |= FAULT_FLAG_WRITE;             
                            541         }                                                 
                            ...
                            __do_page_fault   //缺页异常处理

do_page_fault中,会根据esr来判断是否为写导致的异常,最终执行__do_page_fault来处理缺页异常。

  • 5. 不行,发生异常了

在__do_page_fault函数中:

代码语言:javascript
复制
__do_page_fault
490         ¦* Check that the permissions on the VMA allow for the fault which    
491         ¦* occurred.                                                          
492         ¦*/                                                                   
493         if (!(vma->vm_flags & vm_flags))                                      
494                 return VM_FAULT_BADACCESS;                                    

在do_page_fault的538 到 541 判断了是写导致的异常,然后设置vm_flags = VM_WRITE,在__do_page_fault中就会通过vma->vm_flags 来判断vma的是否有写权限,从而很早的时候就拦截非法的地址访问(由访问权限造成的)

这里我们知道exec的时候映射vma的属性为只读可执行,并没有写权限,所有__do_page_fault直接访问VM_FAULT_BADACCESS。

  • 6. 抱歉,内核发送死亡信号

当从__do_page_fault返回到do_page_fault的时候会进入如下处理路径:

代码语言:javascript
复制
do_page_fault
->      636         } else {                                                                                  
        637                 /*                                                                                
        638                 ¦* Something tried to access memory that isn't in our memory                      
        639                 ¦* map.                                                                           
        640                 ¦*/                                                                               
        641                 arm64_force_sig_fault(SIGSEGV,                                                    
        642                                 ¦     fault == VM_FAULT_BADACCESS ? SEGV_ACCERR : SEGV_MAPERR,    
        643                                 ¦     far, inf->name);                                            
        644         }                                                                                         
        645                                                                                                   

由于 fault == VM_FAULT_BADACCESS ,所以在 641 内核就会向进程发生SEGV_ACCERR的死亡信号杀手进程,就即是发生了段错误的位置

  • 7. 合法修改只读数据

上面几节我们详细分析了,修改只读数据为何发生段错误的过程和原因,那么下面我们就想合法修改只读数据怎么办,我们直观上知道需要修改只读数据的页表属性为可写,但是需要在改写页表之前需要保证页表已经存在,那么我们可以先读访问只读数据(当然这里.text和.rodata在一个段,由于文件预读等操作,访问.text的时候已经建立好了只读数据的相关映射)。

那么我们下面的代码通过两种方式来修改只读数据的页表,一种是我们通过访问一个字符设备来修改页表(字符设备驱动程序所作的工作就是遍历各级页表,然后将相关的叶子表项修改为可写),一种是通过mprotect来实现

下面截取应用代码:

代码语言:javascript
复制
#define DEV_FILE_NAME "/dev/modify_ro_dev"
char *buf = "Hello, I'm read-only data!";
char hacker_str[] = "Hi, The read-only data has been modified!";

int main(int argc, char **argv)
{
 int fd;
 int ret;
 modify_args_t modify_args;

 /* Ensure that the access read-only data page table exists */
 puts("Before ioctl!");
 printf("<line:%d> &buf:%p\n",__LINE__,  buf);
 printf("<line:%d> Print out buf content ---->\n", __LINE__);
 puts(buf);


#if 1
/***************************ioctl call driver************************************/
 fd = open(DEV_FILE_NAME, O_RDWR);  //打开字符设备
 if (fd < 0) {
  printf("fail to open %s\n", DEV_FILE_NAME);
  return -1;
 }

#if 0
 memcpy(buf, hacker_str, sizeof(hacker_str)); //Segmentation fault
 printf("<line:%d> After modifying the read-only data!", __LINE__);
 puts(buf);
#endif


 modify_args.addr = (unsigned long)buf;   //设置修改只读数据的参数
 modify_args.size = 4096;
 modify_args.prot_flags = F_PROT_WRITE;

 ret = ioctl(fd, CMD_MODIFY_RO_BY_PGTABLE, &modify_args); //调用字符设备的ioctl方法来修改只读数据为可写
 if (ret) {
  printf("fail to ioctl: ret=%d\n", ret);
  return -1;
 }
 puts("\nAfter ioctl WRITE!");

 memcpy(buf, hacker_str, sizeof(hacker_str)); //access ok    写访问只读数据
 puts("After modifying the read-only data!");

 printf("<line:%d> Print out buf content ---->\n", __LINE__);   //打印修改之后的只读数据内容
 puts(buf);
#if 0
 modify_args.prot_flags = F_PROT_READONLY;     //重新将页表属性改为只读
 ret = ioctl(fd, CMD_MODIFY_RO_BY_PGTABLE, &modify_args);
 if (ret) {
  printf("fail to ioctl: ret=%d\n", ret);
  return -1;
 }

 puts("\nAfter ioctl READONLY");

 buf[0] = 'O';//Segmentation fault

 printf("<line:%d> Print out buf content ---->\n", __LINE__);
 puts(buf);
#else
 buf[0] = 'O';//access ok

 puts("\nAfter ioctl WRITE!");
 printf("<line:%d> Print out buf content ---->\n", __LINE__);
 puts(buf);
#endif

 close(fd);
#else
/*************************mprotect syscall**************************************/
 unsigned long addr = (unsigned long)buf & ~(4096 -1);
 printf("<lien:%d> &addr:%lx###\n", __LINE__, addr);
 ret = mprotect((void *)addr, 4096, PROT_READ | PROT_WRITE | PROT_EXEC);  //mprotect方式修改内存访问属性
 if (ret) {
  perror("fail to mprotect");
  return -1;
 }
 printf("<lien:%d> mprotect PROT_WRITE ok!\n", __LINE__);
 memcpy(buf, hacker_str, sizeof(hacker_str)); //access ok
 printf("<line:%d> After modifying the read-only data!\n", __LINE__);
 puts(buf);
 ret = mprotect((void *)addr, 4096, PROT_READ | PROT_EXEC);
 if (ret) {
  perror("fail to mprotect");
  return -1;
 }
 printf("\n<lien:%d> mprotect PROT_READ ok!\n", __LINE__);
 memcpy(buf, hacker_str, sizeof(hacker_str)); //Segmentation fault
 printf("<line:%d> After modifying the read-only data!\n", __LINE__);
 puts(buf);
#endif  /* by ioctl */


 pause();

 return 0;
}                            

驱动代码中的处理:

代码语言:javascript
复制
modify_ro_dri.c:
static long modify_ro_dri_unlocked_ioctl(struct file *file,                                 
                                        ¦unsigned int cmd,                                  
                                        ¦unsigned long arg)                                 
{                                                                                           
        modify_args_t modify_args;                                                          
        long ret;                                                                           
                                                                                            
        dprintk(DEBUG, "###%s:%d###\n", __func__, __LINE__);                                
                                                                                            
        if (copy_from_user(&modify_args, (modify_args_t *)arg, sizeof(modify_args))) {  //拷贝用户传递来的修改只读参数    
                dprintk(ERROR, "fail to copy_from_user\n");                                 
                return -EINVAL;                                                             
        }                                                                                   
                                                                                            
        switch (cmd) {                                                                      
        case CMD_MODIFY_RO_BY_PGTABLE:                                                      
                dprintk(DEBUG, "### Modify the read-only data page table ###\n");           
                ret = modify_pgprot(&modify_args);     //修改页表属性                                     
                break;                                                                      
        default:                                                                            
                dprintk(ERROR, "%s: fail to ioctl\n", __func__);                            
                return -EFAULT;                                                             
        }                                                                                   
                                                                                            
        return ret;                                                                         
}                                                                                           

下面我们首先来看一下效果:

编译拷贝驱动代码和应用代码到qemu的共享目录:

代码语言:javascript
复制
$ make modules
$ make install 
cp *.ko ~/kernel/linux-5.11-arm64/kmodules

启动qemu进入/mnt目录:

代码语言:javascript
复制
加载驱动代码:
# insmod modify_ro.ko
[ 6902.670804] ### modify_ro_dri_init:113 ###

执行应用代码:
# ./modify_ro_app 
Before ioctl!
<line:41> &buf:0xaaaabc980c48
<line:42> Print out buf content ---->
Hello, I'm read-only data!
[ 6942.837136] Modify [addr:=0x0000aaaabc980c48, size=0x1000] write ok!

After ioctl WRITE!
After modifying the read-only data!
<line:75> Print out buf content ---->
Hi, The read-only data has been modified!

After ioctl WRITE!
<line:95> Print out buf content ---->
Oi, The read-only data has been modified!

可以看到我们访问的只读数据内容原本为:Hello, I'm read-only data!

然后应用打开字符设备,通过ioctl设置只读数据的页表属性为可写:Modify [addr:=0x0000aaaabc980c48, size=0x1000] write ok!

修改完之后,我们再来写访问。

我们看到现在只读数据已经变为:Hi, The read-only data has been modified!

我们修改只读数据成功!

大家也可以打开不同的宏开关,体验下:1.不修改页表属性为可写,直接写访问。2. 修改可写属性之后,再次修改为只读属性,然后写访问。3.使用mprotect方式来修改页表属性。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-05-03,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Linux内核远航者 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档