破解我吧:一次ELF文件的解构之旅

2011-09-16 16:49

破解我吧:一次ELF文件的解构之旅

by yuanyi

at 2011-09-16 08:49:14

original http://item.feedsky.com/~feedsky/heikezhi/~8608072/569336372/6713895/1/item.html

一个朋友最近发给我一个他编写的据称很难破解的程序,让我找出它的密码,经过几个小时的Hacking,我顺利找到了密码,并且在这个过程中,除了学到许多新技术之外,还有几个特别有意思的地方,我认为值得写篇文章与大家分享下。

答应接受他的挑战几分钟后,我就收到了一个名叫“hackme"的二进制文件,感兴趣的同学可以先下载这个文件自己尝试下,然后再回头来看看这篇文章,如果你在破解过程中有什么收获,记得发封[hackme]打头的邮件到manohar dot vanga at gmail dot com和我分享哦,另外,你也可以参加Hacker News上到讨论

试试手气

首先,我随机测试了一组密码,和我想的一样,失败,下面是我得到的消息:

$ ./hackme
Password, please? password
Oops..

有趣的是,当我试图通过GDB调试这个文件时,我得到了下面这句特别的欢迎词:

$ gdb ./hackme
Reading symbols from /tmp/hack/hackme...(no
debugging symbols found)...done.
(gdb) r
Starting program: ./hackme
Fuck off! no debuggers!

Program exited with code 0364.
(gdb)

ptrace也是同样的结果:

$ strace ./hackme
execve("./hackme", ["./hackme"], [/* 41 vars */]) = 0
brk(0) = 0x9016000
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
... snip ...
ptrace(PTRACE_TRACEME, 0, 0, 0) = -1 EPERM (Operation not permitted)
fstat64(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 3), ...}) = 0
mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb783e000
write(1, "Fuck off! no debuggers!\n", 24Fuck off! no debuggers!) = 24
_exit(2543604) = ?

明知故犯

尽管密码以明文形式保存的可能性接近于0,我还是决定试试

首先,我检查了这个二进制文件中的符号信息有没有被抽取(strip)过:

$ file hackme
hackme: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically
linked (uses shared libs), for GNU/Linux 2.6.27, stripped

很不幸,这是一个抽取过的程序,这条路走不通,GDB对于抽取过的二进制基本不起作用,于是我决定看看这个二进制文件中包含的明文字符串信息:

$ strings hackme
/lib/ld-linux.so.2
libdl.so.2
__gmon_start__
_Jv_RegisterClasses
dlopen
dlsym
libc.so.6
_IO_stdin_used
__libc_start_main
random
GLIBC_2.1
GLIBC_2.0
PTRh
QVhE
[^Ph
[^_]
8%':!06!
%!'460
&64;3
%'<;!3
UWVS
[^_]
Fuck off! no debuggers!
Password, please?
Congratulations!
Oops..

我尝试了所有的明文字符串作为密码,不对,很自然的结果,不过这个输出还是提供了一些有用的信息,至少我知道了输入正确密码后我会看到什么,那就是这句”Congratulations!",另外,它还包含了字符串"libc.so.6",这很可疑,不过看了ltrace的输出,我就明白怎么回事了:

$ ltrace ./hackme
__libc_start_main(0x8048645, 1, 0xbfb48a04, 0x80486b0, 0x8048720
<unfinished ...>
dlopen("/lib/libc.so.6", 2)
= 0xb7757ae0
dlsym(0xb7757ae0, "ptrace")
= 0x00eddf40
dlsym(0xb7757ae0, "scanf")
= 0x00e621a0
dlsym(0xb7757ae0, "printf")
= 0x00e5baa0
Fuck off! no debuggers!
+++ exited (status 244) +++

这里我们再次看到了那句讨人厌的欢迎词,但是不要紧,前面的信息已经足可以说明问题了,libc是通过dlopen动态连接的,而ptrace,scanf以及printf函数的地址都是通过dlsym取得的,很卑鄙的手段!

尽管如此,更刺激的是strings的输出显示这个程序调用了random()函数,但是因为它的密码是不变的,所以对random的调用应该是没有经过seed的,我们稍后再来关注这个。

strings的输出同样解释了这个程序是如何阻止调试的,当你在ptrace环境中调用ptrace函数时,它让ptrace返回了-1.

这个问题可以很容易的通过LD_PRELOAD环境变量来解决,LD_PRELOAD可以包含一个自定义的动态库列表,这个列表中的动态库可以在要调试的可执行文件执行之前预先加载到内存中,这可以很容易的阻止可执行文件调用你不希望它去调用的函数,我很快就写了一个简单的包含我们想要的ptrace函数的新文件:

/* fake ptrace() */
#include <stdio.h>

long ptrace(int x, int y, int z)
{
        printf("B-)\n");
        return 0;
}

...然后编译:

gcc -shared -fPIC -o fake.so fake.c

现在使用strace并加上LD_PRELOAD变量来执行这个程序,可以看到,我们的ptrace函数已经起作用了:

$ strace -E LD_PRELOAD=./fake.so ./hackme
execve("./hackme", ["./hackme"], [/* 24 vars */]) = 0
brk(0)                                  = 0x9727000
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
mmap2(NULL, 8192, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb78a6000
open("./fake", O_RDONLY)                = 3
read(3, "\177ELF\1\1\1\0\0\0\0\0\0\0\0\0\3\0\3\0\1\0\0\0\240\3\0\0004\0\0\0"...,512) = 512
... snip ...
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb78a1000
write(1, "Password, please? ", 18Password, please? ) = 18
read(0, password "password\n", 1024)    = 9
write(1, "Oops..\n", 7Oops..)           = 7
exit_group(7)                           = ?

看起来存放密码的buffer只有1024字节,我尝试了缓冲区溢出但是遇到了栈地址混淆(Stack randomization)(当然如果我没记错,这也是有办法可以关掉的),只是对于一个慵懒的星期五来说,这过于麻烦了一点,更重要的是,我的目的并不是破解这个程序,我只是想得到那个密码。

现在看起来我只有一条路可以走了,那就是逆向这个程序,尽管我实在不愿意在星期五的下午做这种事。但是这种对Geek的挑战还是轻松战胜了我每天都有的懒惰,所以我开始了我的反汇编之旅。

反汇编

我首先从objdump的输出开始入手(建议你在一个新tab中执行这个命令以便我们更好的继续)

$ objdump -D ./hackme > out.asm

一个抽取过得二进制文件的汇编如我想象的一样,那不是一般的混乱,我需要从中快速的找出密码的生成逻辑,从上面的运行结果,我们知道,生成密码的代码段应该是在“Password,please?”这个字符串与“Oops..."字符串之间,于是我首先需要做的就是定位这些字符串。"Password please?"中的“Pa”字符转成16进制就是50后面跟上61,简单的搜索后,我就定位到了这个字符串:

$ grep "50 61" objdumpout.txt
 8048798:       00 50 61              add    %dl,0x61(%eax)

这个字符串的地址就是0x8048799(因为它前面还有一个无效字符),搜索这个地址:

 804859d:       68 99 87 04 08          push   $0x8048799
 80485a2:       ff 15 94 99 04 08       call   *0x8049994

很好!它将这个地址压入了堆栈,并调用了一个函数,我假设这个变量保存的就是dlsym返回的printf函数的地址。

现在,我需要找到"Oops..."字符串,同样的方法:

 8048633:       68 c1 87 04 08          push   $0x80487c1
 8048638:       ff d0                   call   *%eax

同样,我也顺手标注了下“Congratulations”字符串的位置,最后,代码看起来是下面的样子:

 # The "Password, please?" message is being printed here
 804859d:       68 99 87 04 08          push   $0x8048799
 80485a2:       ff 15 94 99 04 08       call   *0x8049994
 80485a8:       8d 45 84                lea    -0x7c(%ebp),%eax
 ... snip ...
 8048626:       83 ec 0c                sub    $0xc,%esp
 # The "Congratulations!" message is being printed here
 8048629:       68 af 87 04 08          push   $0x80487af
 804862e:       eb 08                   jmp    8048638 <dlopen@plt+0x268>
 8048630:       83 ec 0c                sub    $0xc,%esp
 # The "Oops.." message is being printed here
 8048633:       68 c1 87 04 08          push   $0x80487c1
 8048638:       ff d0                   call   *%eax

我很快标记好了这段汇编程序,所以我可以回忆起来我最终是怎么找到答案的:

 804859d:        68 99 87 04 08          push   $0x8048799
 80485a2:       ff 15 94 99 04 08       call   *0x8049994
 # The "Password, please?" message is being printed here

 80485a8:       8d 45 84                lea    -0x7c(%ebp),%eax
 # This is probably the address of the password buffer.

 80485ab:       5b                      pop    %ebx
 80485ac:       5e                      pop    %esi

 80485ad:       50                      push   %eax
 80485ae:       68 ac 87 04 08          push   $0x80487ac
 80485b3:       ff 15 90 99 04 08       call   *0x8049990
 80485b9:       83 c4 10                add    $0x10,%esp
 # Push the password buffer and the string "%s" onto the stack and call scanf

 80485bc:       31 c0                   xor    %eax,%eax
 # Clear EAX.

 80485be:       eb 01                   jmp    80485c1 <dlopen@plt+0x1f1>
 80485c0: 40                      inc    %eax
 80485c1:  80 7c 05 84 00          cmpb   $0x0,-0x7c(%ebp,%eax,1)
 80485c6:       75 f8                   jne    80485c0 <dlopen@plt+0x1f0>
 # Find the string length of the password we entered. Return value in EAX.

 80485c8:       31 db                   xor    %ebx,%ebx

 80485ca:       83 f8 13                cmp    $0x13,%eax
 80485cd:       0f 94 c3                sete   %bl
 # Hmm! If the strlen(buf) != 0x13) BL is set to 1! We have our first hint!

 80485d0:       be 0a 00 00 00          mov    $0xa,%esi
 # Move integer 10 into ESI. This is the start of a loop that runs 10 times.

 80485d5:       e8 b6 fd ff ff          call   8048390 <random@plt>
 # Call random(). Return value in EAX

 80485da:       b9 13 00 00 00          mov    $0x13,%ecx
 80485df:       99                      cltd
 80485e0:       f7 f9                   idiv   %ecx
 # Divide the random number in EAX with 19. EAX is quotient, EDX is remainder.

 80485e2:       31 c0                   xor    %eax,%eax
 # Throw away quotient.

 80485e4:       8a 8a 9c 86 04 08       mov    0x804869c(%edx),%cl
 # Hmm. That address looks like a lookup table of some sort.
 # The operation is basically doing "CL = table[remainder]".
 # Since remainder can't be more that 19, I dump the first 19 bytes of this
 # address:
 #     0xfb, 0x4c, 0x8d, 0x58, 0x0f, 0xd4, 0xe8, 0x94, 0x98, 0xee,
 #     0x6b, 0x18, 0x30, 0xe0, 0x55, 0xc5, 0x28, 0x0e

 80485ea:       0f b6 7c 15 84          movzbl -0x7c(%ebp,%edx,1),%edi
 # This basically does EDI = password[remainder]

 80485ef:       42                      inc    %edx
 80485f0:       89 95 74 ff ff ff       mov    %edx,-0x8c(%ebp)
 # Increment the remainder and store it in another variable

 80485f6:       31 d2                   xor    %edx,%edx
 80485f8:       eb 0c                   jmp    8048606 <dlopen@plt+0x236>
 80485fa:  69 c0 8d 78 01 6d       imul   $0x6d01788d,%eax,%eax
 8048600:       42                      inc    %edx
 8048601:       05 39 30 00 00          add    $0x3039,%eax
 8048606: 3b 95 74 ff ff ff       cmp    -0x8c(%ebp),%edx
 804860c:       7c ec                   jl     80485fa <dlopen@plt+0x22a>
 # This is a weird loop. It seems to be a pseudorandom generator.
 # The loop runs while a counter is less than the incremented remainder above.
 # Inside, it's doing the following (remember eax was cleared above to 0):
 #     eax = eax * 0x6d01788d //This is a prime number according to Wolfram Alpha
 #     eax += 0x3039 // 12345 in decimal
 # That is an unseeded (or seeded to 0) pseudorandom generator! Nice but
 # pointless as it is unseeded.

 804860e:       31 f8                   xor    %edi,%eax
 # XOR the pseudorandom value above with password[remainder] as stored above

 8048610:       38 c1                   cmp    %al,%cl
 # Compare the lower byte of the XOR'ed result with the lookup table entry stored in CL

 8048612:       b8 00 00 00 00          mov    $0x0,%eax
 8048617:       0f 45 d8                cmovne %eax,%ebx
 # If the lower byte of the XOR is not equal to the lookup table entry set EBX=0

 804861a:       4e                      dec    %esi
 804861b:       75 b8                   jne    80485d5 <dlopen@plt+0x205>
 # Decrement the main loop counter (the one that runs 10 times) and jump
 # if more iterations are left

 804861d:       85 db                   test   %ebx,%ebx
 804861f:       a1 94 99 04 08          mov    0x8049994,%eax
 8048624:       74 0a                   je     8048630 <dlopen@plt+0x260>
 # At last! Jump to the failure message (past the congratulations) if EBX is 0!
 # EBX should be non-zero in order to print the congratulations message!

 8048626:       83 ec 0c                sub    $0xc,%esp

 # The "Congratulations!" message is being printed here
 8048629:       68 af 87 04 08          push   $0x80487af
 804862e:       eb 08                   jmp    8048638 <dlopen@plt+0x268>
 8048630:       83 ec 0c                sub    $0xc,%esp

 # The "Oops.." message is being printed here
 8048633:       68 c1 87 04 08          push   $0x80487c1
 8048638:       ff d0                   call   *%eax

哇!还不坏,情况没我想的那么糟,将这些生成密码的汇编转换成C代码并进行测试花费了我一些时间,最后我得到了下面的C代码:

#include <stdio.h>
#include <string.h>

int main()
{
        int i, j, edi;
        char buf[50], ch;
        char out[50];
        unsigned char check;
        int ret = 0, val, len, rem;
        int magic;
        int k;
        unsigned char arr[] = {0x6a, 0xfb, 0x4c, 0x8d, 0x58, 0x0f, 0xd4, 0xe8,
                0x94, 0x98, 0xee, 0x6b, 0x18, 0x30, 0xe0, 0x55, 0xc5, 0x28,
                0x0e};

        for (i = 0; i < 19; i++)
                out[i] = 'x';
        out[i] = '\0';

        for (i = 10; i > 0; i--) {
                int m2;

                val = random();
                rem = val%19;
                check = arr[rem] & 0xff;
                ch = buf[rem++];

                j = 0;
                magic = 0;
                printf("rem = %d\n", rem);
                while (j < rem) {
                        magic *= 1828812941;
                        magic += 12345;
                        j++;
                }
                m2 = magic;

                magic ^= ch;
                out[rem - 1] = (m2 & 0xff) ^ (check & 0xff));
        }
        printf("Password: %s\n", out);
}

现在让我们来运行看看:

$ ./decompiled
rem = 3
rem = 16
rem = 4
rem = 4
rem = 11
rem = 9
rem = 11
rem = 12
rem = 3
rem = 8
Password: xxsaxxxpexYoxxxexxx

那个可执行文件只执行了10次循环,它会持续的检查密码的偏移,而密码中只有那些没有标记成x的字符会起作用(我故意将它显示成了这样)

现在,到了最激动人心的时刻了,让我们来再次运行hackme试试看:

$ ./hackme
Password, please? xxsaxxxpexYoxxxexxx
Congratulations!

哈哈,搞定!

结论

那么我从中学到了什么呢?

了解你的工具

我可以很容易的基于过去的知识和经验为我遇到的问题选择最合适的工具,你对你的工具了解越多,你就越有可能更快的发现问题的本质(在这个例子中,就是那段反汇编后的密码生成代码)。

不断尝试

尽管我知道我不大可能轻松破解这个程序,但我还是尝试了所有这些简单的方法,尽管它们没有提供太多有用的信息,但它们至少帮我排除了一些选项,为我的后续工作扫清了道路。

掌握汇编

机器指令很难掌握,在这个过程中,我不知道多少次翻出Intel的汇编手册以帮助我理解这些指令的含义,尽管如此,我还是强烈的推荐你们学习GNU的汇编语法,而不是原始汇编,我对Intel的汇编语法很熟悉(比如NASM),但是对于GAS的语法(也就是AT&T的语法),我发现这篇文章这篇文章都很有帮助。

对于这个程序本身的一些看法:

  • 只检查密码的一部分看起来是行不通的,当然全部检查也不会让破解过程困难多少(补充:程序的作者后来告诉我他将主循环设为10次本来是为了调试的,但是后来忘记改回来了)
  • 随机数一开始可能是想吓吓我,但是,因为要保证结果的一致性,所以它不能根据种子来产生随机数,如果我有一个不同版本的libc,同时它有一个不同的random实现,那么恐怕作者最初设定的密码也会失效。
  • 实际上真正的密码是“SesameOpenYourself!”!但是我测试了几个变体,也是有效的,比如“NasaJeeperYouShelby”

最后,不管怎么说,这是个愉快的星期五下午,再一次提醒,欢迎发送评论到我的邮箱:manohar dot vanga at gmail dot com,记得在主题中加入[hackme]。

你可以在这里下载全部文件

-----------
本文来自“hackme: Deconstructing an ELF File”,作者:manohar,翻译:@yuanyiz

想和我们一道传播黑客精神?快来加入吧!