第4章
Linux安全机制
4.1 Linux基础
4.1.1 常用命令
shell是一个用户与Linux进行交互的接口程序,通常它会输出一个提示符,等待用户输入命令。如果该命令行的第一个单词不是一个内置的shell命令,那么shell就会假设这是一个可执行文件的名字,它将加载并运行这个文件。bash是当前Linux标准的默认shell,我们也可以选用其他的shell脚本语言,如zsh、fish等。下面我们列举一些日常使用的命令。
4.1.2 流、管道和重定向
在操作系统中,流(stream)是一个很重要的概念,可以把它简单理解成一串连续的、可边读边处理的数据。其中标准流(standard streams)可以分为标准输入、标准输出和标准错误。
文件描述符(file descriptor)是内核为管理已打开文件所创建的索引,使用一个非负整数来指代被打开的文件。Linux中一切皆可看作文件,流也不例外,所以输入和输出就被当作对应文件的读和写来执行。标准流定义在头文件unistd.h中,如下所示。
管道(pipeline)是指一系列进程通过标准流连接在了一起,前一个进程的输出(stdout)直接作为后一个进程的输入(stdin)。管道符号为“|”,例如:“$ ps -aux | grep bash”。
了解了流和管道的概念,我们再来看什么是输入输出重定向,如下所示。
4.1.3 根目录结构
Linux中一切都可以看成文件,所有的文件和目录被组织成一个以根节点(斜杠/)开始的倒置的树状结构,系统中的每个文件都是根目录的直接或间接后代。
Linux文件的三种基本文件类型分别如下。
? 普通文件:包含文本文件(只含ASCII或Unicode字符,换行符为“\n”,即十六进制0x0A)和二进制文件(所有其他文件);
? 目录:包含一组链接的文件,其中每个链接都将一个文件名映射到一个文件,这个文件可能是另一个目录;
? 特殊文件:包括块文件、符号链接、管道、套接字等。
目录层次结构中的位置用路径名来指定,分为绝对路径名(从根节点开始)和相对路径名(从当前工作目录开始)两种。通过tree命令可以更直观地查看目录树。
4.1.4 用户组及文件权限
Linux是一个支持多用户的操作系统,每个用户都有User ID(UID)和Group ID(GID),其中UID是对一个用户的单一身份标识,而GID则对应多个UID。知道某个用户的UID和GID是非常有用的,一些程序可能就需要UID/GID来运行。可以使用id命令来查看:
UID为0的root用户类似于系统管理员,它具有系统的完全访问权。我自己新建的普通用户firmy,其UID为1000,是一个普通用户。GID的关系存储在/etc/group文件中。
所有用户的信息(除了密码)都保存在/etc/passwd文件中,为了安全起见,加密过的用户密码则保存在/etc/shadow文件中,此文件只有root权限可以访问。
由于普通用户的权限比较低,这里使用sudo命令可以让普通用户以root用户的身份运行某一命令。使用su命令则可以切换到一个不同的用户。
whoami用于打印当前有效的用户名称,shell中普通用户以“$”开头,root用户以“#”开头。在输入密码后,我们已经从firmy用户转换到root用户了。
在Linux中,文件或目录权限的控制分别以读取、写入和执行3种一般权限来区分,另有3种特殊权限可供运用。可以使用ls -l [file]来查看某文件或目录的信息。
第一栏的第一个字母代表文件类型,第一行是一个目录(d),第二行是链接文件(l),而第三行是普通文件(-)。
第一栏从第二个字母开始就是权限字符串,权限标志三个为一组,依次是所有者权限、组权限和其他人权限。每组的顺序均为“rwx”,权限表示成相应的字母,没有权限则用“-”表示。其中,r代表读取权限(查),对应数字“4”;w代表写入权限(增、删、改),对应数字“2”;x代表执行权限(执行文件、进入目录),对应数字“1”。如下表所示。
用户可以使用chmod命令变更文件与目录的权限。权限范围被指定为所有者(u)、所属组(g)、其他人(o)和所有人(a)。chmod命令的用法如下所示。
? -R:递归处理,将目录下的所有文件及子目录一并处理
? <权限范围> + <权限设置>:添加权限。例如:$ chmod a+r [file]
? <权限范围> - <权限设置>:删除权限。例如:$ chmod u-w [file]
? <权限范围> = <权限设置>:指定权限。例如:$ chmod g=rwx [file]
4.1.5 环境变量
环境变量相当于给系统或应用程序设置了一些参数,例如共享库的位置,命令行的参数等信息,对于程序的运行十分重要。环境变量字符串以“name=value”这样的形式存在,大多数name由大写字母加下画线组成,通常把name部分称为环境变量名,value部分称为环境变量的值,其中value需要以“/0”结尾。
Linux环境变量的分类方法通常有下面两种。
(1)按照生命周期划分
? 永久环境变量:修改相关配置文件,永久生效;
? 临时环境变量:通过export命令在当前终端下声明,关闭终端后失效。
(2)按照作用域划分
? 系统环境变量:对该系统中所有用户生效,可以在“/etc/profile”文件中声明;
? 用户环境变量:对特定用户生效,可以在“~/.bashrc”文件中声明。
使用命令env可以打印出所有的环境变量,也可以对环境变量进行设置。下面我们来看一些常见的环境变量。
LD_PRELOAD
LD_PRELOAD环境变量可以定义程序运行时优先加载的动态链接库,这就允许预加载库中的函数和符号能够覆盖掉后加载的库中的函数和符号。在CTF中,我们可能需要加载一个特定的libc,这时就可以通过定义该变量来实现。举例如下。
需要注意的是,ELF文件的INTERP字段指定了解释器ld.so的位置,如果该路径与动态链接库的位置不匹配,则会触发错误。关于这一点会在编译debug版本的glibc一节(5.1.3节)中做深入讲解。
environ
libc中定义的全局变量environ指向内存中的环境变量表,更具体地,该表就位于栈上,因此通过泄露environ指针的地址,即可获得栈地址。这一技巧在Pwn题中很常见,我们会在后续章节中给出实例。
4.1.6 procfs文件系统
procfs文件系统是Linux内核提供的虚拟文件系统,为访问内核数据提供接口。之所以说是虚拟文件系统,是因为它只占用内存而不占用存储。用户可以通过procfs查看有关系统硬件及当前正在运行进程的信息,甚至可以通过修改其中的某些内容来改变内核的运行状态。
每个正在运行的进程都对应/proc下的一个目录,目录名就是进程的PID,下面我们以命令“cat -”为例,介绍一些比较重要的文件。
4.1.7 字节序
计算机中采用了两种字节存储机制:大端(Big-endian)和小端(Little-endian)。其中大端规定MSB(Most Significan Bit/Byte)在存储时放在低地址,在传输时放在流的开始;LSB(Least Significan Bit/Byte)存储时放在高地址,在传输时放在流的末尾。小端则正好相反。常见的Intel处理器使用小端,而PowerPC系列处理器则使用大端,另外,TCP/IP协议和Java虚拟机的字节序也是大端。
举一个例子,将十六进制整数0x12345678存入以1000H开始的内存,大端和小端的存储形式分别如图4-1所示。作为比对,我们到内存中看一下字符串“12345678”的小端存储情况。
图4-1 大端和小端的不同存储形式
4.1.8 调用约定
函数调用约定是对函数调用时如何传递参数的一种约定。关于它的约定有许多种,下面我们分别从内核接口和用户接口两方面介绍32位和64位Linux的调用约定。
(1)内核接口
? x86-32系统调用约定:Linux系统调用使用寄存器传递参数。eax为syscall_number,ebx、ecx、edx、esi和ebp用于将6个参数传递给系统调用。返回值保存在eax中。所有其他寄存器(包括EFLAGS)都保留在int 0x80中。
? x86-64系统调用约定:内核接口使用的寄存器有rdi、rsi、rdx、r10、r8和r9。系统调用通过syscall指令完成。除了rcx、r11和rax,其他的寄存器都被保留。系统调用的编号必须在寄存器rax中传递。系统调用的参数限制为6个,不直接从堆栈上传递任何参数。返回时,rax中包含了系统调用的结果,而且只有INTEGER或者MEMORY类型的值才会被传递给内核。
(2)用户接口
? x86-32函数调用约定:参数通过栈进行传递。最后一个参数第一个被放入栈中,直到所有的参数都放置完毕,然后执行call指令。这也是Linux上C语言默认的方式。
? x86-64函数调用约定:x86-64下通过寄存器传递参数,这样做比通过栈具有更高的效率。它避免了内存中参数的存取和额外的指令。根据参数类型的不同,会使用寄存器或传参方式。如果参数的类型是MEMORY,则在栈上传递参数。如果类型是INTEGER,则顺序使用rdi、rsi、rdx、rcx、r8和r9。所以如果有多于6个的INTEGER参数,则后面的参数在栈上传递。
4.1.9 核心转储
当程序运行的过程中出现异常终止或崩溃,系统就会将程序崩溃时的内存、寄存器状态、堆栈指针、内存管理信息等记录下来,保存在一个文件中,叫作核心转储(Core Dump)。
会产生核心转储的信号有如下几种。
下面我们开启核心转储并修改转储文件的保存路径。
使用gdb调试核心转储文件,一个简单的例子如下。
4.1.10 系统调用
在Linux中,系统调用是一些内核空间函数,是用户空间访问内核的唯一手段。这些函数与CPU架构有关,x86提供了358个系统调用,x86-64提供了322个系统调用。
在使用汇编写程序(如Shellcode)的时候,常常需要使用系统调用,下面我们以hello world为例,看看32位和64位上的系统调用有何不同。先看一个32位的例子。
程序将调用号保存到eax,参数传递的顺序依次为ebx、ecx、edx、esi和edi。通过int $0x80来执行系统调用,返回值存放在eax。编译执行(也可以编译成64位程序):
虽然软中断int 0x80非常经典,早期2.6及更早版本的内核都使用这种机制进行系统调用,但因其性能较差,在往后的内核中被快速系统调用指令替代,32位系统使用sysenter(对应sysexit)指令,64位系统则使用syscall(对应sysret)指令。
下面是一个32位程序使用sysenter的例子。
可以看到,为了使用sysenter指令,需要为其手动布置栈。这是因为在sysenter返回时,会执行__kernel_vsyscall的后半部分(从0xf7fd5059开始)。__kernel_vsyscall封装了sysenter调用的规范,是vDSO的一部分,而vDSO允许程序在用户层中执行内核代码。关于vDSO会在12.8节中细讲。
编译执行(不可编译成64位程序):
最后我们来看一个64位的例子,它使用了syscall指令。
编译执行(不可编译成32位程序):
从strace的结果可以看出,这几个例子都直接使用了execve、write和exit三个系统调用。但一般情况下,应用程序通过在用户空间实现的应用编程接口(API)而不是系统调用来进行编程,而这些接口很多都是系统调用的封装,例如函数printf()的调用过程如下所示。