Linux提权系列11:理解共享库的概念
2023-4-5 08:2:41 Author: 奶牛安全(查看原文) 阅读量:10 收藏

Linux 中看到的任何程序都是用 C/C++ 编写的,甚至是高级编程语言,如 Java、Python 等,也是在它们写的虚拟机运行。为了使其简单和模块化,C/C++开发人员编写他们的库或使用其他开发人员编写的外部库,可以节省开发时间。

共享库与静态库

有两种类型的库

  • 静态库
  • 共享(动态)库

顾名思义,静态库被合并到代码中并使可执行文件变得庞大。由于它们与可执行文件一起提供,加载时间非常快,但如果库代码中报告了任何错误,则很难分发补丁,因为开发人员必须使用该库重建整个应用程序并分发所有应用程序的新版本。静态库的扩展名是.a

共享库独立安装在系统上或由开发人员在单独的文件中提供,但不嵌入可执行文件中。加载时间会比静态库慢很多,但它们过于模块化且易于分发,因为库的开发人员会修复代码并提供有关如何使用的信息。共享库的扩展名是.so

ELF 文件格式和库搜索顺序

当使用共享库创建可执行文件时,特定的程序头会被注入到 ELF 文件 .dynamic 中,其中包含有关要加载的库的信息。要获取依赖库的列表,可以使用 lddreadelf 工具。要了解有关 ELF 格式的更多信息,应该阅读维基百科页面 https://en.wikipedia.org/wiki/Executable_and_Linkable_Format。

但在真正深入之前,必须知道可执行文件在实际加载之前如何找到库。

在左侧,有库搜索顺序。第一个优先级高,最后一个优先级最低。这意味着,如果在 LD_LIBRARY_PATH 环境变量中找到库,它将停止搜索并加载它。

在右边,会看到一个叫做符号的东西。好吧,符号可以是可执行文件正在使用的库中定义的任何变量或函数名称。这是有道理的,在将库加载到内存之前,程序不能使用库定义的代码。

现在程序如何知道它是否必须加载这个库。这就是 .dynamic 部分的作用。它具有要加载的库的确切名称,并且当搜索目录中的库名称与动态部分中的库名称匹配时,它会立即加载该库。

此外,如果在 /lib/usr/lib 中找不到该库,加载程序将抛出类似“未找到共享库 <name>”的错误。

$ ls -l app 
-rwxr-xr-x 1 root root 16200 Aug  9 00:34 app
$ ldd app 
        linux-vdso.so.1 (0x00007ffde1bef000)
        libc.so.6 => /usr/lib/libc.so.6 (0x00007fa203d80000)
        /lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007fa203f7e000)
$ readelf -d app 

Dynamic section at offset 0x2df8 contains 26 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000000c (INIT)               0x1000
 0x000000000000000d (FINI)               0x12b8
 ---------------- trimmed ------------------

命令 readelf -d app 将提供 .dynamic 部分中的内容列表。而 ldd app 是专门从搜索顺序中找到了可执行文件所需的共享库列表以及文件的位置。

有一个库 linux-vsdo.so.1,从信息安全的角度来看应该忽略它,因为它由内核自动映射到用户空间应用程序。

要了解加载是如何工作的,可以通过传递运行时环境变量 LD_DEBUG=libs 来运行命令

 $ LD_DEBUG=libs ./app 2>&1  | tr -s " "
 154979:        find library=libc.so.6 [0]; searching
 154979:         search cache=/etc/ld.so.cache
 154979:         trying file=/usr/lib/libc.so.6
 154979:
 154979:
 154979:        calling init: /lib64/ld-linux-x86-64.so.2
 154979:
 154979:
 154979:        calling init: /usr/lib/libc.so.6
 154979:
 154979:
 154979:        initialize program: ./app
 154979:
 154979:
 154979:        transferring control: ./app
 154979:
 154979:
 154979:        calling fini: ./app [0]
 154979:

创建和使用共享库

在开始创建库之前, 了解以下两个组件

  • 库本身
  • 头文件

已经知道库包含可执行文件中符号的定义,但在开发过程中,开发人员将如何知道符号的名称、函数或变量等。为此,符号的签名在头文件中定义,也称为原型设计或声明。

因此,从创建一个简单的库开始,该库将包含带有 char [] 参数和 void 返回类型的函数 greet()。在下面的代码中,创建了一个头文件,其中包含有关函数的规范。

#ifndef GREET_H
#define GREET_H

extern void greet(char name[]);

#endif

externC 语言中的一个关键字,用于声明一个全局变量,该变量是一个没有分配任何内存的变量,用于在头文件中声明变量和函数。

现在是时候定义 greet 函数的实现了。这将在以下文件中完成

#include <stdio.h>

void greet(char name[]) {
 printf("Hello, %s!\n", name);
}

在有足够的代码来构建库之后,现在是时候调用 GCC 并构建 so 文件了。为简单起见,将在 /usr/lib 目录中创建库, 也可以使用低权限用户和 LD_LIBRARY_PATH 环境变量。这有助于了解图书馆搜索顺序。

gcc -shared -fPIC -o /usr/lib/libgreet.so greet.{h,c}

在上面的命令中,-shared 用于告诉 GCC 希望从代码中创建一个共享库。如果省略,它将尝试寻找 main 函数并构建一个 ELF 可执行文件。

请注意库的名称,它以 lib 开头。所有库都应以 lib 开头,在向 GCC 构建命令提供名称时,只需要传递 -l<name without lib and extension>

在为下面的可执行二进制文件编写代码后,看看它的实际效果

#include <stdio.h>
#include "greet.h"

int main() {
        char name[20];
        scanf("%20s", name);
        greet(name);
        return 0;
}

现在是时候构建应用程序了

可见,即使在链接阶段,链接器也无法在搜索顺序中找到 libgreet.so 名称。要解决此问题,必须将库的名称传递给链接器。由于库是共享的,名称将添加到程序头的 .dynamic 部分。要解决此问题,必须通过库 -lgreet

$ gcc main.c -o app -lgreet

现在,运行 ldd 命令。它将显示所有库以及自定义 libgreet

$ ldd ./app 
        linux-vdso.so.1 (0x00007ffc6f90d000)
        libgreet.so => /usr/lib/libgreet.so (0x00007f31b7ca1000)
        libc.so.6 => /usr/lib/libc.so.6 (0x00007f31b7ad5000)
        /lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f31b7cd8000)

此外,readelf 将在需要的部分显示库的名称

$ readelf -d app  | grep -E "(NEEDED|OPTIONAL)"
 0x0000000000000001 (NEEDED)             Shared library: [libgreet.so]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]

使用 LD_PRELOAD 执行恶意代码

就像在可执行程序中有 main 函数一样,对于库,有在加载和卸载库时执行的入口和出口函数。

  • _init
  • _fini
  • __attribute__((constructor))
  • __attribute__((destructor))

译者注:加解壳的原理也是在,一般解壳程序就是在__init__attribute__((constructor))进行

更详细可以看<GCC Complete Reference>,中文版是《GCC技术大全》或《GCC技术参考大全》。

中文译版是烂书,不要看那些没写过代码的知名大学老师的译著

前两个已经过时且危险,但仍然支持向后兼容。推荐使用最后两个。在大多数情况下,只需要使用 __attribute__((constructor))

此属性在函数原型设计的名称之后或之前指定,然后定义函数。

#include <stdio.h>

void enter()__attribute__((constructor));
void exit()__attribute__((destructor));

void enter() {
 printf("library loaded\n");
}

void exit() {
 printf("library unloaded\n");
}

现在,在重新编译库代码并执行相同的二进制文件后,将看到何时以及如何调用库构造函数和析构函数

$ sudo gcc -shared -fPIC -o /usr/lib/libgreet.so greet.{h,c}
$ ./app 
library loaded
tbhaxor
Hello, tbhaxor!
library unloaded

现在坏人所做的是,尝试使用 LD_PRELOAD环境变量加载库,而这样做时不必遵循命名约定。看看是怎么样?

#include <stdlib.h>

void shell()__attribute__((constructor));

void shell() {
        unsetenv("LD_PRELOAD");
        system("/bin/bash");
}

在这段代码中,除了 unsetenv("LD_PRELOAD") 之外的一切看起来都很熟悉,当省略这一行时,系统调用的子进程将继承 LD_PRELOAD 并继续调用 exploit.so,直到内存爆满。

请点一下右下角的“在看”,谢谢!!

暗号:444869


文章来源: http://mp.weixin.qq.com/s?__biz=MzU4NjY0NTExNA==&mid=2247488930&idx=1&sn=c945875d3701c9fb7b6787dbbe2c5ffe&chksm=fdf97eb7ca8ef7a19a911b66c8b1f7e3d0040861dfdc44828acdcea77b0daff074496aa8941e#rd
如有侵权请联系:admin#unsafe.sh