C和C++程序通常会对文件进行读写,并将此作为它们正常操作的一部分。不计其数的漏洞正是由这些程序与文件系统(其操作由底层操作系统定义)交互方式的不规则性而产生的。这些漏洞最常由文件的识别问题、特权管理不善,以及竞争条件导致。
8.1 文件I/O基础:安全地执行文件I/O会是一项艰巨的任务,一方面是因为有这么多的接口、操作系统和文件系统的变化。最重要的是,每种操作系统都可以用各种各样的文件系统。
文件系统:许多UNIX和类UNIX操作系统都使用UNIX文件系统(UNIX File System, UFS)。Linux支持广泛的文件系统,包括早期的MINIX、MS-DOS和ext2文件系统。Linux还支持较新的日志文件系统,如ext4、日志文件系统(Journaled File System, JFS)和ReiserFS等。此外,Linux支持加密文件系统(Cryptographic File System, CFS)和虚拟文件系统/proc。Mac OS X为几种不同的文件系统提供内置支持,包括Mac OS分层文件系统扩展格式(Hierarchical File System Extended Format, HFS+)、BSD标准文件系统格式(UFS),网络文件系统(Network File System, NFS)、ISO 9660(用于CD-ROM),MS-DOS, SMB(服务器消息块[Windows文件共享标准])、AFP(AppleTalk文件协议[Mac OS文件共享])和通用磁盘格式(Universal Disk Format, UDF)。这些文件系统中有许多,如NFS、AFS(Andrew文件系统)、Open Group DFS(分布式文件系统),都是分布式文件系统,它们允许用户访问存储在异构的计算机中的共享文件,就像它们被存储在本地用户自己的硬盘驱动器一样。
无论是C或C++标准都没有定义目录或分层文件系统的概念。POSIX规定:系统中的文件被组织在一个分层的结构中,其中所有的非终端节点都是目录,而所有的终端节点都是任何其它类型的文件。
分层文件系统是常见的,虽然平面文件系统也存在。在一个具有层次结构的文件系统中,文件被组织在一个有层次的树状结构中,这个树状结构有一个不被任何其它目录包含的根目录,所有的非叶节点都是目录,所有的叶节点都是其它(非目录)文件系统。由于多个目录条目可引用同一个文件,因此该层次结构被适当地描述为有向非循环图(Directed Acyclic Graph, DAG)。文件由块(通常在磁盘上)的集合组成。在UFS中,每个文件都有一个与之关联的固定长度的记录,称为i节点(i-node),它保留所有文件属性,并保持一个固定的地址块数。目录是由目录条目的列表组成的特殊文件(special file)。目录条目的内容包括目录中的文件名和相关的i-节点的数量。文件都有名称。虽然文件命名约定有所不同。通常情况下,使用一个路径(path)名来代替一个文件名。路径名不但包含一个文件或目录的名称,还包括如何浏览文件系统来找到该文件的信息。绝对路径名以一个文件分割字符开始(在POSIX系统中,通常是一个正斜杠”/”,而在Windows系统中是反斜杠”\”),这意味着路径名中的第一个文件名前面是这个进程的根目录。在MS-DOS和Windows系统上,这个分隔字符也可以通过一个驱动器盘符(例如,C:)前导。如果路径名不以文件分隔符开始,那么称它为相对路径名,并且路径名中的第一个文件名前面是这个进程的当前工作目录。多个路径名可以解析到同一个文件。
特殊文件:包括目录、符号链接、命名管道、套接字和设备文件。目录只包含其它文件(目录的内容)的一个列表。当用ls -l命令查看时,它们都在权限域的第一个字母上标有d。符号链接(symbolic link)是对其它文件的引用。这样的引用被存储为文件路径的一个文字表述。在权限字符串中,用一个l表示符号链接。命名管道(named pipe)使不同的进程能够通信,并可以在文件系统中的任何地方存在。创建命名管道的命令是mkfifo,如mkfifo mypipe。它们用权限字符串中的第一个字母p来表示。套接字(socket)允许在同一台机器上运行的两个进程之间通信。它们用权限字符串的第一个字母s来表示。设备文件(device file)用来申请访问权限和直接操作相应设备驱动器上的文件。字符设备只提供串行数据流的输入和输出(由权限字符串的第一个字母c表示)。块设备是随机访问的(由一个b表示)。各命令执行结果如下图所示:
8.2 文件I/O接口:C中的文件I/O包括在<stdio.h>中定义的所有函数。I/O操作的安全性依赖于具体的编译器实现、操作系统和文件系统。较旧的库与较新的版本相比,通常更容易遭受到安全漏洞攻击。
字节或char类型的字符用于有限字符集的字符数据。字节输入函数执行字节字符和字节字符串的输入:fgetc()、fgets()、gets()、getchar()、fscanf()、scanf()、vfscanf()、vscanf()。
字节输出函数执行字节字符和字节字符串的输出:fputc()、fputs()、putc()、putchar()、fprintf()、vfprintf()、vprintf()。
字节输入/输出函数是ungetc()函数、字节输入函数和字节输出函数的并集。
宽字符或wchar_t类型字符用于自然语言的字符数据。
宽字符输入函数执行宽字符和宽字符串的输入:fgetwc()、fgetws()、getwc()、getwchar()、fwscanf()、wscanf()、vfwscanf()、vwscanf()。
宽字符输出函数执行宽字符和宽字符串的输出:fputwc()、fputws()、putwc()、putchar()、fwprintf()、wprintf()、vfwprintf()、vwprintf()。
宽字符输入/输出函数是ungetwc()函数、宽字符输入函数和宽字符输出函数的并集。因为宽字符输入/输出函数更加新,它们在相应的字节输入/输出函数设计上进行了一些改进。
数据流:输入和输出被映射到逻辑数据流,这些逻辑数据流的属性比它们所连接到的实际物理设备(如终端和结构化存储设备支持的文件)更一致。流通过打开一个文件与一个外部文件关联,这可能涉及创建一个新的文件。创建一个现有的文件会导致其以前的内容被丢弃。如果调用者不对哪些文件可以被打开仔细地加以限制,就可能导致现有的文件被意外覆写,或更糟的情况,即攻击者利用这个漏洞破坏有漏洞的系统上的文件。
通过<stdio.h>中所提供的FILE机制访问的文件称为流文件。
在程序启动时,预定义了三个文本流,并且不必明确打开它们:
(1).stdin:标准输入(用于读常规输入)。
(2).stdout:标准输出(用于常规输出)。
(3).stderr:标准错误(用于写入诊断输出)。
文本流stdin、stdout和stderr是FILE指针类型的表达式。在最初打开时,标准错误流不是完全缓冲的。如果流不是一个交互设备,那么标准输入和标准输出流是完全缓冲的。
打开和关闭文件:fopen(filename, mode)函数打开一个文件,其名称是由文件名指向的字符串,并把它与流相关联。参数mode指向一个字符串。如果该字符串是有效的,那么该文件以指定的模式打开;否则,其行为是未定义的。C99支持以下模式:
(1).r:打开文本文件进行读取。
(2).w:截断至长度为零或创建文本文件用于写入。
(3).a:追加;打开或创建文本文件用于在文件结束处写入。
(4).rb:打开二进制文件进行读取。
(5).wb:截断至长度为零或创建二进制文件用于写入。
(6).ab:追加;打开或创建二进制文件用于在文件结束处写入。
(7).r+:打开文本文件用于更新(读取与写入)。
(8).w+:截断至零长度或创建文本文件用于更新。
(9).a+:追加;打开或创建文本文件用于在文件结束处更新和写入。
(10).r+b或rb+:打开二进制文件用于更新(读取与写入)。
(11).w+b或wb+:截断至长度为零或创建二进制文件用于更新。
(12).a+b或ab+:追加;打开或创建二进制文件用于在文件结束处更新和写入。
C11增加一个独占模式。如果该文件已经存在或无法创建,那么用独占模式(mode参数的最后一个字符是x)打开文件失败。否则,文件被独占(也称为非共享(nonshared))访问式地创建,这个访问的扩展是支持独占访问的底层系统:增加这种模式解决了一个重要的安全漏洞。
(1).wx:创建独占文本文件用于写入。
(2).wbx:创建独占的二进制文件用于写入。
(3).w+x:创建独占的文本文件用于更新。
(4).w+bx或wb+x:创建独占的二进制文件用于更新。
调用fclose()函数来关闭文件,使得这个文件可以从控制流脱离。任何未写入的缓存数据流被传递到主机环境,并被写入到该文件中。任何未读的缓存数据将被丢弃。关闭相关文件(包括标准文本流)后,一个指向FILE对象指针的值是不确定的。引用一个不确定的值是未定义的行为。长度为零的文件(在它上面没有已写入输出流的字符)是否确实存在是实现定义的。关闭的文件可能随后被相同或另一个程序的执行重新打开,并且其内容被回收或修改。如果main()函数返回到原来的调用者,或如果调用exit()函数时,所有打开的文件在程序终止之前关闭(且所有的输出流被刷新)。其它终止程序的路径,如调用abort()函数,不必正确地关闭所有文件。因此,尚未写入到磁盘中的缓冲数据可能会丢失。Linux保证,甚至在程序异常终止时,这个数据也被刷新到磁盘文件。
POSIX:除了支持标准的C文件I/O函数,POSIX定义了一些自己的文件I/O函数。其中包括具有下列签名的打开和关闭文件的函数:
int open(const char* path, into flag, …);
int close(int fildes);
open()函数并不操作FILE对象,它创建一个引用某个文件的打开文件描述(open file description),并创建一个引用该打开文件描述的文件描述符(file descriptor)。此文件描述符用于其它I/O函数,如close(),来引用该文件。
文件描述符是每一个进程为了文件访问的目的,用来识别一个打开的文件的唯一的非负整数。一个文件描述符的取值范围是0~OPEN_MAX。一个进程可以同时打开不超过OPEN_MAX个文件描述符。一种常见的利用攻击是耗尽可用的文件描述符的数量来发动拒绝服务(Dos)攻击。打开文件描述符是一个进程或一组进程正在如何访问文件的记录。文件描述符只是一个标识符或句柄,它实际上并没有描述什么。一个打开文件描述符,包括某个文件的文件偏移量、文件状态和文件访问模式。
C++中的文件I/O:C++中提供与C相同的系统调用和语义,只有语法是不同的。C++的<iostream>库包括了<cstdio>,后者是<stdio.h>的C++版本。因此,C++支持所有的C的I/O函数调用以及<iostream>对象。C++中的文件流不使用FILE,而使用ifstream处理基于文件的输入流,用ofstream处理基于文件的输出流,用iofstream同时处理输入和输出的文件流。所有这些类都继承自fstream并操作字符(字节)。对于使用wchar_t的宽字符I/O,使用wofstream、wifstream、wiofstream、wfstream来处理。
int test_secure_coding_8_2()
{
// 从一个文件list.txt中读取字符数据,并将其写入到标准输出
#ifdef _MSC_VER
const char* name = "E:/GitCode/Messy_Test/testdata/list.txt";
#else
const char* name = "testdata/list.txt";
#endif
std::ifstream infile;
infile.open(name, std::ifstream::in);
if (!infile.is_open()) {
std::cerr << "fail to open file: " << name << std::endl; //fprintf(stderr, "fail to open file: %s\n", name);
return -1;
}
char c;
while (infile >> c)
std::cout << c; //fprintf(stdout, "%c", c);
std::cout << std::endl; //fprintf(stdout, "\n");
infile.close();
return 0;
}
C++提供下列的流来操作字符(字节):
(1).cin取代stdin用于标准输入。
(2).cout取代stdout用于标准输出。
(3).cerr取代stderr用于无缓冲标准错误。
(4).clog用于缓冲标准错误,对记录日志有用。
对于宽字符流,使用wcout、wcin、wcerr、wclog。
8.3 访问控制:不同的文件系统有不同的访问控制模型。UFS和NFS使用的都是UNIX文件权限模型。这绝不是唯一的访问控制模型。例如,AFS和DFS使用访问控制列表(Access Control Model, ACL)。以下介绍UNIX文件权限模型。
权限(permission)和特权(privilege)的含义相似但有所不同,特别是在UNIX文件权限模型的上下文中。特权是通过计算机系统委派的权限。因此,特权位于用户、用户代理或替代,如UNIX进程中。权限是访问资源所必要的特权,因此它与资源(如文件)相关。特权模型往往是特定于系统且复杂的。它们往往会出现”完美风暴”,在管理特权和权限中的错误往往直接导致安全漏洞。UNIX的设计基于大型多用户分时系统,如Multics的思想。UNIX的访问控制模型的基本目标是防止用户和程序恶意(或意外)修改其他用户的数据或操作系统的数据。UNIX系统的用户都有一个用户名,它是用一个用户ID(User ID, UID)来确定的。把一个用户名映射到一个UID所需的信息保存在/etc/passwd文件中。超级UID(root)拥有一个为0的UID,并可以访问任何文件。每个用户都属于一个组,因此也有一个组ID,或GID。用户还可以属于补充组。用户提供自己的用户名和密码给UNIX系统作身份验证。login程序检查/etc/passwd或shadow文件/etc/shadow来确定用户名是否对应到该系统上的有效用户,并检查提供的密码是否与该UID所关联的密码对应。
UNIX文件权限:UNIX文件系统中的每个文件都有一个所有者(UID)和一个组(GID)。所有权决定了哪些用户和进程可以访问文件。只有文件的所有者或root可以改变其权限。这种特权不能被委派或共享。这些权限是:
(1).读:读一个文件或列出一个目录的内容。
(2).写:写入到一个文件或目录。
(3).执行:执行一个文件或递归一个目录树。
对于下列每种用户类别,这些权限可以授予或撤销:
(1).用户:该文件的所有者。
(2).组:属于文件的组成员的用户。
(3).其他:不是文件的所有者或组成员的用户。
文件权限一般都用八进制值的向量表示。在这种情况下,所有者被授予读、写和执行权限;该文件的组成员的用户和其他用户被授予读取和执行权限。
查看权限的另一种方法是在UNIX上使用ls -l命令,如下图所示:权限字符串的第一个字符表示文件类型:普通-、目录d、符号链接l、设备b/c、套接字s或FIFO f/p。权限字符串中的其余字符表示分配给用户、组和其他部分的权限。这些可以是r(读取),w(写入),x(执行),s(set. id)或t(sticky, 粘滞)。当访问一个文件时,进程的有效用户ID(Effective User ID, EUID)与文件所有者的UID进行比对。如果该用户不是所有者,那么再对GID进行比较,然后再测试其他。
进程特权:实际用户ID(Real User ID, RUID)是启动该进程的用户的ID,它与父进程的用户ID是相同的,除非它被改变。有效用户ID是由内核检查权限时,使用的实际ID,因此它确定了进程的权限。如果新的进程映像文件的设置用户ID模式位被设置,则新进程映像的EUID被设置为新进程映射文件的用户ID。最后,保存的设置用户ID(Saved Set-User-ID, SSUID)是执行时设置用户ID程序的进程映像文件的所有者ID。除了进程用户ID,进程也有进程组ID,它基本上与进程用户ID是对应的。每个进程都维护一个组列表,称为补充组ID(supplementary group ID),进程在其中有成员关系。当内核检查组权限时此列表用于EGID。由C标准system()调用,或由POSIX的fork()和exec()系统调用从父进程继承RUID、RGID、EUID、EGID、补充组ID以实例化进程。若要永久放弃特权,则在调用exec()之前把EUID设置为RUID,以使提升的特权不传递给新程序。
更改特权:最小特权原则指出,每一个程序和系统的每一个用户应该使用必要的特权的最小集合来完成作业。如果你的进程正在以提升的特权运行,并访问共享目录或用户目录中的文件,则你的程序就可能会被利用,使得它在程序的用户不具有相应特权的文件上执行操作。暂时或永久删除提升的特权使得程序在访问文件时与非特权用户有同样的限制。提升的特权,可以通过把EUID设置为RUID暂予撤销,它使用操作系统底层权限模型来防止执行任何他们没有权限来执行的操作。C标准没有定义用于权限管理的API。
管理特权:”setuid程序”是有自己的执行时设置用户ID位设置的程序。同样,”setgid程序”也是有自己的执行时设置组ID位设置的程序。不是所有调用setuid()或setgid()的程序都是setuid或setgid程序。setuid程序可以以root身份运行或以更受限制的特权运行。非root的setuid和setgid程序通常用于执行有限或特定的任务。这些程序只限于把EUID更改为RUID和SSUID。在可能的情况下,系统应采用这种方法设计,而不是创建设置用户ID为root的程序。在撤销特权时注意正确的撤销顺序。
管理权限:进程特权管理是成功的一半,另一半则是文件权限管理。
(1).安全目录:在大多数情况下,一个安全的目录是指只有所有者用户,或者可能是管理员,才能创建、重命名、删除,或以其他方式处理文件,除此以外的其他用户都不能执行这些操作的目录。其他用户可以阅读或搜索目录,但一般不得以任何方式修改目录的内容。在安全目录中进行文件操作,消除了攻击者篡改文件或文件系统利用程序文件系统中的漏洞的可能性。要创建一个安全的目录,必须确保目录和它之上的所有目录都被这个用户或超级用户所拥有,不能被其他用户写入,并且不能被任何其他用户删除或改名。
(2).新创建的文件权限:当一个文件被创建,权限应独占地限于其所有者。C标准在它们的附录K之外没有权限的概念,C标准和POSIX标准都没有定义通过fopen()打开文件的默认权限。在POSIX中,操作系统存储一个称为umask的值,它用来在每个进程创建新文件时代表该进程。umask可以用于禁用由创建文件时的系统调用指定的权限位。umask仅适用于文件或目录的创建。操作系统通过计算进程请求的权限与对umask按位求反的结果做按位逻辑乘确定访问权限。创建进程时,进程从其父进程继承了它的umask值。通常情况下,当用户登录时,shell会设置一个默认的umask。
C标准fopen()函数不允许新文件使用指定的权限。无论是C标准还是POSIX标准都没有定义文件的默认权限。大多数实现的默认值为0666。仅有的修改此行为的方法是在调用fopen()函数之前设置umask或在创建文件后调用fchmod()。在文件创建后使用fchmod()来改变权限不是一个好办法,因为它引入了竞争条件。例如,攻击者可以在文件已经创建后但修改权限前访问该文件。正确的做法是在创建该文件之前修改umask。C标准和POSIX标准都没有指定这两个函数之间的相互作用。因此,这种行为是实现定义的,你需要在你的实现上验证这种行为。
C标准的附录K”边界检查接口”,还定义了fopen_s()函数。该标准要求,在创建用户写入的文件时,fopen_s()在操作系统支持的程度,使用一种防止其他用户访问该文件的文件权限。u模式可以被用来创建一个具有系统默认的文件访问权限的文件。这些与通过fopen()创建的文件权限都是相同的。
8.4 文件鉴定:许多与文件相关的安全漏洞由程序访问一个意外的文件对象导致的,因为文件名只松散地与底层的文件对象绑定。文件名没有提供有关文件对象本身性质的信息。此外,每当在操作中使用文件名时,文件名与一个文件对象的绑定都会被重新申请。操作系统把文件描述符和FILE指针绑定到底层文件对象。
目录遍历:目录内的特殊文件名”.”指的是目录本身,”..”指的是目录的父目录。作为一种特例,在根目录中,”..”可能指的是根目录本身。在Windows系统上,还可能提供驱动器盘符(例如C:),以及其它特殊文件名,如”…”,它相当于”../..”。当一个程序对通常由用户提供的路径名进行操作时,若没有进行足够的验证,就会出现目录遍历漏洞。接受”../”形式的输入而没有适当的验证,会允许攻击者遍历文件系统来访问任意文件。
等价错误:当一个攻击者提供不同但等效名字的资源来绕过安全检查时,就会发生路径等价漏洞。做到这一点的方式有很多种,其中有许多是经常被忽视的。例如,路径名结尾的文件分割符可以绕过不期望这个字符的访问规则,从而导致一台服务器提供它通常不会提供的文件。等价错误的另一大类来自区分大小写的问题。
符号链接(symbolic link):是一个方便的解决文件共享的方案。创建符号链接实际上创建了一个具有独特的i-节点(i-node)的新文件。符号链接是特殊的文件,其中包含了实际文件的路径名。符号链接是一个实际的文件,但此文件仅包含一个到另一个文件的引用,该引用存储为用文本表示的路径。如果路径名称解析过程中遇到符号链接,则用符号链接的内容替换链接的名称。
符号链接上的操作与普通文件操作相似,除非所有下列情况为真:该链接是路径名的最后一个组件,路径名没有尾随斜线,而且函数需要在符号链接本身上起作用。
规范化:是一种解决方案,而不是一个问题,但只有当正确使用时才是如此。路径名、目录名、文件名可能包含使验证变得困难和不准确的字符。此外,任何路径名组件都可以是一个符号链接,从而进一步掩盖了文件的实际位置或身份。为了简化文件名验证,建议把名称翻译成规范(canonical)形式。规范形式是某种东西的标准形式或陈述。规范化是把一个名字的等价形式解析到单个标准名称的过程。例如,/usr/../home/rcs相当于/home/rcs,但/home/rcs是规范形式(假设/home不是一个符号链接)。规范化文件名,通过使名字更容易比较,使得路径、目录或文件名更容易验证。规范化也使得防止文件识别漏洞,包括目录遍历和等价错误更容易。规范化也有助于验证包含符号链接的路径名,因为规范形式不包括符号链接。规范化文件名是困难的,并且涉及对底层文件系统的理解。由于不同的操作系统和文件系统的规范形式可以有所不同,因此最好用操作系统的特定机制进行规范化。规范化在验证规范路径名的时间和打开文件的时间之间,存在一种固有的竞争条件。在这段时间内,规范的路径名可能已经被修改,可能不再引用一个有效的文件。
在一般情况下,文件名和文件之间有一个非常宽松的相关性。避免基于一个路径名、目录名或文件名做出决策。特别是,不要因为资源名字而相信它的属性或使用资源的名称用于访问控制。不要使用文件名,而要使用基于操作系统的机制,如UNIX文件权限、访问控制列表,或其他访问控制技术。
Windows中的规范化问题更加复杂,由于Windows命名文件的方法很多,包括通用命名约定(UNC)共享、驱动器映射、短文件名、长文件名、Unicode名称、特殊文件、尾随点、正斜线、反斜杠、快捷方式,等等。最好的建议是,尽量避免完全基于路径名、目录名或文件名做决策。
硬链接:可以使用ln命令创建硬链接。硬链接无法与原目录条目区分,但不能引用目录或跨文件系统引用。删除硬链接不会删除文件,除非该文件的所有引用都已被删除。引用要么是一个硬链接,要么是一个打开的文件描述符。
设备文件:不要在专用于文件的设备上执行操作。在许多操作系统中,包括Windows和UNIX,文件名可能会被用来访问特殊的文件(special file),这些文件实际上是设备。保留的MS-DOS设备名称包括AUX、CON、PRN、COM1、LPT1。在UNIX系统上使用的设备文件,经常应用访问权限并在设备驱动器相应的文件上直接操作。在目的是普通字符或二进制文件的设备文件上执行操作,可能会导致崩溃和拒绝服务攻击。当攻击者可以用未经授权的方式访问UNIX中的设备文件时,可能会有安全风险。在Linux上,打开设备而不是文件,可以锁定某些应用程序。POSIX定义了O_NONBLOCK标志用于open(),从而确保延迟操作一个文件不会使程序挂起。对于Windows系统,GetFileType()函数可以被用来确定该文件是否是一个磁盘文件。
int test_file_io_getfiletype()
{
#ifdef _MSC_VER
const char* file_name = "E:/GitCode/Messy_Test/README.md";
HANDLE handle = CreateFile(file_name, 0, 0, nullptr, OPEN_EXISTING, 0, nullptr);
if (handle == INVALID_HANDLE_VALUE) {
fprintf(stderr, "fail to CreateFile: %s\n", file_name);
return -1;
}
if (GetFileType(handle) != FILE_TYPE_DISK) {
fprintf(stderr, "it's not a disk file: %s\n", file_name);
}
CloseHandle(handle);
#endif
return 0;
}
文件属性:除了文件名,文件通常可以按其它属性来识别,例如,通过比较文件的所有权或创建时间。已经创建和关闭的文件的有关信息可以被存储,然后在文件被重新打开时用于验证文件的识别。比较文件的多个属性增加了重新打开的文件与以前曾操作的文件是相同文件的可能性。
POSIX的stat()函数可用于获取有关某个文件的信息。fstat()函数的功能与stat()类似,但它需要一个文件描述符。你可以使用命令fstat()收集已经打开的文件的有关信息。lstat()函数的功能也与stat()类似,但如果该文件是一个符号链接,那么lstat()报告链接的信息,而stat()报告链接指向的文件信息。如果stat()、fstat()和lstat()函数执行成功,它们返回0;如果发生错误,则返回-1。
int test_file_io_stat()
{
#ifdef _MSC_VER
const char* file_name = "E:/GitCode/Messy_Test/testdata/list.txt";
#else
const char* file_name = "testdata/list.txt";
#endif
struct stat st;
if (stat(file_name, &st) == -1) {
fprintf(stderr, "fail to stat:\n");
return -1;
}
return 0;
}
stat()所返回的结构至少包括以下成员:
(1).dev_t st_dev:包含文件的设备ID。
(2).ino_t st_ino: i-节点编号。
(3).mode_t st_mode:保护。
(4).nlink_t st_nlink:硬链接的数量。
(5).uid_t st_uid:所有者的用户ID。
(6).gid_t st_gid:所有者的组ID。
(7).dev_t st_rdev:设备ID(如果是特殊文件)。
(8).off_t st_size:总字节数。
(9).blksize_t st_blksize:用于文件系统I/O的块大小。
(10).blkcnt_t st_blocks:分配的块数量。
(11).time_t st_atime:最后访问时间。
(12).time_t st_mtime:最后修改时间。
(13).time_t st_ctime:最后状态变更时间。
对于与POSIX兼容的系统上的所有文件类型,结构成员st_mtime, st_mode, st_ino, st_dev, st_uid, st_gid, st_atime, st_ctime都应该保存有意义的值。st_ino域包含文件序号。st_dev域标识包含该文件的路径。st_ino和st_dev两者共同唯一标识该文件。但是,重启或系统崩溃后,st_dev值不一定是一致的,因此,如果在尝试重新打开文件前,有系统崩溃或重启,你可能不能够使用此域来识别文件。
8.5 竞争条件:可以产生自受信(trusted)或非受信的(untrusted)控制流。受信的控制流包括同一程序内紧密耦合的执行线程。非受信的控制流是一个单独的、并发执行的应用程序或进程,它们的起源往往是未知的。
任何支持多任务处理共享资源的系统,都具有源自非受信控制流的竞争条件的可能性。文件和目录通常作为竞争对象。一个文件在一段时间内由独立的函数调用打开、读取或写入、关闭,可能重新打开的文件访问序列,容易造成竞争窗口。打开的文件可以被同等的线程共享,而文件系统可以由独立的进程操纵。
检查时间和使用时间:文件I/O期间可能出现检查时间和使用时间(Time Of Check, Time Of Use, TOCTOU)竞争条件。首先测试(检查)某个竞争对象属性,然后再访问(使用)此竞争对象,TOCTOU竞争条件形成一个竞争窗口。TOCTOU漏洞可能是首先调用stat(),然后调用open(),或者它可能是一个被打开、写入、关闭,并被一个单独的线程重新打开的文件,或者它也可能是先调用一个access(),然后再调用fopen()。
创建而不是替换:C标准fopen()函数和POSIX open()函数都将打开一个现有的文件,如果指定的文件不存在,则创建一个新的文件。防止攻击者在现有的文件上操作的方法之一是,仅当文件不存在时才打开一个文件。为了消除任何潜在的竞争条件,无论是确定该文件是否存在的测试,还是打开的操作,都必须自动进行。
独占访问:由独立的进程产生的竞争条件不能用同步原语来解决,因为这些过程不可能访问共享的全局数据(如一个互斥变量)。C标准附录K,”边界检查接口”包括fopen_s()函数。在底层系统支持的概念的程度上,为写入而打开的文件以独占(也称为非共享)访问方式打开。通过将文件当作锁来使用,仍可以同步这类并发控制流。
文件锁(file lock):文件或文件区域可以被锁定,从而阻止两个进程并发地对其进行访问。Windows支持两种形式的文件锁定:共享锁(shared lock)禁止对锁定的文件区域的所有写访问,但允许所有进程的并发读访问;排他锁(exclusive lock)则对锁定的进程授予不受限制的文件访问权,同时拒绝所有其它进程的访问。对LockFile()的调用可获得共享访问;排他访问可以经由LockFileEx()实现。在这两种情况下,都可以通过调用UnlockFile()来移除锁。共享锁和排他锁都可以消除锁定区域中发生竞争条件的可能性。排他锁类似于一种互斥解决方案,共享锁则通过移除”改变锁定的文件区域的状态(这个竞争条件的一个必备属性)”的可能性,以消除竞争条件。这些Windows文件锁定机制称为强制性锁(mandatory lock),因为每一个尝试访问锁定的文件区域的进程都受到限制。Linux既实现了强制性锁,又实现了建议性锁(advisory lock)。建议性锁并不是由操作系统强迫实施的。
共享目录:当两个或更多用户,或一组用户都拥有对某个目录的写权限时,共享和欺骗的潜在风险比对几个文件的共享访问情况要大得多。因通过硬链接和符号链接的恶意重建所导致的漏洞告诉我们最好避免共享目录。
程序员经常在对所有用户都是可写(如UNIX上的/tmp和/var/tmp目录和Windows上的C:\TEMP)并可以定期清除(例如,每天晚上或重启时)的目录中创建临时文件。临时文件常用于辅助存储并不需要或者不能驻留在内存中的数据,并通过文件系统传输数据,作为与其它进程进行通信的一种手段。例如,一个进程用一个众所周知的名字或一个临时名称在共享目录中创建一个临时文件,并把它传达给合作的进程,那么就可以使用该文件,在这些合作的进程之间共享信息。这是一个危险的做法,因为一个在共享目录中的众所周知的文件很容易被攻击者劫持或操纵。缓解策略包括以下内容:(1).使用其它低级别的IPC(进程间通信)机制,如套接字或共享内存。(2).使用更高级别的IPC机制,如远程过程调用。(3).使用只能被应用实例(确保在同一平台上运行的应用程序的多个实例不存在竞争)访问的安全目录或jail。
在共享目录创建临时文件没有完全安全的方式。为了降低风险,可以把文件创建为具有独特并且不可预知的文件名、仅当文件不存在时打开(原子打开)、用独占访问模式打开、用适当权限打开,并在程序退出之前删除。安全地创建临时文件容易出错,并且依赖于使用的C运行时库的版本、操作系统和文件系统。不要在共享目录中创建临时文件。
8.6 缓解策略:
关闭竞争窗口:由于竞争条件漏洞只存在于竞争窗口期间,因此最显而易见的缓解方案就是尽可能地消除竞争窗口。消除竞争窗口的技术:
(1).互斥缓解方案:UNIX和Windows支持很多能够在一个多线程应用程序中实现临界区的同步原语,包括互斥变量、信号量、管道、命名管道、条件变量、CRITICAL_SECTION(临界区)对象以及锁变量等。一旦识别到两个或更多相冲突的竞争窗口,就应该将它们作为互斥的临界区保护起来。对同步原语的使用要求我们小心翼翼地将临界区的大小减到最小。当竞争条件产生自不同进程时,仅当同步对象都位于共享内存并被多进程感知到,才能使用线程同步原语。在不同的进程间实现互斥的常用缓解方案是使用Windows具名的互斥体对象或POSIX命名信号。在UNIX中一个稍差的方法是用文件作为锁。线程间的同步可能引入死锁的潜在威胁。
(2).线程安全的函数:在多线程应用程序中,仅仅确保应用程序自己的指令内不包含竞争条件是不够的,被调用的函数也有可能造成竞争条件。当宣告一个函数为线程安全的时候,就意味着作者相信这个函数可以被并发线程调用,同时该函数不会导致任何竞争条件问题。不应该假定所有函数都是线程安全的,即使是操作系统提供的API。如果必须调用一个非线程安全的函数,那么最好将它处理为一个临界区,以避免与任何其它代码调用冲突。
(3).使用原子操作:同步原语依赖于原子(不可分割的)操作。
(4).重新打开文件:重新打开一个文件流一般应避免,但对于长期运行的应用程序,这可能是必要的,以避免消耗可用文件描述符。由于文件名在每次打开时重新与文件关联,因此无法保证重新打开的文件就是原始文件。
消除竞争对象:竞争条件的存在,部分原因是某个对象(竞争对象)被并行的执行流所共享。如果可以消除共享对象或移除对共享对象的访问,那么就不可能存在竞争漏洞了。
(1).同一台计算机上任何两个并发的执行流都可以共享访问该计算机的设备和系统提供的形形色色的资源。其中最重要也最容易产生漏洞的共享资源是文件系统。Windows系统还拥有另一个关键的共享资源----注册表。安全起见,应该为系统资源设置最小的访问权限,并且应该定期地安装安全补丁。软件开发人员也应该消除对系统资源不必要的使用,以尽量减少漏洞的暴露。在线程中,尽量少地使用全局变量、静态变量和系统环境变量,可以将潜在的竞争对象出现的可能性降至最低。
(2).使用文件描述符,而非文件名:在一个与文件有关的竞争条件中的竞争对象通常不是文件,而是文件所在的目录。
控制对竞争对象的访问:竞争对象的改变状态属性规定,”必须至少有一个(并发的)控制流会改变竞争对象的状态(有多个流可用对其进行访问)”。这表明,如果很多进程只是对共享对象进行并发的读取,那么对象将保持不变的状态,且不存在竞争条件。
(1).最小特权原则:有时候,可用通过减少进程的特权来消除竞争条件,而其他时候减少特权仅仅可以限制漏洞的暴露。无论如何,最小特权原则都是一种缓解竞争条件以及其它漏洞的明智策略。
(2).安全目录:用以检验文件访问权限的算法必须检查的东西不仅仅包括文件自身的权限,还包括从父目录开始,向上至文件系统根目录的每一个包含目录的权限。保证文件操作在安全目录中执行。在大多数UNIX系统中还可以使用chroot jail技术提供安全的目录结构。
(3).容器的虚拟化:容器提供轻量级的虚拟化技术,让你隔离进程和资源,而不需要提供指令解释机制和其它完全虚拟化的复杂性。容器可以被看作是jail的高级版本,它隔离文件系统、单独的进程ID、网络命名空间等,并限制诸如内存和CPU资源的使用。这种虚拟化形式带来的开销通常很小或根本没有,因为在虚拟分区中的程序使用操作系统的正常系统调用接口且不需要仿真或在中间的虚拟机中运行,就像全系统虚拟化技术,如VMware的情况。容器的虚拟化可用于Linux、Windows和Solaris。
(4).暴露:避免通过用户接口或其它的API暴露你的文件系统的目录结构或文件名。
竞争检测工具:
(1).静态分析:静态分析工具并不通过实际执行软件来进行竞争条件软件分析。这种工具对软件源代码(或者,在某些情况下,二进制执行文件)进行解析,这种解析有时依赖于用户提供的搜索信息和准则。静态分析工具能报告那些显而易见的竞争条件,有时还能根据可察觉的风险为每个报告项目划分等级。
(2).动态分析:动态分析工具通过将侦测过程与实际的程序执行相结合,克服了静态分析工具存在的一些问题。这种方式的优势在于可以使工具获得真实的运行时环境。一个商业工具是来自英特尔公司的Thread Checker。Thread Checker对Linux和Windows上的C++代码的线程竞争和死锁执行动态分析。Helgrind是Valgrind包的工具之一。