硬核思路看安全--内核篇:基础知识(五)16位实模式下的内存管理
前言
网络安全这个方向有太多东西可以讨论,从最常见的web安全、通信链路安全、协议安全、无线安全、系统安全、物联网安全、移动安全以及细分出来的密码学、可穿戴设备安全、车联网安全等等,完全是一颗拥有无数分支、枝繁叶茂的参天大树。
所以掌握事务的本质就变得很重要,也就是从万变中找到不变!,那么对于所有计算机系统和所有常见的基于计算机系统的设备来说什么是本质呢?是二进制?是CPU架构?是硬件体系?还是各种操作系统?
明显本质也是个复合混杂的东西,那么认知它的方式也很简单,各种各样的计算机系统必定符合一个简单的金字塔式的结构:
- 各个功能性的计算机网络(或者说是云),包括物联网设备接入的网络
- 各个计算机设备通过各种协议链接组成的网络
- 各种用于交互的软件和实现功能的软件,是用户直接操作的部分
- 操作系统的可见部分或者应用部分
- 操作系统的内核部分(包括系统软件、系统中断、系统软件驱动等等)
- 系统的硬件驱动
- 系统的硬件
正如互联网的结构,每个设备作为接入网络的一个端点,是整个互联网的最小支点,而每个设备根据其功能需求特化了硬件,再通过上面安装的软件程序实现功能。
如果把所有的硬件看做一个容器,软件看做主要的功能器具,那么操作系统的内核就是这个容器里专放功能器具的框框架架。
一个软件怎样实现其兢兢业业的功能?一个计算机病毒怎么实现它的破坏?一个木马后门怎么偷偷地伪造用户身份篡夺江山?一个杀毒软件又是怎样当好用户的锦衣卫?我们该如何学习内核?我们该如何分析新操作系统带来的新内核?一切尽在硬核思路看安全–内核篇!
- 注:我将以系统安全的视角来科普这些东西,整个篇章的更新顺序会按照类似于渗透测试思路的顺序,依次递进,比如:先是信息收集,我就会先讲解常见的CPU的架构信息和指令集信息和内核信息,以及如何收集这些信息,然后才是具体的涉及实操的内容,当涉及到某些基础知识和概念的时候也会优先讲概念知识。
基础知识补充
-
汇编编译器在编译过程中会生成一个过程文件,比如nasm汇编器在编译.asm汇编语言文件时,通过
-l
指令可以输出.lst
过程文件 -
在汇编编译器编译过程中的
.lst
过程文件会展示汇编地址、机器码、原汇编语言指令,如:汇编地址 机器码 汇编语言 00000000 B800B8 mov ax,0xb800 00000003 8EC0 mov es,ax - 编译好的程序加载到物理内存后,它在段内的偏移地址和在编译阶段生成的汇编地址其实是相同的
- 汇编语言里的标号,通过标号取地址时,实际上就是取的汇编地址,也就相当于取得了写入物理内存后的偏移地址
-
小端序 和 小端表示法决定了:获取的地址写入机器码时是倒过来写的.
比如一个汇编指令
mov ax,text
中text
标号的对应的汇编地址(相当于偏移地址)是012E
,从汇编编译写入为机器码后就是B82E01
这里的
012E
写作了2E01
-
小端序是一种存储多字节数据的方式,其中==最低有效字节存储在最低地址处,而最高有效字节存储在最高地址处==。
-
对于某些处理器架构(如x86和x86_64),小端序可以简化一些操作,例如直接访问最低有效字节。
-
小端序存储的数据在强制转换数据类型的时候,由于低位在低字节处,在简单类型转换的情况下是不需要调节字节序(或者说数据类型)的。
低精度向高精度转换:比如C语言里的
short
是两个字节,值是1234
,如果转换成四个字节的int
。那么无非就是从1234
转变成这个0x0001234
那么在数值上是不变的,只是存储形式上变了。高精度向低精度转换,也只是截断后面那部分而已,比如
int
数据0x0001234
,小端存储是34 12 00 00
,截断高地址的高精度保留低地址的低精度,结果就是34 12
-
-
大端序是另一种存储多字节数据的方式,其中最高有效字节(MSB)存储在最低地址处,而最低有效字节(LSB)存储在最高地址处。
- 许多网络协议(如TCP/IP)使用大端序来传输数据,因为这种顺序与人类阅读习惯一致(从左到右)。
- 大端序存储的数据,由于数据高位不仅表示数字本身,还起到符号的作用,所以容易判断正负
-
由于汇编的调试依赖于操作系统,不同操作系统的编译结果都会不一样,如果要不靠操作系统来调试汇编,可以使用Bochs软件来调试汇编,用虚拟机虚拟一块硬盘来查看汇编执行效果
-
汇编编译时,会默认偏移地址从0开始,段地址自动设置
-
一个物理地址,可以有不同的逻辑地址来表示,比如物理地址
0x07c00
可以为0x0000:0x7c00
或0x07c0:0x0000
-
栈也是内存,是一段有特殊读写方式的内存
-
无论什么语言还是高级语言,字符串在编译后都是以0结尾
汇编指令补充
-
在masm汇编编译器中,
$
是指当前行行首的标号,如果是&&
就是当前汇编段的起始地址比如 text: jmp near text
就是jmp near &
用&-&&就可以得到从当前行之前整个汇编段的字节数
-
neg
指令:将正数变成负数,负数变成正数,原理是用零减去操作数,如neg al
-
cbw
指令:无操作数参数,功能是将AL寄存器的数值扩展并存入AX寄存器(正数通过在前面补零,将其从8位变成16位。负数就补1)AL = 0100 1111 CBW AX = 0000 0000 0100 1111 AL = 1000 1101 CBW AX = 1111 1111 1000 1101
-
cwd
指令:无操作数参数,将AX中的有符号数扩展到DX:AX中(在前面提到的32位除法中,DX保存高位,AX保存低位,这里也是这种方式,将有符号数的符号位存入DX,从而实现扩展)AX = 0100 1111 0111 1001 ;正数无符号 CWD DX = 0000 0000 0000 0000 ;AX的内容不变 AX = 1100 1111 0111 1001 ;负数 CWD DX = 1111 1111 1111 1111 ;AX的内容不变
-
section
和segment
编译器关键词是一个意思,都是定义一个段,不同的汇编编译器对这两个词的支持可能不一样 -
通过
section
语法声明的段不使用end
来表明段的结束,一段代码里两个section
语句之间的所有内容就是一个完整的段,并且第二个section
也是第二个段的声明(如果只有一个段,那么通过section
声明段后,段里的代码写到哪里就在那里结束,整个代码里只有一个section指令) -
关键词
equ
相当于高级语言的“等于”,赋值操作,可以用于声明一个常数(常数的声明不会占用汇编地址)text_x EQU 100 ;声明一个常量text_x,它的值是100
-
in
指令访问端口的时候只能通过DX寄存器或立即数,8位端口是AL寄存器,16位是AX寄存器,如果是立即数的形式,立即数的大小不能超过255(0xFF),因为这里只能放一个字节 -
out
的操作数可以是8位立即数或DX,源操作数必须是AL或AX -
ror
是循环右移指令,rol
是循环左移指令,左移或右移过程中,每次被移出的位都会先存入到CF寄存器里,然后再放入操作数据的另一端(比如右移过程中,最右边的那一位被移出,先放入到CF标志位,然后其他位右移完成后,最左边的位空出,就将之前CF标志位存储的那一位内容放入最左边的空位,例如10000001B这个数右移1位变成11000000B),这是和普通左移右移命令(使用0填充)的最大区别 -
resb
指令用于跳过一段字节的空间,后面的参数可以是立即数,比如resb 256
就是跳过256字节
8086的内存管理
程序的重定位
- 为防止多程序运行时,程序都加载到内存中,出现不同程序间寻址出现地址重复(A程序的调用的物理地址B程序也在用),于是会划分逻辑段
- 人为划分的代码段和数据段(都是逻辑段),没有改变内存的物理性质
- 每种操作系统都对管理的程序提出了种种格式上的要求(比如:包含日期是哪天编译的;针对哪种操作系统或者程序的版本;代码的第一条指令在哪里?这个程序第一条指令从哪里开始?数据段从哪里开始?数据段有多长?代码段从哪里开始?有多长?),典型例子是windows的PE、linux的elf
8086的重定位
-
8086的内存是1Mb,8086是16位CPU(即寄存器为16个二进制位(2个字节)大小)
-
分段管理机制
- 段地址和偏移地址:8086将1MB的内存空间划分为多个逻辑段,每个段最大64KB。
- 段地址是段起始地址的高16位,存放在16位的段寄存器(如CS、DS、SS、ES)中;
- 偏移地址是段内某单元相对于段起始地址的偏移量,也是16位,存放在通用寄存器(如IP、SI、DI、BX等)或指令中。
- 物理地址计算:CPU将16位的段地址左移4位(相当于乘以16),加上16位的偏移地址,得到20位的物理地址,从而访问1MB内存空间中的任意位置。
-
逻辑段的大小
- 段起始地址的要求:8086规定逻辑段的起始地址必须是16的倍数,即段地址的低4位必须为0。
- 段内偏移地址的范围:段内偏移地址是16位,范围从0到FFFFH,对应64KB的寻址范围。
- 最小逻辑段:当段内偏移地址从0开始,到15结束(即16个字节)时,就形成了一个最小的逻辑段。
逻辑段的划分
- 最多逻辑段数量:1MB内存空间最多可以划分为64K(1MB / 16B / 65536)个逻辑段。
- 最少逻辑段数量:1MB内存空间最少可以划分为16(1MB / 64KB / 65536)个逻辑段,每个段最大64KB。
-
物理地址划分为逻辑地址必须被16整除
-
偏移地址必须从零开始
8086的寻址方式
-
寄存器寻址:操作数位于寄存器中
mov ax,cx ;直接操作寄存器是寄存器寻址 add bx,0xff00 ;目的操作数是寄存器,就是寄存器寻址 inc bx
-
立即寻址:也叫立即数寻址,指令的第一个操作数是立即数
add bx,0xf000 ;指令的第一个操作数是寄存器,第二个是一个立即数,这个立即数也可以看做指令的第一操作数,所以它也是立即数寻址 mov dx,label ;标号地址是一个立即数(编译器设置的),所以就同上也是立即数寻址
-
内存寻址:因为cpu寄存器数量和长度有限,所以资源更大的内存寻址更普遍,内存寻址有几种方式:
-
直接寻址
mov ax,[0x1111] and word [0x023],0x555 xor byte [es:label],0x555
-
基址寻址:在指令的地址部分使用基址寄存器BX或BP来提供偏移地址
buffer dw 0x10,0x20,0x300 ;比如有一堆数据需要处理 inc word [buffer] inc word [buffer+2] inc word [buffer+4] ;上面是基址寻址的原理代码,比较复杂,现在更多用下面这种方式: mov [bx],dx ;中括号表示调用中括号里内容所在的默认段寄存器作为基址,中括号里的内容作为偏移地址,bx基址寄存器的默认段寄存器是DS,这里[bx]相当于DS:BX add byte [bp],0x222 mov ax,[bp] ;bp基址寄存器的默认段寄存器是SS ;基址寻址也可以在寄存器的基础上使用一个偏移量 mov dx,[bp-2] ;这里[bp-2]使用了一个偏移量,但bp-2不会改变bp本身的值,bx也是同理
-
变址寻址:类似于基址寻址,不过这种寻址方式使用的是变址寄存器(索引寄存器)DI、SI
mov [si],dx mov ax,[di] mov [si+0x222],al and byte [di+100],0x888
-
基址变址寻址
顾名思义,是基址加上变址的寻址方式,例如:mov ax,[bx+si] mov ax,[bx+di] mov ax,[bp+di] mov ax,[bp+si] mov ax,[bx+di+2]
-
分段内存管理机制及段的重定位(8086)
-
这里所谈到的段都是逻辑段,如果用汇编语言来说明,汇编语言里所有的栈段(stack对应SS寄存器)、数据段(data对应DS寄存器)、指令段(code对应CS寄存器)等等都是一种逻辑段
-
一个完整程序通常是分段的,包括了多种段来实现功能,在程序载入内存后,就需要重新计算段地址(这是因为,在编译时,这些地址是基于假设的位置计算的,而在实际加载时,段可能会被放置在不同的内存位置。),这就是段的重定位,这个过程也包括(主要包括)了程序的内存初始化
-
实模式(8086)的分段内存管理机制下,操作系统加载程序(包括外设)的过程就是段的重定位的过程,程序有很多种,但加载过程是固定的
-
汇编语言里通过
segment
或section
都可以定义一个段,每个段里的内容包括了当前segment
或section
到下一个segment
或section
(也就是下一个段)之间所有的内容,这中间包括data(数据段)、code(指令段)、stack(栈段)等等都可以定义,也就是一段完整的汇编代码 -
段在内存里面的起始物理地址必须是以16字节对齐的(是16的整倍数),保证初始偏移地址从零开始,从而让程序里关于偏移地址的调用正常准确
-
每个段都有一个汇编地址(不是偏移地址,是汇编编译时生成的地址,在
.lst
文件里可以看到,在加载到内存后会转化为偏移地址),相对于整个程序开头(从零开始0)的地址,这个地址是32位的section.段名称.start ;表达式可以取得该段的汇编地址
-
header
段是定义用户程序的头部段,内容包括用户程序的入口点、段重定位表等SECTION header vstart=0 ;定义用户程序头部段
-
vstart=0
就定义了当前这个段里的内容地址是相对于这个段从零开始计算的,没有vstart=0
段里的内容就是相对于整个程序的从零开始计算;比如: ...... ;假设到SECTION这一行前面共有55长度的代码 SECTION header vstart=0 text MOV AX,0x11 ;这里text标号对应的地址就是00000000 ;如果没有vstart=0,这里text的标号对应地址就是00000038(55的十六进制是37h)
-
在没有定义
vstart=0
时,段内的某个标号的地址就相当于从代码开头到标号所在位置的整个代码段的大小 -
如果是引导扇区的程序,往往vstart=0x7C00
-
程序加载
-
操作系统的程序加载器,就是利用的分段内存管理机制,从硬盘的扇区加载程序
-
加载器加载程序必须要了解一些必要的信息,这些信息不是很多,但是足以知道怎么加载用户程序
-
用户程序头部需要包含以下信息:
-
用户程序的大小,以字节为单位的大小,加载器需要根据这一信息来决定读取多少个逻辑扇区
-
应用程序的入口,(在汇编语言中,所有的标号本质都一样,是不是入口标号在于这个标号的内容是不是首先调用)
- 这里的start没有特殊含义,和其他标号一样,但是是程序首先调用的标号,所以就是入口地址
- 下面
section.code_1.start
是将入口程序所在的段地址获取出来 - 汇编地址是32位的,因为实模式下是20位的物理地址,16位的单元不能保存20位的长度,所以用两个16位即32位的地址来表示
-
段重定位表及其表项数(包括段重定位表、表项、表项数(表的大小))
- 这里的段重定位表里,左边依旧是标号,右边就是每个段入口标号的汇编地址
- 计算段重定位表项数这里是用下面的
header_end
标号减去code_1_segment
标号算出段重定位表的总长度,再除以dd
双字型数据的长度(两个字型就是4字节),header_end的汇编地址是前一指令地址0x1C加上dd的长度4字节,就是0x20,这里表项数就是(0x20-0x0c)/4=5
,对应代码的五个dd类型的汇编地址
-
-
程序加载过程中,会将程序编译好的汇编地址加载到内存中,这时会根据当前内存的情况将汇编地址转化为实际内存中的偏移地址(内存中可能已有其他的程序,所以实际地址不可能从0开始,需要转化)
-
加载器要加载程序并且执行,需要知道内存的什么地方是空闲的,从哪个物理地址开始加载用户程序,还要知道程序位于硬盘的哪个位置(即程序所在的逻辑扇区)
-
系统访问或者处理其他设备的功能,本质上就是程序加载的过程,所有输入输出设备(外设、I/O设备)、网络设备等等都是通过这种方式实现功能的,只是加载的程序在这些硬件设备上,cpu处理加载到内存的这些硬件的程序和信号,完成交互功能
-
狭义上的声卡和显卡其实是指相关硬件的I/O接口,I/O接口可以是一个电路板,也可以是一个芯片,本质上是一种信号转换器,负责按照处理器的信号规程工作,将处理器的信号转换成外围设备能接受的另一种信号,或者将外围设备信号转化为处理器规程的信号
-
现在一般通过蓝桥(ICH)实现CPU和I/O控制芯片的访问,或者蓝桥链接局部总线实现和总线上的I/O设备交互,南桥芯片还可能链接的总线有:
- PCI/PCIE总线实现的插槽,可以同时插上多个显卡或其他I/O设备
- USB总线
- 网络总线
-
处理器通过端口和外围设备通信,本质上,端口也是寄存器,和处理器里的寄存器差不多,但这些寄存器位于I/O接口的电路中,每一个I/O接口可能有好几个端口,分别用于不同目的。
- 不同功能和厂商设计的端口可能拥有不同的位宽
- 端口在不同计算机系统中有不同的实现方式,在一些计算机系统中,端口号是映射到内存空间的(比如实模式下的显存映射到内存是0xb800地址,通过这个地址就能直接访问显存)
- 通过映射到内存的端口访问对应设备的过程就相当于直接访问端口来访问设备
- 在另一部分计算机系统里,端口是端口、内存是内存,是相互独立的,这种独立编址的计算机访问设备是不通过内存地址访问的,只能通过端口访问
- 现在主流系统的处理器,一般既支持内存映射端口又支持独立编址端口
-
程序加载的过程:
- 先将用户程序从硬盘里读取出来
- 读取用户程序头部的信息
- 由于代码是分段的,根据头部信息获取到入口段,然后将入口段重定位到内存(过程中会计算逻辑地址,并按照逻辑地址分配内存)
- 然后重定位整个程序的其他段(包括各种代码段、栈段、数据段等等)
- 重定位完成后,从程序入口地址处开始执行程序
程序加载器代码示例
- 这是一段及其简单的程序加载器代码示例(适用于8086CPU的DOS系统),段重定位表没有像前面截图里的源码那样手动定义,但更加清晰明了
.model small
.stack 100h
.data
buffer db 1024 dup(0) ; 缓冲区用于存储加载的程序
;? 表示该变量在定义时不进行初始化
code_seg_len dw ?
data_seg_len dw ?
num_reloc_entries dw ? ;定义段重定位表
.code
main proc
mov ax, @data ; 初始化数据段寄存器
mov ds, ax
mov ah, 3dh ; 打开文件函数号
lea dx, prog_name ; 文件名指针
xor cx, cx ; 访问模式:只读
int 21h ; 调用DOS中断
jc error ; 如果发生错误跳转到error处理
mov bx, ax ; 将文件句柄保存在bx中
mov ah, 3fh ; 读文件函数号
mov cx, 1024 ; 读取最大字节数
lea dx, buffer ; 数据缓冲区地址
int 21h ; 调用DOS中断
mov ah, 3eh ; 关闭文件函数号
int 21h ; 调用DOS中断
; 解析目标文件头
lea si, buffer ; 指向缓冲区开始
lodsw ; 加载代码段长度
mov code_seg_len, ax ; 保存代码段长度
add si, ax ; 移动到数据段长度位置
lodsw ; 加载数据段长度
mov data_seg_len, ax ; 保存数据段长度
add si, ax ; 移动到段重定位表项数位置
lodsw ; 加载段重定位表项数
mov num_reloc_entries, ax ; 保存段重定位表项数
; 设置代码段基址
mov ax, cs
add ax, 3 ; 基址指向下一个段后的位置
shl ax, 4 ; 转换为物理地址
mov es, ax ; 设置ES为代码段基址
; 复制代码段数据到ES:0
lea si, buffer ; 指向缓冲区开始
cld ; 清除方向标志
mov di, 0 ; 目标偏移为0
mov cx, code_seg_len ; 字节数
rep movsb ; 复制代码段数据
; 设置数据段基址
add ax, code_seg_len ; 数据段基址
mov ds, ax ; 设置DS为数据段基址
; 复制数据段数据到DS:0
mov di, 0 ; 目标偏移为0
mov cx, data_seg_len ; 字节数
rep movsb ; 复制数据段数据
; 应用段重定位表
mov cx, num_reloc_entries ; 重定位表项数
jcxz exec_program ; 如果没有重定位项,直接执行程序
apply_relocation:
lodsw ; 加载重定位偏移量
push ax ; 保存偏移量
mov ax, ds ; 获取数据段基址
pop bx ; 恢复偏移量
add [bx], ax ; 修改偏移量以反映新的基址
loop apply_relocation ; 循环处理所有重定位项
exec_program:
jmp far ptr cs:0x0 ; 跳转到加载的程序开始处
error:
mov ah, 9 ; 显示字符串函数号
lea dx, err_msg ; 错误消息指针
int 21h ; 调用DOS中断
mov ah, 4ch ; 终止程序函数号
int 21h ; 调用DOS中断
prog_name db 'PROG.OBJ', 0 ; 要加载的程序文件名
err_msg db 'Error loading program$' ; 错误消息
end main
从安全思路来看
- 关于基础知识的内容已经进入尾声,这一章讲到的实模式内存管理机制和后面即将讲到的32位处理器x86架构的16位模式息息相关,也是理解32位保护模式的基础
- 内存管理的方式、逻辑扇区的划分、程序重定位的寻址方式都是及其重要的,很多极客DIY的攻击模块里(比如单片机模块、flipper zero的DIY拓展等等),在深度自制时使用的16位芯片进行程序加载和模块化功能的实现都离不开这些知识
- 尝试自己编写一个8086处理器DOS系统的程序加载器,理解其内存的分配和管理方法,对于免杀思路的拓展和更高权限的程序驻留是很有帮助的