且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

《逆向工程权威指南》—第3章3.5节MIPS

更新时间:2022-10-10 17:18:49

本节书摘来自异步社区《逆向工程权威指南》一书中的第3章3.5节MIPS,作者【乌克兰】Dennis Yurichev(丹尼斯),更多章节内容可以访问云栖社区“异步社区”公众号查看。

3.5 MIPS
3.5.1 全局指针Global pointer
全局指针是MIPS软件系统的一个重要概念。我们已经知道,每条MIPS指令都是32位的指令,所以单条指令无法容纳32位地址(指针)。这种情况下MIPS就得传递一对指令才能使用一个完整的指针。在前文的例子中,GCC在生成文本字符串的地址时,就采用了类似的技术。

从另一方面来说,单条指令确实可以容纳一组由寄存器的符号、有符号的16位偏移量(有符号数)。因此任何一条指令都可以构成的表达式,访问某个取值范围为“寄存器−32768”~“寄存器+32767”之间的地址(总共69KB)。为了简化静态数据的访问操作,MIPS平台特地为此保留了一个专用的寄存器,并且把常用数据分配到了一个大小为64KB的内存数据空间里。这种专用的寄存器就叫作“全局指针”寄存器。它的值是一个指针,指向64KB(静态)数据空间的正中间。而这64KB空间通常用于存储全局变量,以及printf()这类由外部导入的的外部函数地址。GCC的开发团队认为:获取函数地址这类的操作,应当由单条指令完成;双指令取址的运行效率不可接受。

在ELF格式的文件中,这个64KB的静态数据位于.sbss 和.sdata之中。“.sbss”是small BSS(Block Started by Symbol)的缩写,用于存储非初始化的数据。“.sdata”是small data的缩写,用于存储有初始化数值的数据。

根据这种数据布局编程人员可以自行决定把需要快速访问的数据放在.sdata、还是.sbss数据段中。

有多年工作经验的人员可能会把全局指针和MS-DOS内存(参见本书第49章)、或者MS-DOS的XMS/EMS内存管理器联系起来。这些内存管理方式都把数据的内存存储空间划分为数个64KB区间。

全局指针并不是MIPS平台的专有概念。至少PowerPC平台也使用了这一概念。

3.5.2 Optimizing GCC
下面这段代码显示了“全局指针”的特色。

指令清单3.18 Optimizing GCC 4.4.5 (汇编输出)

1 $LC0:
 2 ; \000 is zero byte in octal base:
 3          .ascii "Hello, world!\012\000"
 4 main:
 5 ; function prologue.
 6 ; set the GP:
 7          lui    $28,%hi(__gnu_local_gp)
 8          addiu  $sp,$sp,-32
 9          addiu  $28,$28,%lo(__gnu_local_gp)
10 ; save the RA to the local stack:
11          sw     $31,28($sp)
12 ; load the address of the puts() function from the GP to $25:
13          lw     $25,%call16(puts)($28)
14 ; load the address of the text string to $4 ($a0):
15          lui    $4,%hi($LC0)
16 ; jump to puts(), saving the return address in the link register:
17          jalr   $25
18          addiu  $4,$4,%lo($LC0) ; branch delay slot
19 ; restore the RA:
20          lw     $31,28($sp)
21 ; copy 0 from $zero to $v0:
22          move   $2,$0
23 ; return by jumping to the RA:
24          j      $31
25 ; function epilogue:
26          addiu $sp,$sp,32 ; branch delay slot

主函数序言启动部分的指令初始化了全局指针寄存器GP寄存器的值,并且把它指向64KB数据段的正中间。同时,程序把RA寄存器的值存储于本地数据栈。它同样使用puts()函数替代了printf()函数。而puts()函数的地址,则通过LW(Load Word)指令加载到了$25寄存器。此后,字符串的高16位地址和低16位地址分别由LUI(Load Upper Immediate)和ADDIU(Add Immediate Unsigned Word)两条指令加载到$4寄存器。LUI中的Upper一词说明它将数据存储于寄存器的高16位。与此相对应,ADDIU则把操作符地址处的低16位进行了求和运算。ADDIU指令位于JALR指令之后,但是会先于后者运行[22]。$4寄存器其实就是$A0寄存器,在调用函数时传递第一个参数[23]。

JALR (Jump and Link Register)指令跳转到$25寄存器中的地址,即puts()函数的启动地址,并且把下一条LW指令的地址存储于RA寄存器。可见,MIPS系统调用函数的方法与ARM系统相似。需要注意的是,由于分支延迟槽效应,存储于RA寄存器的值并非是已经运行过的、“下一条”指令的地址,而是更后面那条(延迟槽之后的)指令的地址。所以,在执行这条JALR指令的时候,写入RA寄存器的值是PC+8,即ADDIU后面的那条LW指令的地址。

第19行的LW (Load Word)指令,用于把本地栈中的RA值恢复回来。请注意,这条指令并不位于被调用函数的函数尾声。

第22行的MOVE指令把$0($ZERO)的值复制给$2($V0)。MIPS有一个常量寄存器,它里面的值是常量0。很明显,因为MIPS的研发人员认为0是计算机编程里用得最多的常量,所以他们开创了一种使用$0寄存器提供数值0的机制。这个例子演示了另外一个值得注意的现象:在MIPS系统之中,没有在寄存器之间复制数值的(硬件)指令。确切地说,MOVE DST, SRC是通过加法指令ADD DST,SRC, $ZERO变相实现的,即DST=SRC+0,这两种操作等效。由此可见,MIPS研发人员希望尽可能地复用opcode,从而精简opcode的总数。然而这并不代表每次运行MOVE指令时CPU都会进行实际意义上的加法运算。CPU能够对这类伪指令进行优化处理,在运行它们的时候并不会用到ALU(Arithmetic logic unit)。

第24行的J指令会跳转到RA所指向的地址,完成从被调用函数返回调用者函数的操作。还是由于分支延迟槽效应,其后的ADDIU指令会先于J指令运行,构成函数尾声。

我们再来看看IDA生成的指令清单,熟悉一下各寄存器的伪名称。

代码清单3.19 Opimizing GCC4.4.5(IDA)

1 .text:00000000 main:
 2 .text:00000000
 3 .text:00000000 var_10       = -0x10
 4 .text:00000000 var_4        = -4
 5 .text:00000000
 6 ; function prologue.
 7 ; set the GP:
 8 .text:00000000             lui        $gp, (__gnu_local_gp >> 16)
 9 .text:00000004             addiu      $sp, -0x20
10 .text:00000008             la         $gp, (__gnu_local_gp & 0xFFFF)
11 ; save the RA to the local stack:
12 .text:0000000C             sw         $ra, 0x20+var_4($sp)
13 ; save the GP to the local stack:
14 ; for some reason, this instruction is missing in the GCC assembly output:
15 .text:00000010             sw         $gp, 0x20+var_10($sp)
16 ; load the address of the puts() function from the GP to $t9:
17 .text:00000014             lw         $t9, (puts & 0xFFFF)($gp)
18 ; form the address of the text string in $a0:
19 .text:00000018             lui        $a0, ($LC0 >> 16) # "Hello, world!"
20 ; jump to puts(), saving the return address in the link register:
21 .text:0000001C             jalr       $t9
22 .text:00000020             la         $a0, ($LC0 & 0xFFFF) # "Hello, world!"
23 ; restore the RA:
24 .text:00000024             lw         $ra, 0x20+var_4($sp)
25 ; copy 0 from $zero to $v0:
26 .text:00000028             move       $v0, $zero
27 ; return by jumping to the RA:
28 .text:0000002C             jr         $ra
29 ; function epilogue:
30 .text:00000030             addiu      $sp, 0x20

第15行的指令使用局部栈保存GP的值。令人感到匪夷所思的是,GCC的汇编输出里看不到这条指令,或许这是GCC自身的问题[24]。严格地说,此时有必要保存GP的值。毕竟每个函数都有着自己的64KB数据窗口。

程序中保存puts()函数地址的寄存器叫作$T9寄存器。这类T-开头的寄存器叫作“临时”寄存器,用于保存代码里的临时值。调用者函数负责保存这些寄存器的数值(caller-saved),因为它有可能会被被调用的函数重写。

3.5.3 Non-optimizing GCC
代码清单3.20 Non-optimizing GCC 4.4.5 (汇编输出)

1 $LC0:
 2          .ascii "Hello, world!\012\000"
 3 main:
 4 ; function prologue.
 5 ; save the RA ($31) and FP in the stack:
 6          addiu  $sp,$sp,-32
 7          sw     $31,28($sp)
 8          sw     $fp,24($sp)
 9 ; set the FP (stack frame pointer):
10          move   $fp,$sp
11 ; set the GP:
12          lui    $28,%hi(__gnu_local_gp)
13          addiu  $28,$28,%lo(__gnu_local_gp)
14 ; load the address of the text string:
15          lui    $2,%hi($LC0)
16          addiu  $4,$2,%lo($LC0)
17 ; load the address of puts() using the GP:
18          lw     $2,%call16(puts)($28)
19          nop
20 ; call puts():
21          move  $25,$2
22          jalr  $25
23          nop; branch delay slot
24
25 ; restore the GP from the local stack:
26          lw    $28,16($fp)
27 ; set register $2 ($V0) to zero:
28          move  $2,$0
29 ; function epilogue.
30 ; restore the SP:
31          move  $sp,$fp
32 ; restore the RA:
33          lw    $31,28($sp)
34 ; restore the FP:
35          lw    $fp,24($sp)
36          addiu $sp,$sp,32
37 ; jump to the RA:
38          j     $31
39          nop; branch delay slot

未经优化处理的GCC输出要详细得多。此处,我们可以观察到程序把FP当作栈帧的指针来用,而且它还有3个NOP(空操作)指令。在这3个空操作指令中,第二个、第三个指令都位于分支跳转指令之后。

笔者个人认为(虽然目前无法肯定),由于这些地方都存在分支延迟槽,所以GCC编译器会在分支语句之后都添加NOP指令。不过,在启用它的优化选项之后,GCC可能就会删除这些NOP指令。所以,此处仍然存在这些NOP指令。

使用IDA程序观察下面这段代码。

指令清单3.21 Non-optimizing GCC 4.4.5 (IDA)

1 .text:00000000 main:
 2 .text:00000000
 3 .text:00000000 var_10      = -0x10
 4 .text:00000000 var_8       = -8
 5 .text:00000000 var_4       = -4
 6 .text:00000000
 7 ; function prologue.
 8 ; save the RA and FP in the stack:
 9 .text:00000000             addiu      $sp, -0x20
10 .text:00000004             sw         $ra, 0x20+var_4($sp)
11 .text:00000008             sw         $fp, 0x20+var_8($sp)
12 ; set the FP (stack frame pointer):
13 .text:0000000C             move       $fp, $sp
14 ; set the GP:
15 .text:00000010             la         $gp, __gnu_local_gp
16 .text:00000018             sw         $gp, 0x20+var_10($sp)
17 ; load the address of the text string:
18 .text:0000001C             lui        $v0, (aHelloWorld >> 16) # "Hello, world!"
19 .text:00000020             addiu      $a0, $v0, (aHelloWorld & 0xFFFF) # "Hello, world!"
20 ; load the address of puts() using the GP:
21 .text:00000024             lw         $v0, (puts & 0xFFFF)($gp)
22 .text:00000028             or         $at, $zero ; NOP
23 ; call puts():
24 .text:0000002C             move       $t9, $v0
25 .text:00000030             jalr       $t9
26 .text:00000034             or         $at, $zero ; NOP
27 ; restore the GP from local stack:
28 .text:00000038             lw         $gp, 0x20+var_10($fp)
29 ; set register $2 ($V0) to zero:
30 .text:0000003C             move       $v0, $zero
31 ; function epilogue.
32 ; restore the SP:
33 .text:00000040             move       $sp, $fp
34 ; restore the RA:
35 .text:00000044             lw         $ra, 0x20+var_4($sp)
36 ; restore the FP:
37 .text:00000048             lw         $fp, 0x20+var_8($sp)
38 .text:0000004C             addiu      $sp, 0x20
39 ; jump to the RA:
40 .text:00000050             jr         $ra
41 .text:00000054             or         $at, $zero ; NOP

在程序的第15行出现了一个比较有意思的现象——IDA识别出了LUI/ADDIU指令对,把它们显示为单条的伪指令LA(Load address)。那条伪指令占用了8个字节!这种伪指令(即“宏”)并非真正的MIPS指令。通过这种名称替换,IDA帮助我们这对指令的作用望文思义。

NOP的显示方法也构成了它的另外一种特点。因为IDA并不会自动地把实际指令匹配为NOP指令,所以位于第22行、第26行、第41行的指令都是“OR $AT, $ZERO”。表面上看,它将保留寄存器$AT的值与0进行或运算。但是从本质上讲,这就是发送给CPU的NOP指令。MIPS和其他的一些硬件平台的指令集都没有单独的NOP指令。

3.5.4 栈帧
本例使用寄存器来传递文本字符串的地址。但是它同时设置了局部栈,这是为什么呢?由于程序在调用printf()函数的时候由于程序必须保存RA寄存器的值和GP的值,故而此处出现了数据栈。如果此函数是叶函数,它有可能不会出现函数的序言和尾声,有关内容请参见本书的2.3节。

3.5.5 Optimizing GCC: GDB的分析方法
指令清单3.22 GDB的操作流程

root@debian-mips:~# gcc hw.c -O3 -o hw
root@debian-mips:~# gdb hw
GNU gdb (GDB) 7.0.1-debian
Copyright (C) 2009 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "mips-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /root/hw...(no debugging symbols found)...done.
(gdb) b main
Breakpoint 1 at 0x400654
(gdb) run
Starting program: /root/hw

Breakpoint 1, 0x00400654 in main ()
(gdb) set step-mode on
(gdb) disas
Dump of assembler code for function main:
0x00400640 <main+0>:     lui     gp,0x42
0x00400644 <main+4>:     addiu   sp,sp,-32
0x00400648 <main+8>:     addiu   gp,gp,-30624
0x0040064C <main+12>:    sw      ra,28(sp)
0x00400650 <main+16>:    sw      gp,16(sp)
0x00400654 <main+20>:    lw      t9,-32716(gp)
0x00400658 <main+24>:    lui     a0,0x40
0x0040065c <main+28>:    jalr    t9
0x00400660 <main+32>:    addiu   a0,a0,2080
0x00400664 <main+36>:    lw      ra,28(sp)
0x00400668 <main+40>:    move    v0,zero
0x0040066c <main+44>:    jr      ra
0x00400670 <main+48>:    addiu   sp,sp,32
End of assembler dump.
(gdb) s
0x00400658 in main ()
(gdb) s
0x0040065c in main ()
(gdb) s
0x2ab2de60 in printf () from /lib/libc.so.6
(gdb) x/s $a0
0x400820:          "hello, world"
(gdb)