1.8 scanf()
1.8.1. 简单例子
用scanf()从屏幕上的输入读入一个整数。
关于指针:在32位系统中是一个4byte的地址,在64位中则是8byte。指针仅仅是一个源代码里的概念。汇编代码里找不到这个概念。
x86
MSVC
用于接收输入值的x变量是局部变量,所以储存在栈上。函数前言里有一个PUSH ECX,并不是要存下来ECX的值,而是在栈上为保存x变量开辟4字节的空间。要访问x,通过EBP-4完成。因为EBP指向当前栈帧,所以基于EBP+offset来访问栈帧中的局部变量很方便。ESP就不那么方便了,因为它指向栈顶,所以总是在变化。
scanf函数在这个例子里有两个参数。,第一个是指向字符串%d的指针,第二个是x变量的地址。首先,用lea eax, DWORD PTR _x$[ebp]指令将x变量的地址加载到EAX寄存器。LEA指令即load effective address,通常用来形成一个地址。这个指令其实就是lea eax, [epb-4]。
然后,EAX寄存器的值会被入栈,再调用scanf函数。下面的指令mov ecx, [ebp-4]是把调用scanf后修改过的x值加载到ECX中。然后把ECX的值入栈再调用printf,即打印出输入的x值。
MSVC+Ollydbg
当用lea把x局部变量的地址放到EAX之后,我们查看EAX的值,就知道此刻x存放在哪个地址。在随后的执行中,我们在调用scanf之后再查看EAX存放地址的地方的值,能够看到值的变化,即x成为了我们输入的值。然后这个值就会被放到ECX中了,供后面的printf进行调用。
GCC
GCC会把printf换成puts。可以看到这里入栈是直接用mov指令。
x64
主要的区别就是用寄存器来传参。
MSVC
调用printf的时候是用rcx来传参的,调用scanf的时候是用rcx存放’%d’地址,用rdx存放临时变量x的地址(在栈上,用rsp加偏移表示)。
GCC
给出的是优化的版本,用puts代替了printf,用edi传递要打印的字符串。调用scanf时,用edi传递’%d’,用rsi传递变量x的地址。
ARM
优化的Keil (Thumb模式)
在栈上为临时变量分配空间,将指针SP传递给R1寄存器。R0存放字符串格式。后面用printf调用时,使用LDR指令,将值从栈复制到R1。
ARM64
未优化的GCC中, 编译器把变量x放在了栈帧的底部,而不是一开始。
MIPS
用$sp+24指向栈中的x变量。
1.8.2. 全局变量
之前我们讨论的scanf接收的参数x是局部变量,就是定义在main()函数内部的。如果x是全局变量呢?那么这个值就可以在任何地方被访问,而不仅仅在被调用函数里。
MSVC: x86
此时,会把x定义在数据段_DATA,那么在函数的局部栈上,就不会分配任何空间。当访问x时,直接访问数据段中的x的位置(偏移)。由于x没有初始化,所以没有分配空间。当需要使用这个地址的时候,操作系统会为这个地址分配一个空间。(其实x应该是放在BSS段里,与其他未初始化变量一起,用0填充。)应该这么说。在二进制文件中,未初始化的数据是不占任何空间的。但是加载到内存后,会开辟一块空间把这些未初始化的值放进去,然后用0填充。
MSVC: x86 + OllyDbg
可以看到调用scanf的时候把x的地址00C53394入栈,查看这个地址可以看到调用结束后该地址的值变成了7B。然后我们可以看到这个地址是位于.data段中的。
GCC: x86
在Linux中未初始化变量放在_bss段中。如果查看ELF文件的_bss信息(未加载时),可以发现:
; Segment type: Uninitialized
; Segment permissions: Read/Write
如果把这个值初始化,就会被放到_data段中。
MSVC: x64
不同之处在于没有用栈而是用寄存器来传参数。为rdx和rcx传参时使用了lea指令。为edx传参时使用了mov指令。DWORD PTR是指32位的数据。
ARM: Optimizing Keil (Thumb mode)
同样地,因为x变成全局变量,被放在了.data段。我们发现奇怪的一点,为什么字符串参数放在了code段里,但是x却在data段里呢。因为后者是变量,前者是不变量。代码段有的时候会放在ROM中。这里我们要注意,ARM一般是嵌入式系统中的。而DATA段一般放在RAM中。这种存放方式是为了经济考虑。放到RAM中的值需要初始化。代码段中还有一个值off_2C用来存储x在数据段中的位置。如果一个变量被声明为const,就会分配在.constdata段中。
ARM 64
用ADRP/ADD指令对计算x地址。
MIPS
未初始化的全局变量
用IDA加载,发现x位于.sbss段(全局指针)。
x地址如何计算?使用GP和偏移进行计算,指向64KB的数据,3个函数puts()、scanf()、printf()也是用这种方法读出来的。GP指向那个数据段的中间。
这个函数最后是由两个NOP指令结尾的:MOV AT, AT。这是为了对齐,这样下一个函数可以从16byte开始。
初始化的全局变量
IDA会显示x位于.data段。
x地址的计算使用LUI和ADDIU,这个时候地址在$S0寄存器,用LW取出这个地址,传输给printf()。
存储临时变量的寄存器以t为前缀。s开头的是用来保存一些数据的。这些数据一会会被其他函数使用。
1.8.3. scanf()
scanf()返回接收参数的个数。可用来检查输入是否正确。
编写了一个检查scanf()返回值的函数。
MSVC: x86
出现了分支跳转指令。scanf()的返回值存在EAX中。JNE即Jump if Not Equal. CMP实质是SUB,其实所有算数运算指令都会设置flag寄存器。JNE实质上与JNZ相同。SUB与CMP不一样的地方是,它会改变第一个参数的值。换句话说,CMP就是不保存结果,只设置flag寄存器的SUB。
MSVC: x86: IDA
在IDA中可以对标签做注释。空格可以查看调用图。逆向工程的一个重要工作是减少要处理的信息。
MSVC: x86 + OllyDbg
尝试破解绕过错误处理代码。其实就是调用了scanf之后手动设置EAX的值,绕过错误处理代码。
MSVC: x86 + Hiew
尝试打补丁,让程序无论接受什么输入永远也达不到错误处理路径。只要把用于比较和跳转的两个指令替换成NOP就行了。
MSVC: x64
基本差不多,64位数据使用了R开头的寄存器。
ARM
ARM:优化的Keil (Thumb模式)
这里出现的新指令是CMP和BEQ。CMP不用解释了。BEQ是如果相等就调到一个地址。有点像x86中的JZ。函数返回值存在R0中。
ARM64
不一样的地方是用的是CMP/BNE。
MIPS
scanf()的返回值放在$V0中。
beq $v0, $v1, loc_40070c
Branch Equal,一条指令就完成了功能:比较 V0和 V1中的值,之前$V1中已经放入了值1。如果两个值相等,就跳转到地址0x0040070c。
练习
JNE/JNZ可用JE/JZ替换,但基本块布置也要变化。
1.8.4. 练习
1.9. 访问传递的变量
调用函数如何访问参数。
1.9.1. x86
MSVC
_f子函数内存对参数的访问是这样的:DWORD PTR _a$[ebp]。因为_a值为正,所以实际上是访问栈帧外的内容。用EAX将计算得到的值返回给main函数。
MSVC + OllyDbg
栈中从低地址到高地址依次存了EBP、RA、1、2、3。
GCC
在f和printf调用之后,栈指针并没有恢复,因为leave指令做了这个工作。
1.9.2. x64
不同之处在于用寄存器传递参数。
MSVC
这个例子中,把第一个参数放在ECX,第二个放到EDX,第三个放到R8D。用LEA完成操作,因为比ADD快。
在未优化的MSVVC中,参数存放到栈中。叫做shadow space。
GCC
EDI存放第一个参数,ESI存放第二个参数,EDX存放第三个参数。EAX存放返回值。
GCC: 用uint64_t代替int
即换成64位整数。代码是一样的,只不过之前用寄存器的32位部分,现在用整个64位的。即RDX, RSI, RDI。
1.9.3. ARM
非优化的Keil (ARM模式)
调用f时,用RI传3,用RI传2,用R0传1。头四个参数一般用R0-R3。MLA指令把R3和R1相乘,加上R3,最后把值存在R0里。R0就是传递返回值的。
MLA R0, R3, R1, R2
第一个MOV R3, R0指令是多余的。这是未优化的版本。
BX把控制流转回LR寄存器中存放的RA。在需要的情况下,可以转换为ARM或Thumb模式。
优化的Keil (ARM模式)
把刚才说的MOV R3, R0去掉了。
而且MLA R0, R1, R0, R2可以少用一个寄存器。
优化的Keil (Thumb模式)
Thumb模式里没有MLA指令。用两条指令代替。
MULS R0, R1
ADDS R0. R0, R2
BX LR
ARM 64
优化的GCC
madd w0, w0, w1, w2
ret
用madd代替MLA返回值存在w0中。
如果把int换成64位的,w-寄存器变为x-寄存器。
非优化的GCC
没有优化的就产生很多多余的代码。先把参数放在栈中。这样做是为了防止原始数据被修改。
这叫做Register Save Area,有点类似shadow space。
前面的优化,考虑的是这些参数不会再用了,寄存器w0…w2也不会被别的使用。
用MUL/ADD指令对代替了MADD。
1.9.4. MIPS
前4个参数以A开头的4个寄存器传递。
两个特殊寄存器HI和LO。在MULT计算时保存乘法的64位结果。这两个寄存器只能用MFLO和MFHI访问。MFLO访问低位并存进$v0。高32位被丢弃了。也就是HI的数据没用。这很正常,我们处理的数据是32位int。
最后用ADDU在结果上加上第三个参数。
ADD和ADDU都是加法指令。ADD可触发溢出的异常。ADDU不会触发溢出的异常。不过C/C++不支持,所以用ADDU。
32位结果存在$v0中。
main()这里有个新指令JAL,即Jump and link。JAL和JALR的区别是,前者的偏移硬编码,后者的偏移要加上寄存器中村的值。f()后的地址已知,所以用JAL。