好的,我们接着回来看《深入理解计算机系统》……这名字都已经刻在我的输(D)入(N)法(A)里了。
这一篇的内容很大一部分是对于逆向手再熟悉不过的汇编语言。由于原书中程序的机器级表示是一个长达一百余页的超耐久回,一回肯定讲不完,所以哪怕是分期也得讲的简练一点。
从编码开始
如果我们需要编译我们所写的代码,往往都是从这一句指令开始:
gcc -o -Wall hello_world.c
当然,今天为了看的清楚一点,我们特地使用-Og选项,因为我们需要更贴近于C语言格式的汇编代码,如果开了o1甚至o2优化的话那八成会产生只有小娜看得懂的贵物。

但是今天没你啥事,歇着吧。

哦对了,现在她在打只狼,所以她很烦抢线程的语言理解环节。那咱们赶紧进入正片:
至于编译器运作的流程,我们之前应该都讲清楚了,这里不再赘述。不过,我们知道汇编程序的大体结构,但是……
细节呢?
这就得结合一些源代码来讲了。比方说我们真的就写一个halloworld.c:
#include <stdio.h>
#include "lib_text.h"
void multstore(long x, long y, long *dest) {
long t = mult2(x, y);
*dest = t;
}
int main()
{
return 0;
}
由于库函数没给mult2乘法,于是自己写了个lib_text.c的代码如下:
#include "lib_text.h"
int mult2(int a,int b){
return a*b;
}
最后生成可执行文件halloworld用了如下指令:
gcc -c -Og lib_text.c
gcc -c -Og halloworld.c
gcc -Og -o halloworld halloworld.o lib_text.o
我们在ida反汇编multstore函数的时候看到的是如下的代码:
push rbx
mov rbx,rdx
call mult2
mov [rbx], rax
pop rbx
retn
这与我们直接使用-S选项调出halloworld.c的汇编编译结果是相似的,如果排除掉含前缀点的,引导汇编器和连接器的语句的话:
.file "halloworld.c"
.text
.globl multstore
.type multstore, @function
multstore:
.LFB11:
.cfi_startproc
pushq %rbx
.cfi_def_cfa_offset 16
.cfi_offset 3, -16
movq %rdx, %rbx
call mult2@PLT
movq %rax, (%rbx)
popq %rbx
.cfi_def_cfa_offset 8
ret
.cfi_endproc
非常简单。但为什么是rbx寄存器充当存储数据的角色,又为什么需要入栈出栈的操作?
从寄存器开始
我们都知道又作用类似于变量,但数量和代号都固定的,名为“寄存器”的存在。
在intel x86-64的cpu架构里,寄存器包括通用寄存器rax(累加器,加法执行)、rbx(基址寄存器,数据存取)、rcx(计数寄存器,循环计数)、rdx(数据寄存器,读写I/O端口时存放端口号)、rsi(字符串源)/rdi(字符串目的地址)、rbp(栈底指针)/rsp(栈顶指针)、r8~r15(64版本新增通用寄存器)。
在回到我们的函数的同时,需要提到两个概念,调用者保存寄存器(Caller-saved Register)和被调用者保存寄存器(Callee-saved Register)。至于“调用者”和“被调用者”的概念差别,举个简单的例子就是你的函数和你所调用的变量。
因此从定义上而言,用于存取的rbx就是一种典型的被调用者保存寄存器。我们也经常使用push(q)、mov(q)和pop(q)指令来让rbx寄存器中的数据与函数的堆栈交互,譬如如上的程序,我们就用这些指令,实现了从外源函数Mul2的地址到整型t(push rbx;mov rbx,rdx),再从t到dest指针(mov [rbx], rax)的整个赋值链条。至于这些指令具体的功用,则是接下来的内容:
包括push和pop在内的数据传送指令
一个函数要实现功能,那必须令其构成一条完整的指令,这尤其是汇编语言逃不开的内容。比如说你做逆向要绕过反调试,你肯定是修改call或者jmp调用的地址,或者干脆整个nop了,而不是暴力删除地址。
一个完整的数据传送指令,包含**操作码(Operation Code)部分和操作数(Operands)**部分。操作码即为我们看到的指令前半段诸如pop、push的部分,负责告诉你的CPU你要干嘛,而操作数则是后续跟进的参数。目前来讲,你可以将两者简单理解为汇编层面的库函数和所需参数。
ps:不论对于操作码还是操作数,不同数据类型的数据或操作码有各自的汇编代码后缀,比方说b后缀代表字符型,q后缀代表四字型,具体可以翻阅原书3.3部分。
ps2:操作码的后缀只是无关紧要的大小指示符,并不会对你读代码有什么影响,尽管这年头没多少需要你正儿八经全程手撕汇编的场合就是了
操作数又分为:
立即数(Immediate)
简单理解就是写在函数里的常量,但因为是汇编,大部分情况下仅满足C语言定义的数值。部分系统的立即数带有“$”前缀。
寄存器(Register)
不需多说,但需要注意,高位系统的汇编会向下兼容到以下所有位数系统的寄存器。
内存引用(Memory Reference)
一般表现为带括号的寄存器(及其和立即数的组合)。
如果你认真了解过内存空间,以及自己看过《深入理解》的原书和之前的篇目,你就会知道整个内存空间可以被抽象地视为一个可以存取的数组,不过存取需要确切的起始地址和偏移(数据长度)。
而我们使用内存引用,最常用的方法是使用一种语法表示为$Imm(r_b, r_i, s)$,实际值为$Imm+R[r_b]+R[r_i]*s$的表示方法。$Imm$表示立即数偏移;$r_b$表示基址寄存器;$r_i$表示变址寄存器。s为比例因子,且必须是8以内的2的次方,取决于你源代码中定义的数组类型,比方说char单位仅占单字节,比例因子是1,int由于单位占4字节,比例因子是4。
至于其它形式,都是延伸自这一基础形式的变体,建议看书。
那么接下来,就是各类传送指令的操作数:
mov系
mov,简单明了,格式为mov src dest,src表示源操作数,dest表示目的操作数,数据从源操作数表示的地址迁移至目的操作数表示的地址。若使用寄存器作为操作数,则mov的后缀必须与寄存器大小保持一致。指令本身和源操作数的地址上的元素构成一堆调用者和被调用者,你也可以将这个关系粗暴地理解为mov指令下源和目的的关系。
但是,我们也知道,高版本系统架构存在寄存器向下兼容,而面对不同位数的mov指令向不同位数下的相同寄存器传输数据,寄存器会根据指令位数不同而变更不同位数,比方说对于已经被赋值0x12345678的64位rax寄存器,我们使用movb为其赋值-1,则只有最后一个字节变成FF,也就是0x123456FF,但如果是movw就是0x1234FFFF。
总结而言,同名寄存器的不同位表示,对应自低位起占用不同的内存空间。
以下是一些特殊情况:
1、在你使用movl指令时需要注意,movl在64位架构下使用时会习惯性地将寄存器高位4字节清零。如果哪道pwn题就利用这个机制让你的shell报废,那你可能得想点别的招。
2、传送绝对四字(八字节)长度数据,使用movabsq,且以寄存器作为源时必须以寄存器为目的
3、movz系列指令会把目的中剩余高位字节填充为0,以实现从至多为字的低大小单位源向高大小单位目的的数据传递。通俗点来讲,你可以理解为如果你想让char变量做数据源,让int变量做数据目的地,那你就用movzbl,也就是从字节到字的movz。两个单位后缀决定了传输源与目的的数据大小。
4、movs系列指令会对目的的剩余字节进行符号扩展,即正数填充0,复数填充1。基本用法与movz相同,但额外支持四字节到八字节的传输movslq,以及拓展eax到rax的专用指令cltq。
栈数据系(push,pop)
对于逆向手而言,栈的概念肯定不陌生,单进单出。你在ida里多半能看到每个函数对应的栈,这些栈其实对应着一段内存空间,毕竟数组就是可以拿来实现栈的,而内存空间抽象上就是数组。栈的增长方向从高地址向低地址,故栈顶元素地址最低,栈底元素地址最高,这点你从ida的function stack里从-N到+0的区间看,肯定会有比较直观的感受。
在原理上,系统通过栈顶指针的修改,来实现栈内数据的增删。每次push,栈顶寄存器的值,也就是栈顶地址减去对应数据位数的值,然后才把数据存入新拓展的栈顶地址。分开这两个动作就是如下指令:
subn $len %rsp
movn %rxp (%rsp)
而pop则是会读取数据,而后令栈顶地址加上对应数据位数的值。拆开来就是这样:
addn $len %rsp
movn (%rsp) %rxp
在你的函数定义局部变量之时,压栈就已经开始了。程序会先将你定义的变量对应的源数据,通过push src的格式进行压栈,然后执行函数的时候再通过pop dest格式的pop指令将数据弹出至指定目的寄存器,再进行各项运算。关于具体案例,你可以自行百度或查看原书,抑或是看看九曲阑干的视频。
如上,我们对程序的机器级表示,也就是汇编做了一个初探。
别急着晕,这才哪跟哪呢。前面的区域,以后还得探索呢~
等下,为什么还是有坤声?等下,我为啥要说还是?

麻了,姑奶奶你消停消停(闭门声)