C指针的使用问题

在本节中,我们会研究解引操作和数组下标的误用,也会研究跟字符串、结构体和函数指针有关的问题。

很多安全问题聚焦的是缓冲区溢出的概念。覆写对象边界以外的内存就会导致缓冲区溢出,这块内存可能是本程序的地址空间,也可能是其他进程的,如果是程序地址空间以外的内存,大部分操作系统会发出一个段错误然后终止程序。因为这个原因恶意引发的终止本身就是拒绝服务攻击,这类攻击不会获取未授权的访问,但会试图搞垮应用程序甚至是服务器。

如果缓冲区溢出发生在应用程序的地址空间内,就会导致对数据的未授权访问和(或)控制转移到其他代码段,于是就可能攻陷系统。以超级用户权限运行应用程序更要加倍小心。

下面几种情况可能导致缓冲区溢出:

  • 访问数组元素时没有检查索引值;
  • 对数组指针做指针算术运算时不够小心;
  • gets这样的函数从标准输入读取字符串;
  • 误用strcpystrcat这样的函数。

如果缓冲区溢出发生在栈帧的元素上,就可能把栈帧的返回地址部分覆写为对同一时间创建的恶意代码的调用。查程序栈来获取关于栈帧的详细信息。函数返回时会将控制转移到恶意函数,该函数可以执行任何操作,只受限于当前用户的特权等级。

测试NULL

malloc这类函数时一定要检查返回值,否则可能会导致程序非正常终止。下面说明一般的方法:

float *vector = malloc(20 * sizeof(float));
if(vector == NULL) {
    // malloc分配内存失败
} else {
    // 处理vector 
}

错误使用解引操作

声明和初始化指针的常用方法如下:

int num;
int *pi = #

下面是一种看似等价的声明方法:

int num;
int *pi;
*pi = #

不过,这样是错误的,注意最后一行的解引操作。我们试图把num的地址赋给pi所指向的内存地址(而不是pi)。指针pi还没有被初始化。我们犯了一个简单的错误,误用了解引操作,正确的写法如下:

int num;
int *pi;
pi = #

在原声明int *pi = &num中,星号把变量声明为指针,而不是解引操作。

迷途指针

释放指针后却仍然在引用原来的内存,就会产生迷途指针,这个问题已经在迷途指针中详细讲过了。如果之后试图访问这块内存,其内容可能已经改变。对这块内存进行写操作可能会损坏内存,而读操作则可能返回无效数据,这两种情况都可能导致程序终止。

直到最近,这才被认为是一个安全问题。正如迷途指针(https://www.blackhat.com/presentations/bh-usa-07/Afek/Whitepaper/bh-usa-07-afek-WP.pdf)中所讲的那样,存在利用迷途指针的可能性。不过,这种方法建立在利用C++中的VTable(虚表)的前提下。虚表是函数指针的数组,用来支持C++的虚方法。除非使用涉及函数指针的类似方法,否则在C中这应该不会有问题。

越过数组边界访问内存

没有什么可以阻止程序访问为数组分配的空间以外的内存。在本例中,我们声明并初始化了三个数组来说明这种行为。假设数组分配在连续的内存位置。

char firstName[8] = "1234567";
char middleName[8] = "1234567";
char lastName[8] = "1234567";

middleName[-2] = 'X';
middleName[0] = 'X';
middleName[10] = 'X';

printf("%p %s\n",firstName,firstName);
printf("%p %s\n",middleName,middleName);
printf("%p %s\n",lastName,lastName);

为了说明如何覆写内存,将三个数组初始化为简单的数字。程序的行为会随着编译器和机器而变化,但是这段代码应该能正常运行并覆写firstNamelastName中的字符,输出如下。图7-2说明了内存分配情况。

116 12X4567
108 X234567
100 123456X

图7-2:使用无效的数组索引

数组解释过,用下标计算的地址不会检查索引值,这就是一个简单的缓冲区溢出。

错误计算数组长度

将数组传递给函数时,一定要同时传递数组长度。这个信息帮助函数避免越过数组边界。在下面的replace函数中,将字符串的地址随着替换用的字符以及缓冲区长度一块传入,函数的目的是把字符串中所有的字符都替换为传入的字符,直到NUL字符。长度参数防止函数越过缓冲区写入:

void replace(char buffer[], char replacement, size_t size) {
    size_t count = 0;
    while(*buffer != NUL && count++<size) {
        *buffer = replacement;
        buffer++;
    }
}

在下面的代码中,name数组最多只能装7个字符再加上NUL字符。不过,我们有意越过数组边界写入来说明replace函数的工作原理。我们给replace函数传递了name和替换字符+

char name[8];
strcpy(name,"Alexander");
replace(name,'+',sizeof(name));
printf("%s\n", name);

执行代码后得到如下输出:

++++++++r

只是向数组添加了8个加号,strcpy函数允许缓冲区溢出,但是replace函数不允许,前提还是假设传入的长度信息有效。要谨慎使用strcpy这类不传递缓冲区长度的函数。传递缓冲区长度能提供额外的安全屏障。

错误使用sizeof操作符

错误使用sizeof操作符的一个例子是试图检查指针边界但方法错误。下例为整数数组分配内存,然后把每个元素初始化为0:

int buffer[20];
int *pbuffer = buffer;
for(int i=0; i<sizeof(buffer); i++) {
    *(pbuffer++) = 0;
}

不过,sizeof(buffer)表达式返回了80,因为缓冲区长度以字节计是80(20乘以4字节每元素)。for循环执行了80次而不是20次,这很可能会导致内存访问异常,从而终止应用程序。可以在for表达式的测试条件中用sizeof(buffer)/sizeof(int)来避免这个问题。

一定要匹配指针类型

总是用合适的指针类型来装数据是个好主意。为了说明可能存在的陷阱,考虑下面的代码。将一个整数指针赋值给一个短整数指针:

int num = 2147483647;
int *pi = &num;
short *ps = (short*)pi;
printf("pi: %p Value(16): %x Value(10): %d\n", pi, *pi, *pi);
printf("ps: %p Value(16): %hx Value(10): %hd\n",
        ps, (unsigned short)*ps, (unsigned short)*ps);

这段代码的输出如下:

pi: 100 Value(16): 7fffffff Value(10): 2147483647
ps: 100 Value(16): ffff Value(10): -1

注意,看起来地址100处的第一个十六进制数字要么是7,要么是f,这取决于它是以整数还是短整数显示。这个明显的矛盾是在小字节序机器上运行代码的结果。图7-3说明了地址100处的常量的内存布局。

enter image description here

图7-3:不匹配的指针类型

如果我们把它当做短整数,那就只用前两个字节,于是就得到了短整数值-1。如果我们把它当做整数,就会用4个字节,于是得到2 147 483 647。这类微妙的问题正是导致C和指针如此难的原因。

有界指针

有界指针是指指针的使用被限制在有效的区域内。比如说,现在有一个32个元素的数组,禁止对这个数组使用的指针访问数组前面或后面的任何内存。

C没有对这类指针提供直接支持。不过,程序员可以显式地确保这个限制得以执行,如下所示:

#define SIZE 32

char name[SIZE];
char *p = name;
if(name != NULL) {
    if(p >= name && p < name+SIZE) {
        // 有效指针,继续
    } else {
        // 无效指针,错误分支
    }
}

这种方法比较麻烦,相较而言,使用静态分析工具中讨论的静态分析可能会比较有用。

一种有趣的变化是创建一个指针检验函数(https://www.securecoding.cert.org/confluence/display/seccode/MEM10-C.+Define+and+use+a+pointer+validation+function),要这么做,必须知道初始的位置和范围。

另一种方法是利用ANSI-C和C++的边界模型检查工具(CBMC,http://www.cprover.org/cbmc/)。这个应用程序会对C程序做一些安全问题检查,然后发现数组边界和缓冲区溢出的问题。

注意 C++中的智能指针提供了一种模仿指针同时支持边界检查的方法,不幸的是,C没有智能指针。

字符串的安全问题

字符串相关的安全问题一般发生在越过字符串末尾写入的情况。在本节中,我们主要关注可能造成这种问题的“标准”函数。

如果使用strcpystrcat这类字符串函数,稍不留神就会引发缓冲区溢出。已经有人提出一些方法来取代,但都没有得到广泛认可。strncpystrncat函数可以对这种操作提供一些支持,它们的size_t参数指定要复制的字符的最大数量。不过,如果字符数量计算不正确,替代函数也容易出错。

C11中(Annex K)加入了strcat_sstrcpy_s函数,如果发生缓冲区溢出,它们会返回错误,目前只有Microsoft Visual C++支持。下面这个例子说明了strcpy_s函数的使用,它接受三个参数:目标缓冲区、目标缓冲区的长度以及源缓冲区。如果返回值是0就表示没有错误发生。不过在本例中会有错误发生,因为源缓冲区太大了,目标缓冲区装不下:

char firstName [8];
int result;
result = strcpy_s(firstName,sizeof(firstName),"Alexander");

还有scanf_swscanf_s函数可以用来防止缓冲区溢出。

gets函数从标准输入读取一个字符串,并把字符保存在目标缓冲区中,它可能会越过缓冲区的声明长度写入。如果字符串太长的话,就会发生缓冲区溢出。

有些Linux系统也支持strlcpystrlcat函数,但GNU C库不支持。有人认为这两个函数制造的问题比解决的还多,而且文档不全。

使用某些函数可能造成攻击者用格式化字符串攻击的方法访问内存。在这类攻击中,将用户提供的格式化字符串(如下所示)打造得可以访问内存,甚至能够注入代码。在这个简单的程序中,我们将第二个命令行参数作为printf函数的第一个参数:

int main(int argc, char** argv) {
    printf(argv[1]);
    ...
}

这个程序可以用类似于下面的命令执行:

main.exe "User Supplied Input"

输出类似于:

User Supplied Input

程序本身无害,但是精巧的攻击真的可以造成损害。这里不会就这个话题展开,不过,如何实现这样的攻击可以在hackerproof.org上找到。

printffprintfsnprintfsyslog这些函数都接受格式化字符串作为参数,避免这类攻击的一种简单方法是永远不要把用户提供的格式化字符串传给这些函数。

指针算术运算和结构体

我们应该只对数组使用指针算术运算,因为数组肯定分配在连续的内存块上,指针算术运算可以得到有效的偏移量。不过,不应该将它们用在结构体内,因为结构体的字段可能分配在不连续的内存区域。

下面这个结构体说明了这一点。为name字段分配10字节,之后是一个整数。然而,整数是对齐到4字节边界的,所以两个字段之间会有空隙。这类空隙在为结构体分配内存的“结构体的内存如何分配”中解释过了。

typedef struct _employee {
    char name[10];
    int age;
} Employee;

下面的代码试图用指针来访问结构体的age字段:

Employee employee;
// 初始化employee
char *ptr = employee.name;
ptr += sizeof(employee.name);

指针包含地址110,这是两个字段之间的2字节的地址,解引指针会把地址110处的4字节当做整数,如图7-4所示。

enter image description here

图7-4:结构体填充示例

警告 误用对齐的指针可能会导致程序非正常终止或是取到错误数据。此外,如果编译器需要生成额外的机器码来弥补不恰当的对齐,那么指针访问也可能变慢。

即使结构体内的内存是连续的,用指针算术运算来访问结构体的字段也不是好做法。下面的结构体定义了由三个整数组成的Item,通常会将三个整数字段分配在连续的内存位置,不过也不一定:

typedef struct _item {
    int partNumber;
    int quantity;
    int binNumber;
}Item;

下面的代码片段声明了一个部件,然后用指针算术运算访问每个字段:

Item part = {12345, 35, 107};
int *pi = &part.partNumber;
printf("Part number: %d\n",*pi);
pi++;
printf("Quantity: %d\n",*pi);
pi++;
printf("Bin number: %d\n",*pi);

通常,输出就是我们所期望的那样,但也有例外。更好的办法是把每个字段赋给pi

int *pi = &part.partNumber;
printf("Part number: %d\n",*pi);
pi = &part.quantity;
printf("Quantity: %d\n",*pi);
pi = &part.binNumber;
printf("Bin number: %d\n",*pi);

更好的办法是根本不用指针,如下所示:

printf("Part number: %d\n",part.partNumber);
printf("Quantity: %d\n",part.quantity);
printf("Bin number: %d\n",part.binNumber);

函数指针的问题

函数和函数指针用来控制程序的执行顺序,但是它们可能会被误用,导致不可预期的行为。考虑下面的getSystemStatus函数的使用,它返回反应系统状态的整数:

int getSystemStatus() {
    int status;
    ...
    return status;
}

下面是判断系统状态是否为0的最好方法:

if(getSystemStatus() == 0) {
    printf("Status is 0\n");
} else {
    printf("Status is not 0\n");
}

接下来这个例子中,忘记了加上括号,这段代码不会正常执行:

if(getSystemStatus == 0) {
    printf("Status is 0\n");
} else {
    printf("Status is not 0\n");
}

系统会一直执行else分支,在逻辑表达式中,我们把函数的地址和0作比较,而不是调用函数后比较返回值和0。记住,只用函数名本身时返回的是函数的地址。

一个类似的错误是直接使用函数返回值,而不会将它的结果和其他值进行比较,这样会返回地址然后计算真假,而函数的地址不大可能是0,结果就是返回的地址计算为真,因为C把非0值都作为真:

if(getSystemStatus) {
    // 永远为真
}

我们应该像下面这样写函数调用来判断状态是否为0:

if(getSystemStatus()) {

如果函数和函数指针的签名不同,不要把函数赋给函数指针,这样会导致未定义的行为,一个误用的例子如下所示:

int (*fptrCompute)(int,int);
int add(int n1, int n2, int n3) {
    return n1+n2+n3;
}

fptrCompute = add;
fptrCompute(2,5);

我们试图只用两个参数调用add函数,而该函数期望的是三个参数,代码能编译,但是输出是不确定的。

函数指针可以执行不同的函数,这取决于分配给它的地址。比如说,我们可能想为一般的操作使用printf函数,但是有时候为了打印特定日志需要换成别的函数,那么可以像下面这样声明并使用函数指针:

int (*fptrIndirect)(const char *, ...) = printf;
fptrIndirect("Executing printf indirectly");

攻击者可能用缓冲区溢出来覆写函数指针的地址,如果发生这类攻击,控制可能会转移到内存中的任意位置。

赞(1)

评论 抢沙发

评论前必须登录!

 

C指针