前言

标题中的”某些环境“包括笔者和一些同学的64位系统。
笔者验证时数次得到不符合预期的结果,于是数次推翻之前的结论,一开始以为这仅仅是一个与开关PIC选项有关的问题,后来发现可能是开关PIE的问题,再去实验发现同样的选项在不同编译器版本下行为也不同,可能是不同编译器的行为不同。
本文中关于PIC、PIE的分析比较粗糙,并且笔者的相关知识也很浅薄,可能会有很多错误,希望读者包涵并指正。

问题描述

在进行orange chapter5的第i个实验时,第二个disp_str的输出会变成乱码。原本笔者以为是对输出字符串的修改导致了错误,但经过许多尝试发现即使不做任何修改,只要编译一下原始的代码,第二次disp_str的输出就是乱码。更诡异的是原本附件中的a.img的运行结果是正常的,那么这有可能是编译环境不同,导致出现了问题。
图中可以看到最后一行输出是乱码。

问题溯源

为了方便查看,笔者只将start.c编译到start.o,其中第一次调用disp_str的部分是这样的:
比较有意思的是__x86_get_pc_thunk_bx函数,它的内容是这样的:

public __x86_get_pc_thunk_bx
__x86_get_pc_thunk_bx proc near
; __unwind {
mov     ebx, [esp+0]
retn
; }

这个函数跟软件安全课程中介绍的病毒获取自身代码段位置的代码一样,是一种实现PIE(position-independent Executable,位置无关可执行文件),因为call指令相当于push ip; jmp。下一句紧接着执行mov ebx, [esp+0];retn就会将刚压进栈的ip的值赋给ebx。此时ebx的值是add ebx, (offset _GLOBAL_OFFSET_TABLE)的地址。所以下一句的add就会让ebx的值变为_GLOBAL_OFFSET_TABLE的值了,后续的字符串都是相对此时的ebx寻址的。
从lea指令开始为第一次调用disp_str布置参数,将[ebx]+aCstartBegins-_GLOBAL_OFFSET_TABLE_也就是aCstartBegins的实际地址压入栈中。
这段代码这么做是因为aCstartBegins_GLOBAL_OFFSET_TABLE_的相对偏移是固定的,但程序本身的装载地址会发生变化,为了实现位置无关需要运行时获取代码所在的地址。
于是第一次调用十分正常,在disp_str中下断点,在bochs中调试: 看到正常的ebx的值应该是0x32ff4,此时aCstartBegins的地址是0x31000。
继续到第二次调用disp_str,用调试器或者静态分析都可以看到,正确的字符串aCstartEnds地址应该是aCstartBegins+0x2a=0x3102a。
ebx的值变成了0x32fa0=0x32ff4-0x54,导致disp_str显示字符串的地址变成了0x30fd6=0x3102a-0x54。

既然ebx的值改动了,浏览start.o的反汇编结果也没有发现ebx被改动,并且两次disp_str调用紧邻着也会输出乱码,那就只有可能是disp_str中本身修改了ebx.

disp_str:
  push ebp
  mov ebp, esp

  xchg bx, bx # magic_break for debug
  mov esi, [ebp + 8] ; pszInfo
  mov edi, [disp_pos]
  mov ah, 0Fh
.1:
 lodsb
 test al, al
 jz .2
 cmp al, 0Ah 
 jnz .3
 push eax
 mov eax, edi
 mov bl, 160 ; <--------------修改bl
 div bl
 and eax, 0FFh
 inc eax
 mov bl, 160 ; <--------------修改bl
 mul bl       
 mov edi, eax
 pop eax
 jmp .1
.3:
 mov [gs:edi], ax
 add edi, 2
 jmp .1

.2:
 mov [disp_pos], edi
  pop ebp
  ret

发现代码中两次修改ebx的值,都是将bl(ebx低8位)改为160,也就是16进制的0xa0. 再根据先前的ebx从0x32ff4变成0x32fa0,正好是低8位从0xf4变成了0xa0,出错过程完全分析清楚。

问题思考

那么为什么start.c编译生成的代码没有保存ebx呢?推测是因为x86架构下c语言中函数调用默认是__cdecl调用约定,其中ebx是callee保存的寄存器,但此时的callee也就是disp_str是直接用汇编写的,不遵守函数调用约定,因此产生了问题。事实上原本的代码看似能正常运行,是disp_str违反调用约定却幸运地没被发现。 此时笔者心中还有一个问题,就是原本的a.img为什么没有显示出问题?于是笔者提取出了原本附件中a.img中的kernel.bin进行逆向分析。

发现在函数调用前竟然直接是将aCstartBegins的偏移赋值给了栈顶,根本没有前面分析的用ebx保存_GLOBAL_OFFSET_TABLE的地址的过程。也就是说这份程序编译时~~可能没有开启PIE ~~使用的重定位方法与笔者编译出的不同,是绝对寻址。

与笔者自己编译的kernel.bin结果作对比:

笔者的kernel.bin是开启了PIE的,使用相对PC寻址字符串. 再进行验证:在Makefile中的CFLAGS里加入-no-pie。编译出的程序虽然跟附件中的有略微不同,但也是没有使用ebx运行时获取_GLOBAL_OFFSET_TABLE地址的过程。可以猜测就是由于PIE的开启,才导致了上述不同。也正是因为附件中的kernel.bin编译时没有开启PIE,所以不会有这个问题(事实上是disp_str违反调用约定却幸运地没被发现)。

在Makefile中的CFLAGS里加入-no-pie,发现仍然会用ebx存储_GLOBAL_OFFSET_TABLE_的方式。 在CFLAGS里加入-fno-PIC, 发现与附件中的编译结果类似,是直接用链接时重定位寻址的字符串,填入绝对地址。

分析start.o的反汇编结果和重定位表,可以发现两个对字符串的引用都是通过R_386_32类型(填入绝对地址)的重定位表项进行重定位的,这个步骤在链接时进行。

再与开启了PIC/PIE的进行对比:这里使用相对GOT寻址,重定位时填入的都是相对PC的偏移。

但既然用的是同一份Makefile,编译时却一个开启了-pie,一个没有开启,这多半是因为gcc版本不同,导致默认编译选项不同。 找使用32位ubuntu16.04的同学进行查证,gcc版本如下: 编译出来的kernel.bin中start.c部分,也是直接pushaCstartBegins地址的: 而笔者的gcc版本: 发现笔者的gcc开启了–enable-default-pie选项,也就是默认情况下会开启PIE.而ubuntu16中的gcc就没有这个选项,默认情况下不会开启PIE.

问题总结与延伸

前面得到的结果有些混乱,整理一下:

gcc 14.2.1+PIEgcc 14.2.1+PICUbuntu16 gcc5.4.0+PICgcc 14.2.1 无PIC附件中程序的未知编译器
寻址字符串方式R_386_GOTPC R_386_GOTOFFR_386_GOTPC R_386_GOTOFFR_386_32R_386_32R_386_32

R_386_GOTPC的相对PC寻址的方法是先获取PC,然后根据PC和GOT表地址的偏移计算出GOT表地址,再通过GOT表地址与字符串偏移算出字符串地址。 R_386_32则是链接时直接计算出绝对地址。
其中使用R_386_GOTPC的会因为该方式用到了ebx寄存器保存GOT地址,而该寄存器在disp_str中被修改,导致输出的字符串寻址错误,输出乱码。但使用R_386_32的由于寻址时没用到寄存器,不会表现出问题。 不同重定位项的简介如下:

至于他们重定位方式为什么不同,推测是高版本编译器修改了对字符串寻址的默认重定位方式。

值得注意的是,经检查两个版本的gcc都默认开启-fPIC。 gcc -dumpspecs | grep pic的结果节选:(代表默认情况下开启的是-fPIC)

%{!fno-pic:%{!fno-PIC:%{!fpic:%{!fPIC: -fPIC}}}}}

也就是说在本次实验的环境下,开启PIC时,程序不存在GOT表,对于全局字符串的寻址会采用全局偏移。(经过实验、关闭PIC和PIE时也还是这样) 而开启PIE时,会先用PC相对寻址找到GOT表地址(事实上为了这样寻址,编译时似乎特意生成了一个空的got表),再相对GOT表寻址字符串。

对于PIE和PIC的差别以及不同gcc版本下他们处理寻址全局字符串时的机制,笔者尚未弄清楚,留待以后补充探究。 (为什么之前的版本即使开启了PIC也会用R_386_32重定位字符串,这个机制是在哪个版本修改的?)

参考链接:x86: Relocation Types

解决办法

既然是由于ebx寄存器未保存导致的问题,只要在disp_str中添加保存ebx的代码就可以了。

disp_str:

  push ebp
  mov ebp, esp

  push ebx
  ...
  pop ebx
  pop ebp
  ret

重新编译链接装入a.img, 发现问题已经解决。

或者在makefile中的CFLAGS加入-fno-PIC也可以,不过这种办法毕竟治标不治本,还是推荐第一种做法。