0x00 前言

本篇只小结代码层面的C/C++安全问题,具体漏洞利用不在范围内。

基本思路:全局找出调用的危险函数或危险操作,往上回溯确定相关参数是否外部可控

0x01 栈溢出

审计点

常见的危险函数:

  • 输入
    • gets,直接读取一行,忽略’\x00’
    • scanf
    • vscanf
  • 输出
    • sprintf
  • 字符串
    • strcpy,字符串复制,遇到’\x00’停止
    • strcat,字符串拼接,遇到’\x00’停止
    • bcopy

对应的安全函数:

  • gets——fgets、StringCchGets、gets_s
  • 字符串处理函数:strcpy、strcat、strncpy、strncat、strlen,输出函数sprintf和snprintf,内存拷贝函数memcpy——对于使用Microsoft编译器的用户,考虑使用StrSate库中的函数:StringCchCopy,StringCchCopyN,StringCchCopyEx,StringCchCopyNEx,StringCbCopy,StringCbCopyN,StringCbCopyEx,StringCbCopyNEx,strnlen_s,StringCchPrintf,StringCchVPrintf,StringCchPrintfEx,StringCchVPrintfEx,StringCbPrintf,StringVCbPrintf,StringCbPrintEx,StringCbVPrintEx;对于使用Gcc编译器的用户,考虑使用libssp库,例如__strcpy_chk.c__strncpy_chk.c;另外可以使用支持C/C++标准的新版本编译器,并使用新的安全的标准库函数,例如C11标准附录K中的函数strcpy_sstrncpy_ssprintf_s_snprintf_s_snwprintf_s_vstprintf_s_vsntprintf_s
  • scanf——sscanf_s

随机数值用作长度参数

将随机数值作为strncpy()等函数的长度参数,会导致不可预知的行为发生。

由于rand()等函数返回的整型数值是随机的,其长度可能超过缓冲区的最大长度,甚至可能为负值。该情况会造成缓冲区越界。

Demo

1
2
len = rand();
strncpy(dest, src, len);

用例中,长度参数len经过了长度验证,但验证的行为并不充分:该验证并未考虑到返回负值的情况。

1
2
3
4
5
6
#define URAND31() (((unsigned)rand()<<30) ^ ((unsigned)rand()<<15) ^ rand())
#define RAND32() ((int)(rand() & 1 ? URAND31() : -URAND31() - 1))
char dest[100];
len = RAND32();
if (len < 100)
strncpy(dest, src, len);

修复

不要将随机数用作strncpy()等库函数的长度参数。

如果需要随机数作为strncpy()等库函数的长度参数,需要对其长度进行有效的限制。

1
2
3
4
5
6
#define URAND31() (((unsigned)rand()<<30) ^ ((unsigned)rand()<<15) ^ rand())
#define RAND32() ((int)(rand() & 1 ? URAND31() : -URAND31() - 1))
char dest[100];
len = RAND32();
if (len > 0 && len < 100)
strncpy(dest, src, len);

0x02 堆溢出

审计点

堆分配函数:

  • malloc
  • calloc
  • realloc

常见的危险函数:

  • 输入
    • gets,直接读取一行,忽略 '\x00'
    • scanf
    • vscanf
  • 输出
    • sprintf
  • 字符串
    • strcpy,字符串复制,遇到 '\x00' 停止
    • strcat,字符串拼接,遇到 '\x00' 停止
    • bcopy

对应的安全函数:

alloca——SafeAllocA

0x03 UAF

如果内存在释放后继续使用,可能会造成无法预测的结果。

释放后使用会导致严重的问题。使用已经释放的内存将会导致合法数据损坏,或者执行任意代码,具体取决于当时的运行状态。

当内存被释放,又没有重新分配指针之前,它的内容依然可以访问。这些被释放内存上的数据看似合法,但是会发生非预期的改变,最终可能导致出现意料之外的代码行为。

Demo

例1,代码中int *x = malloc(4)分配的指针x在free(x)释放之后在返回语句进行解引用,这就发生了对已经释放的内存指针进行解引用。

1
2
3
4
5
6
7
#include <stdlib.h>
int foo() {
int *x = (int *)malloc(4);
*x = 10;
free(x);
return *x;
}

例2,代码中可能因为t的值不为0而导致x被释放,之后返回了被释放的指针。

1
2
3
4
5
6
7
8
9
10
11
#include <stdlib.h>
int *foo(int t) {
static int *x = NULL;
if (!x) {
x = (int *)malloc(sizeof(int));
}
if (t) {
free(x);
}
return x;
}

修复

要避免释放后再使用问题,可以采取以下几种措施:

  1. 在指针被释放后将其设为空值。
  2. 确保全局变量仅被释放一次。
  3. 在循环或条件语句中释放内存或重新分配内存时,尤其要多加小心。

上述代码中在free之前将x所指向的int值赋值给局部变量a,再返回a的值,从而避免了释放后再使用的问题。

1
2
3
4
5
6
7
8
#include <stdlib.h>
int foo() {
int *x = (int *)malloc(4);
*x = 10;
int a = *x;
free(x);
return a;
}

0x04 Double Free

如果内存在释放后再次进行重复释放,可能会造成无法预测的结果。

当程序重复释放内存时,内存管理数据结构将被破坏,引起程序崩溃或者返回与上一次相同的指针。在这种情况下,攻击者能够成功的控制数据写入到多重分配的内存,这将导致缓冲区溢出的漏洞攻击。

在C++代码中,下述浅拷贝的情况可能会导致内存重复释放:

如调用一次赋值运算符或拷贝构造函数将会导致两个对象的数据成员指向相同的动态内存。缺少一种合适的引用计数设备,当第一个对象超出作用域时,析构函数将会释放这两个对象共享的内存。第二个对象中对应的数据成员将会指向已经释放的内存地址。当第二个对象超出作用域时,它的析构函数试图再次释放这块内存。这会导致应用程序崩溃或堆内存损坏。

Demo

例1,代码中 release(a) 已经对a进行了释放, 稍后的两个free语句再次对a进行了重复释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdlib.h>
typedef struct x {
char * field;
} tx;
void release(tx * a){
free(a->field);
free(a);
}
int main() {
tx *a = (tx *)malloc(sizeof(tx));
if (a==NULL) return;
a->field = (char *)malloc(10);
release(a);
free(a->field);
free(a);
return 0;
}

例2,示例中类C为C::data动态分配内存,但是没有定义赋值运算符。结果,当函数main()执行时,c1.data和c2.data具有相同的值,当析构时都调用delete[]操作符,相同的指针被释放两次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;
class C {
char *data;
C(const C &) {}
public:
C() { data = new char[10]; }
~C() {
cout << "Calling delete for " << (void *)data << endl;
delete[] data;
}
};
int main() {
C c1;
C c2;
c1 = c2;
}

例3,示例中,因为没有定义拷贝构造函数,编译器生成一个从一个实例拷贝所有值到另一个实例的拷贝构造函数。结果,c1.data和c2.data具有相同的值,当析构函数被调用时释放了两次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;
class C {
char *data;
C &operator=(const C &) { return *this; }
public:
C() { data = new char[10]; }
~C() {
cout << "Calling delete for " << (void *)data << endl;
delete[] data;
}
};
int main() {
C c1;
C c2 = c1;
return 0;
}

例4,示例中,在赋值运算符中对成员指针d执行浅拷贝。在析构函数~C()中释放相应的内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct D {};
class C {
public:
C();
~C() { delete d; }
C &operator=(const C &rhs) {
d = rhs.d; // shallow copy
return *this;
}
private:
C(const C &rhs);
D *d;
};

例5,示例中,在拷贝构造函数中对成员指针d执行浅拷贝。在析构函数~C()中释放相应的内存。

1
2
3
4
5
6
7
8
9
10
11
12
struct D {};
class C {
public:
C();
~C() { delete d; }
C(const C &rhs) {
d = rhs.d; // shallow copy
}
private:
C &operator=(const C &rhs);
D *d;
};

修复

例1:为了解决这个问题,应该定义赋值运算符。依据具体的情况,可以使用不同的赋值运算符实现。

(1)如果需要申请新内存,赋值运算符的实现应该检测是否是自赋值,释放原来内存,申请新内存,并且拷贝数据:

1
2
3
4
5
6
7
8
9
10
11
12
class C {
// ...
C &operator=(const C &src) {
if (&src == this)
return *this;
delete[] data;
data = new char[10];
memcpy(data, src.data, 10);
return *this;
}
// ...
};

(2)先前的实现执行了不必要的堆内存操作。由于释放的数据和申请的数据具有相同大小的结构,在这种情况下原来的内存是可以被重用的:

1
2
3
4
5
6
7
8
9
10
class C {
// ...
C &operator=(const C &src) {
if (&src == this)
return *this;
memcpy(data, src.data, 10);
return *this;
}
// ...
};

(3)如果不想要拷贝类实例,赋值运算符应该被声明为私有的。在这种情况下,如果试图拷贝一个对象,编译器就会产生一个错误:

1
2
3
4
5
6
class C {
// ...
private:
C &operator=(const C &) { return *this; }
// ...
};

例2:为了解决这个问题,依据具体的情况,可以使用两种不同的实现方式中的一个。

(1)通常,定义一个深拷贝构造函数:

1
2
3
4
5
6
7
8
class C {
// ...
C(const C &src) {
data = new char[10];
memcpy(data, src.data, 10);
}
// ...
};

(2)如果不想要拷贝类实例,拷贝构造函数应该被声明为私有的。

1
2
3
4
5
6
class C {
// ...
private:
C(const C &src) { /* do not create copies */ }
// ...
};

例3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct D {
/* omitted for brevity */
};
class C {
public:
C();
~C() { delete d; }
C &operator=(const C &rhs) {
d = new D(*rhs.d);
return *this;
}
private:
C(const C &rhs);
D *d;
};

在示例中,在赋值运算符中执行深拷贝。因此,当这两个对象的析构函数被调用时不会发生两次释放内存。

例4:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct D {
/* omitted for brevity */
};
class C {
public:
C();
~C() { delete d; }
C(const C &rhs) {
d = new D((*rhs.d);
}
private:
C &operator=(const C &rhs);
D *d;
};

在示例中,在拷贝构造函数中执行深拷贝。因此,当这两个对象的析构函数被调用时不会发生两次释放内存。

0x05 整数溢出

整数溢出分为上界溢出和下界溢出。

一般出现整数溢出的场景:

  • 未限制范围
  • 错误的类型转换(范围大的变量赋值给范围小的变量、只做了单边限制)

Demo

size类型为unsigned int,最大取值为65535,当超过这个值时,截断导致越过size检查,然后,在memcpy()函数函数中造成栈溢出,程序crash,可能导致代码执行。堆上的整数溢出同理,只是溢出对象是堆数据,导致覆盖的时候后面的堆结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//栈的整数溢出
#include<stdio.h>
#include<string.h>
int main(int argc,char* argv){
int i;
char buf[8];
unsigned short int size;
char overflow[65550];
memset(overflow,65,sizeof(overflow));
printf("please input size:\n");
scanf("%d",&i);
size =i;
printf("size:%d",size);
printf("i:%d",i);
if(size > 8){
return -1;
}
//stack overflow
memcpy(buf,overflow,i);
return 0;
}

0x06 格式化字符串漏洞

审计点

格式化字符串函数:

函数 基本介绍
printf 输出到 stdout
fprintf 输出到指定 FILE 流
vprintf 根据参数列表格式化输出到 stdout
vfprintf 根据参数列表格式化输出到指定 FILE 流
sprintf 输出到字符串
snprintf 输出指定字节数到字符串
vsprintf 根据参数列表格式化输出到字符串
vsnprintf 根据参数列表格式化输出指定字节到字符串
setproctitle 设置 argv
syslog 输出日志
err, verr, warn, vwarn 等 。。。

工具

LazyIDA

0x07 命令注入

审计点

执行命令的几个函数:

1
2
3
system
fopen
popen

0x08 SQL注入

关键还是在于直接拼接SQL语句。

后续会推出SQL注入检查扫描插件。

Demo

示例中,mysql_real_query执行了外部读取的SQL语句,因stmt_str内容不可控,这将造成恶意用户可以执行任意SQL命令。

1
2
3
4
void test_mysql_real_query(MYSQL *mysql, const char *stmt_str, unsigned long length) {
scanf("%s", stmt_str);
mysql_real_query(mysql, stmt_str, length);
}

修复

为避免SQL注入缺陷,可以:

  1. 创建SQL语句时仅使用常量字符串;
  2. 创建安全库,将用作输入数据的参数化的SQL语句进行安全验证;
  3. 如果构造SQL指令时需要动态输入,应当针对动态输入的数据进行有效的验证。

0x09 条件竞争

TOCTOU条件竞争

TOCTOU(Time-of-check Time-of-use) 指的是程序在使用资源(变量,内存,文件)前会对进行检查,但是在程序使用对应的资源前,该资源却被修改了。

下面是最易出现TOCTOU条件竞争的几个场景。

CWE-365: Race Condition in Switch

当程序正在执行 switch 语句时,如果 switch 变量的值被改变,那么就可能造成不可预知的行为。尤其在 case 语句后不写 break 语句的代码,一旦 switch 变量发生改变,很有可能会改变程序原有的逻辑。

函数都会使用文件名作为参数:access(), open(), creat(), mkdir(), unlink(), rmdir(), chown(), symlink(), link(), rename(), chroot(),…

Signal Handler条件竞争

条件竞争经常会发生在信号处理程序中,这是因为信号处理程序支持异步操作。尤其是当信号处理程序是不可重入的或者状态敏感的时候,攻击者可能通过利用信号处理程序中的条件竞争,可能可以达到拒绝服务攻击和代码执行的效果。

malloc、free, setjmp、longjmp。

工具

静态检测

目前已知的静态检测工具有

  • Flawfinder
    • 目标:C/C++ 源码
    • 步骤
      • 建立漏洞数据库
      • 进行简单的文本模式匹配,没有任何的数据流或控制流分析
  • ThreadSanitizer
    • 目标:C++ 和 GO
    • 实现:LLVM
动态检测

0x0A 数组越界

越界访问

当程序访问一个数组中的元素时,如果索引值超出数组的长度,就会访问数组之外的内存。C和C++没有提供内置的防护措施来防止在任意内存中访问数据,也没有自动检测写入数组(这种语言内置的缓冲区类型)的数据是否在数组的边界以内。

一旦这种情况发生,将会造成程序内存结构破坏,导致安全隐患产生,攻击者可以利用这个缺陷执行任意代码片段。

Demo

例1,循环变量的上界是以数组a为char类型(1字节)数组时计算出来的数组大小,但是在循环内部数组a作为一个整型(4字节)数组被访问。因此,由于1字节的字符和4字节的整数在内存中的大小不同,导致大小为2的“整型”数组a可能使用下标2..7

1
2
3
4
5
6
void foo() {
char a[8]; // holds two 4-byte ints
for (int i = 0; i < sizeof(a); i++) {
((int *)a)[i] = i;
}
}

例2,函数foobar()获取一个值作为访问数组local_array的下标,而main函数使用实参15来调用函数foobar(),超过数组local_array的下标合法值0..7

1
2
3
4
5
6
7
8
void foobar(int x) {
int local_array[7];
local_array[x] = 0;
}
int main() {
foobar(15);
return 0;
}

修复

例1,循环的上界变为数组a作为一个整型数组时所包含的元素个数。

1
2
3
4
5
6
void foo() {
char a[8]; // holds two 4-byte ints
for (int i = 0; i < sizeof(a) / sizeof(int); i++) {
((int *)a)[i] = i;
}
}

例2,使用参数x作为局部数组local_array的下标时,在第5行验证x的值是否合法。在其他情况下,最好的修复方式就是在主调函数中验证值是否合法。

1
2
3
4
5
6
7
8
9
10
11
void foobar(int x) {
int local_array[7];
// verify the parameter is in range
if (x >= 0 && x < 7) {
local_array[x] = 0;
}
}
int main() {
foobar(15);
return 0;
}

字符串操作造成目的缓冲区缺少\0

在执行字符串拷贝的时候,如果源字符串大于目标字符串的大小,可能会导致目标字符串中存入的数据结尾不是\0。如果字符串不是以\0结尾,在后续访问该字符串时,会导致越界访问非法内存。

Demo

例1,foo的大小为10,而”1234567890”的大小也为10,所以执行strncpy之后foo的最后一个字符不是\0

1
2
3
4
5
#include <string.h>
void bad() {
char foo[10];
strncpy(foo, "1234567890", sizeof(foo));
}

例2,由于字符序列c_str中不带有null终结符,而作为参数传给函数printf()。

1
2
3
4
5
#include <stdio.h>
void func(void) {
char c_str[3] = "abc";
printf("%s\n", c_str);
}

例3,作为参数传给wcslen()函数的宽字节字符序列cur_msg可能并不具有null终结符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdlib.h>
#include <wchar.h>
wchar_t *cur_msg = NULL;
size_t cur_msg_size = 1024;
size_t cur_msg_len = 0;
void lessen_memory_usage(void) {
wchar_t *temp;
size_t temp_size;
/* ... */
if (cur_msg != NULL) {
temp_size = cur_msg_size / 2 + 1;
temp = realloc(cur_msg, temp_size * sizeof(wchar_t));
/* temp &and cur_msg may no longer be null-terminated */
if (temp == NULL) {
/* Handle error */
}
cur_msg = temp;
cur_msg_size = temp_size;
cur_msg_len = wcslen(cur_msg);
}
}

修复

审查代码逻辑,避免缓冲区存入数据超过容纳限制,或者增大缓冲区大小。

例1:

1
2
3
4
5
#include <string.h>
void good() {
char foo[11];
strncpy(foo, "1234567890", sizeof(foo));
}

例2,如果该边界被省略,编译器会为字符串以及字符串中的null终结符分配足够的内存空间:

1
2
3
4
5
#include <stdio.h>
void func(void) {
char c_str[] = "abc";
printf("%s\n", c_str);
}

例3,在调用函数wcslen()时,cur_msg会带有null终结符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdlib.h>
#include <wchar.h>
wchar_t *cur_msg = NULL;
size_t cur_msg_size = 1024;
size_t cur_msg_len = 0;
void lessen_memory_usage(void) {
wchar_t *temp;
size_t temp_size;
/* ... */
if (cur_msg != NULL) {
temp_size = cur_msg_size / 2 + 1;
temp = realloc(cur_msg, temp_size * sizeof(wchar_t));
/* temp and cur_msg may no longer be null-terminated */
if (temp == NULL) {
/* Handle error */
}
cur_msg = temp;
/* Properly null-terminate cur_msg */
cur_msg[temp_size - 1] = L'\0';
cur_msg_size = temp_size;
cur_msg_len = wcslen(cur_msg);
}
}

0x0B 迭代器相关问题

迭代器使用不匹配

将一个容器的迭代器用于另一个容器使用将会导致未定义行为。

Demo

在这个示例中,cont2.erase(i) 使用了指向cont1的迭代器,这将导致未定义行为发生。

1
2
3
4
5
void foo(set<int> &cont1, set<int> &cont2) {
set<int>::iterator i = cont1.find(100);
if (i != cont1.end())
cont2.erase(i);
}

在这个示例中,代码cont3.erase(i,j)执行的erase操作,其中i迭代器指向容器cont1,j迭代器指向容器cont2,而调用该方法的容器为cont3,这将导致未定义行为发生。

1
2
3
4
5
void foo(set<int> &cont1, set<int> &cont2, set<int> &cont3) {
set<int>::iterator i = cont1.find(100);
set<int>::iterator j = cont2.find(200);
cont3.assign(i, j);
}

修复

1
2
3
4
5
6
7
8
void foo(set<int> &cont1, set<int> &cont2) {
set<int>::iterator i = cont1.find(100);
if (i != cont1.end()) {
i = cont2.find(100);
if (i != cont2.end())
cont2.erase(i);
}
}

迭代器失效

当迭代器失效后,再次使用迭代器进行数据修改将会导致未定义行为。

Demo

示例代码中,函数试图从cont中删除所有的与“x”相等的元素,但是在调用cont.erase(i)后将会导致迭代器i失效,因此在i++时将会产生未定义行为。

1
2
3
4
5
6
7
void foo(list<int> &cont, int x) {
list<int>::iterator i;
for (i = cont.begin(); i != cont.end(); i++) {
if (*i == x)
cont.erase(i);
}
}

修复

这个修复用例将cont.erase(i)的返回值赋值给i,避免了迭代器i的失效。

1
2
3
4
5
6
7
8
9
void foo(list<int> &cont, int x) {
list<int>::iterator i;
for (i = cont.begin(); i != cont.end();) {
if (*i == x)
i = cont.erase(i);
else
i++;
}
}

对迭代器尾部进行解引用

对容器的迭代器进行解引用时,当迭代器指向容器的end()或者rend(),都将产生未定义行为。

Demo

例1,如果break语句没有得到执行,i将会等于cont.end()。这样对i进行解引用将会产生未定义行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <set>
using namespace std;
int foo(set<int> &cont) {
int x = 0;
set<int>::iterator i;
for (i = cont.begin(); i != cont.end(); i++) {
x += *i;
if (x > 100)
break;
}
x += *i;
return x;
}

例2,如果“cont”容器为空,i将会等于cont.end()。这样对i进行解引用将会产生未定义行为。

1
2
3
4
5
6
7
8
#include <set>
using namespace std;
int foo(set<int> &cont) {
set<int>::iterator i = cont.begin();
if (*i < 100)
return *i;
return 100;
}

修复

迭代器解引用前进行有效性验证,避免迭代器对尾部进行解引用。

例1,在这个解决方案中对i值进行判断,判断是否等于cont.end()。

1
2
3
4
5
6
7
8
9
10
11
12
int foo(set<int> &cont) {
int x = 0;
set<int>::iterator i;
for (i = cont.begin(); i != cont.end(); i++) {
x += *i;
if (x > 100)
break;
}
if (i != cont.end())
x += *i;
return x;
}

例2,在这个解决方案中对i在解引用前进行检测判断其值是否等于cont.end()。

1
2
3
4
5
6
int foo(set<int> &cont) {
set<int>::iterator i = cont.begin();
if ((i != cont.end()) && (*i < 100))
return *i;
return 100;
}

0x0C IO相关问题

使用无效句柄

当申请一个资源失败后,仍然继续使用该资源,就会导致程序崩溃。

Demo

示例中,如果socket()调用失败,再调用close()使用该句柄就会导致程序崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
#include <mqueue.h>
#include <stdio.h>
void bad() {
int sockfd;
struct sockaddr my_addr;
if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) != -1) {
;
} else {
;
}
close(sockfd);
}

修复

在资源申请时应当考虑异常情况的处理。

1
2
3
4
5
6
7
8
9
10
11
12
#include <mqueue.h>
#include <stdio.h>
void good() {
int sockfd;
struct sockaddr my_addr;
if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) != -1) {
;
} else {
return;
}
close(sockfd);
}

错误的资源关闭

程序员手动创建或申请的资源,通常应当进行相应的释放或关闭操作。选用的释放或关闭方法应当和创建或申请的方法相对应,否则,会造成程序运行未定义行为,甚至造成程序运行崩溃。

Demo

代码中通过库函数fopen()打开文件指针fp,然后试图通过调用Windows API对其进行关闭,该行为会造成程序运行未定义行为。

1
2
3
4
void f() {
FILE *fp = fopen("some_file.txt", "r");
CloseHandle((HANDLE)fp);
}

修复

调用和创建或申请资源相对应的释放或关闭方法。

1
2
3
4
void f() {
FILE *fp = fopen("some_file.txt", "r");
fclose(fp);
}

0x0D 加解锁相关问题

加锁未解锁

所有的针对互斥量的加锁解锁操作,都必须针对同一模块,并且在同一抽象层面进行。否则,将会可能导致某些加锁/解锁操作不会依照多线程设计而被执行,甚至依照锁的类型,最终导致死锁、资源竞争等其他漏洞的爆发。

缺少对锁的有效释放,会导致死锁。如果锁被占用并未被有效释放,后续对该加锁资源的操作将无法被执行,直到该锁得到有效释放。

为避免锁竞争,应当:

  1. 尽可能的缩小保存锁的代码;
  2. 不要对有可能产生并行问题(例如数据竞争)的模块进行加锁;
  3. 避免环路等待条件;
  4. 如果使用了多个锁,尤其是在一个递增的守护模式(guard pattern),一定要确保在各种情况下增量的等同。

Demo

示例中,函数foo()在最开始位置进行了加锁操作,但如果switch的case为0,该锁将无法被有效释放。

1
2
3
4
5
6
7
8
9
10
11
12
#include <pthread.h>
extern int z();
void foo(pthread_mutex_t *mutex) {
pthread_mutex_lock(mutex);
switch (z()) {
case 0:
return;
case 1:
break;
}
pthread_mutex_unlock(mutex);
}

修复

检查代码逻辑,详细排查每条代码可能的执行路径,避免加锁未释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <pthread.h>
extern int z();
void foo(pthread_mutex_t *mutex) {
pthread_mutex_lock(mutex);
switch (z()) {
case 0:
pthread_mutex_unlock(mutex);
return;
case 1:
break;
}
pthread_mutex_unlock(mutex);
}

不当的锁初始化

所有的针对互斥量的加锁解锁操作,都必须针对同一模块,并且在同一抽象层面进行。否则,将会可能导致某些加锁/解锁操作不会依照多线程设计而被执行,甚至依照锁的类型,最终导致死锁、资源竞争等其他漏洞的爆发。

在对资源进行加锁/解锁操作前,很多的库都需要对锁进行初始化操作,并在完成对锁资源进行加锁/解锁操作后,对锁进行清理。对锁资源不当的初始化,会造成程序运行逻辑错误等。

Demo

示例中,对已经初始化了的锁资源进行了冗余的初始化。

1
2
3
4
5
#include <pthread.h>
void foo(pthread_mutex_t *mutex) {
pthread_mutex_init(mutex);
pthread_mutex_init(mutex);
}

修复

删除冗余的锁资源初始化代码。

1
2
3
4
#include <pthread.h>
void foo(pthread_mutex_t *mutex) {
pthread_mutex_init(mutex);
}

0x0E 不安全的内存拷贝函数

有一些C/C++函数没有考虑安全性,例如内存拷贝函数memcpy。禁止使用这些不安全的函数是一种移除代码缺陷的非常好的方式。

Demo

1
2
3
void bad(void *dest, const void *src, size_t count) {
memcpy(dest, src, count);
}

修复

不要使用不安全的内存拷贝函数memcpy,应该使用一些安全的函数来代替这些被禁止使用的函数。

0x0F 其他污点数据问题

无论输入的数据是由用户直接输入,还是从环境中读取,都应当对该值的类型、长度、格式以及范围等进行验证。未经验证的值都应被视为污染数据。

如果输入的数据没有经过有效的验证,攻击者就可能会将该数据篡改为程序不期望的数据形式。接受了与预期不符的数据类型,会造成程序控制逻辑泄漏、资源泄漏甚至被植入可执行代码。

该种情况下,攻击者可以:

  1. 提供不合规的值造成程序运行崩溃;
  2. 造成过多的资源消耗;
  3. 读取机密数据;
  4. 通过恶意输入篡改数据或改变控制流;
  5. 执行有风险的命令。

污点数据作为循环边界

Demo

函数中,循环次数由用户直接输入,而未进行验证。该行为可被攻击者控制。

1
2
3
4
5
6
7
8
void iterateFoo_bad() {
unsigned num;
int i;
scanf("%u", &num);
for (i = 0; i < num; i++) {
foo();
}
}

修复

为避免污染输入错误,应当:

  1. 了解每个不可信源可能输入到程序的位置,包括:函数参数、cookies、网络数据读取、环境变量、逆向DNS、查询结果、文件名、数据库以及任何外部系统;
  2. 为输入数据提供一个白名单,或者“已知合理的”输入情况,而非仅仅依赖黑名单以及“输入不当”的输入情况;
  3. 确保输入数据的所有属性都合适,包括长度、类型、范围、输入丢失或额外输入、语法以及连贯性等;
  4. 如果应用的客户端存在安全验证,确保该验证在服务端同样存在;
  5. 如果输入数据由多处输入拼合而成,在完成拼合后,对该数据进行验证。

Demo中,添加了对污点数据的前置判断,避免了过多次数的循环。

1
2
3
4
5
6
7
8
9
10
void iterateFoo_good() {
unsigned num;
int i;
scanf("%u", &num);
if (num > 20)
return;
for (i = 0; i < num; i++) {
foo();
}
}

污染配置

直接将污点数据作为参数传递给对系统进行配置的库函数、API,会为恶意攻击者提供篡改操作系统的可能性,进而对操作系统造成破坏。

Demo

示例中,函数SetFileAttributes()对文件进行属性设置,其属性值是污点数据,该行为会导致文件的属性可被攻击者任意设置。

1
2
3
4
5
#include <Windows.h>
void f(LPCTSTR lpFileName, DWORD dwFileAttributes) {
scanf("%d", &dwFileAttributes);
SetFileAttributes(lpFileName, dwFileAttributes);
}

修复

示例中,通过if语句,对CASE情况进行判断,进而决定对文件属性值进行具体的设置,即用预先获知的可能的固定情况,代替了污点数据,保证了文件属性的安全性。

1
2
3
4
5
6
7
8
#include <Windows.h>
void f(LPCTSTR lpFileName) {
if ( CASE1 ) {
SetFileAttributes(lpFileName, dwFileAttributes_value_1 );
} else if ( CASE2 ) {
SetFileAttributes(lpFileName, dwFileAttributes_value_2 );
}
}

目录穿越

如题,不多说。

进程控制

直接将污点数据作为动态库加载路径,会为攻击者提供加载恶意库的机会,攻击者可以将篡改后的,带有恶意功能的库进行加载,使程序在运行过程中执行危险动作,甚至被攻击者控制。

Demo

示例中,函数LoadLibrary()加载库的参数为污点数据。

1
2
3
4
5
#include <Windows.h>
void f(LPCTSTR lpFileName) {
scanf("%s", lpFileName);
LoadLibrary(lpFileName);
}

修复

用明确的固定的数据来进行动态库加载,如果加载动态库的参数,确实需要从外界获取,在这种情况下,应该设计并实现完备的验证机制。

示例中,函数LoadLibrary()加载库的参数经过了校验函数CheckArgStr()的验证。

1
2
3
4
5
6
#include <Windows.h>
void f(LPCTSTR lpFileName) {
scanf("%s", lpFileName);
lpFileName = CheckArgStr(lpFileName);
LoadLibrary(lpFileName);
}

资源注入

在调用库函数、API进行资源操作的时候,如果相关的参数是污点数据,传入的资源可能会被污染,甚至篡改。

Demo

示例中,函数CreateEvent()创建事件对象的名称为污点数据。

1
2
3
4
5
6
#include <Windows.h>
void f(LPSECURITY_ATTRIBUTES lpEventAttributes, BOOL bManualReset,
BOOL bInitialState, LPTSTR lpName) {
scanf("%s", lpName);
CreateEvent(lpEventAttributes, bManualReset, bInitialState, lpName);
}

修复

示例中,函数CreateEvent()创建事件对象的名称经过了校验函数CheckArgStr()的验证。

1
2
3
4
5
6
7
#include <Windows.h>
void f(LPSECURITY_ATTRIBUTES lpEventAttributes, BOOL bManualReset,
BOOL bInitialState, LPTSTR lpName) {
scanf("%s", lpName);
lpName = CheckArgStr(lpName);
CreateEvent(lpEventAttributes, bManualReset, bInitialState, lpName);
}

日志伪造

将被污染的数据写入系统日志中,会导致系统日志记录的数据混乱。

Demo

示例中,syslog()将被污染的数据str记录到了系统日志中。

1
2
3
4
5
#include <syslog.h>
void f(int priority, const char *format, char *str) {
scanf("%s", str);
syslog(priority, format, str);
}

修复

确保写入日志的数据的正确性,如果确定要将用户输入的某些污点数据写入到日志文件中,应当确保该数据和其他数据加以区分并处理,以免用户输入的污点数据对日志记录造成影响。

0x10 错误的内存释放对象

释放的对象并非动态分配的内存,这种错误的释放操作会导致严重的错误。不要对不是由标准内存分配函数malloc(), calloc(), realloc(), 或 aligned_alloc()所返回的指针调用free()。

向realloc()提供一个指向非动态分配的指针也会产生类似的情况,realloc()函数用于改变一块动态内存的大小。如果向realloc()提供一个指向并非由标准内存分配函数分配的指针,程序行为未定义。结果导致程序异常终止。

Demo

例1,这个不规范的代码样例,根据argc的值,设置c_str引用动态申请内存或非静态的string字符串。无论哪种情况,c_str都被当作参数传递给了free()。如果任何的其他不同于动态分配内存被c_str引用,在调用free(c_str)的时候都将出现错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
enum { MAX_ALLOCATION = 1000 };
int main(int argc, const char *argv[]) {
char *c_str = NULL;
size_t len;
if (argc == 2) {
len = strlen(argv[1]) + 1;
if (len > MAX_ALLOCATION) {
/* Handle error */
}
c_str = (char *)malloc(len);
if (c_str == NULL) {
/* Handle error */
}
strcpy(c_str, argv[1]);
} else {
c_str = "usage: $>a.exe [string]";
printf("%s\n", c_str);
}
free(c_str);
return 0;
}

例2,realloc()代码样例中,指向realloc()函数返回值的指针buf,没有引用动态分配内存。

1
2
3
4
5
6
7
8
9
#include <stdlib.h>
enum { BUFSIZE = 256 };
void f(void) {
char buf[BUFSIZE];
char *p = (char *)realloc(buf, 2 * BUFSIZE);
if (p == NULL) {
/* Handle error */
}
}

修复

例1:代码样例消除了在调用free()函数之前,c_str引用非动态分配内存的可能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
enum { MAX_ALLOCATION = 1000 };
int main(int argc, const char *argv[]) {
char *c_str = NULL;
size_t len;
if (argc == 2) {
len = strlen(argv[1]) + 1;
if (len > MAX_ALLOCATION) {
/* Handle error */
}
c_str = (char *)malloc(len);
if (c_str == NULL) {
/* Handle error */
}
strcpy(c_str, argv[1]);
} else {
printf("%s\n", "usage: $>a.exe [string]");
return EXIT_FAILURE;
}
free(c_str);
return 0;
}

例2(realloc()):在这个规范的代码样例中,buf引用了动态分配内存。

1
2
3
4
5
6
7
8
9
#include <stdlib.h>
enum { BUFSIZE = 256 };
void f(void) {
char *buf = (char *)malloc(BUFSIZE * sizeof(char));
char *p = (char *)realloc(buf, 2 * BUFSIZE);
if (p == NULL) {
/* Handle error */
}
}

注意,realloc()即使在malloc()失败的情况下,依然会表现出正确的行为。因为realloc()在给予null指针的情况下与malloc()行为相同。

0x11 内存泄漏

函数内部分配内存且返回后没有在外部做任何变量赋值保存,这种情况会造成指向分配内存的指针丢失。

当一个类在构造函数中申请动态内存但是没有在析构函数中释放内存时,会导致内存泄漏。在赋值运算符中也会存在潜在的内存泄漏问题。当一个类在构造函数中执行动态内存分配并且在赋值运算符中重写相应的指针时,没有提前释放内存或者减少自身的引用计数,这会导致内存泄漏。

Demo

例1,alloc_data() 分配的内存没有变量保存返回的内存指针,导致内存泄漏。

1
2
3
4
5
6
void* alloc_data() {
return malloc(10);
}
void foo() {
alloc_data();
}

例2,类C在构造函数中申请内存,但是缺少析构函数。即使有除了析构函数之外的方法释放分配的内存,也有可能在使用这样的类时造成内存泄漏。

1
2
3
4
5
6
class C {
char *data;
public:
C() { data = new char[10]; }
//...
};

即使是一个没有使用过的对象的简单声明也会导致内存泄漏。

1
2
3
void foo(){
C c;
}

例3,ip指向的内存在被重写之前没有被释放。由于在构造函数中已经为这个指针分配了内存,这个赋值操作会导致可能的内存泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class C {
public:
C() { ip = new int; }
~C() { delete ip; }
C &operator=(const C &rhs) {
if (this == &rhs)
return *this;
ip = new int; // memory pointed by ip is leaked
*ip = *rhs.ip;
return *this;
}
private:
C(const C &);
int *ip;
};

例4,在内存重写之前,cp所指向的内存的引用计数没有减少会导致内存泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class counted {
public:
counted() { counter = 1; }
void addRef() { counter++; }
void decRef() {
counter--;
if (counter == 0)
delete this;
}
private:
int counter;
};

class C2 {
public:
C2() { cp = new counted(); }
~C2() { cp->decRef(); }
C2 &operator=(const C2 &rhs) {
if (this == &rhs)
return *this;
cp = rhs.cp;
cp->addRef();
return *this;
}
private:
C2(const C2 &);
counted *cp;
};

修复

例1:

1
2
3
4
5
6
7
void* alloc_data() {
return malloc(10);
}
void foo() {
void* ptr = alloc_data();
free(ptr);
}

例2,在析构函数中释放分配的内存:

1
2
3
4
5
6
7
class C {
char *data;
public:
C() { data = new char[10]; }
~C() { delete data; }
//...
};

例3,ip指向的动态分配的内存在赋值之前已经被释放了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class C {
public:
C() { ip = new int; }
~C() { delete ip; }
C &operator=(const C &rhs) {
if (this == &rhs)
return *this;
delete ip;
ip = new int; // memory pointed by ip is leaked
*ip = *rhs.ip;
return *this;
}
private:
C(const C &);
int *ip;
};

例4,增加了引用计数减少操作,保证了引用计数的正确计算,从而保证不会发生内存泄漏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class counted {
public:
counted() { counter = 1; }
void addRef() { counter++; }
void decRef() {
counter--;
if (counter == 0)
delete this;
}
private:
int counter;
};

class C2 {
public:
C2() { cp = new counted(); }
~C2() { cp->decRef(); }
C2 &operator=(const C2 &rhs) {
if (this == &rhs)
return *this;
cp->decRef();
cp = rhs.cp;
cp->addRef();
return *this;
}
private:
C2(const C2 &);
counted *cp;
};

0x12 对指针进行sizeof操作

使用sizeof对指针进行取值,很有可能出现对取值结果的错误理解。因为对指针进行sizeof操作将会返回指针的大小而非指针指向对象的大小。

如果要对指针进行取值,应当使用指针类型作为参数,如下代码:

1
sizeofint *)

因为用户显式的指明了指针类型,可以确保sizeof的意图是明确的。

有时也会出现对指针解引用的sizeof操作,如下代码:

1
2
int *p = a;
sizeof(*p);

上述代码中使用了对指针的解引用操作,可以表明编码者理解sizeof操作的结果输出,明确是对指针所指向对象进行sizeof操作。

Demo

代码中,sizeof(ps)操作,对指针进行操作,获取的结果并非指针指向的内存区域的长度,而是指针的长度。

1
2
3
4
5
6
7
#include <memory.h>
struct S {
int x, y;
};
void zero_S(struct S *ps) {
memset(ps, 0, sizeof(ps));
}

修复

使用sizeof(*ps)或者sizeof(struct S)都可以将示例修复。

1
2
3
4
5
6
7
8
#include <memory.h>
struct S {
int x, y;
};
void zero_S(struct S *ps) {
memset(ps, 0, sizeof(*ps));
memset(ps, 0, sizeof(struct S));
}

0x13 整数回绕造成的逻辑问题

因常量值超出变量取值范围造成的二元逻辑判断恒真或恒假,将导致程序逻辑判断失去意义。这种类型的问题可以通过改变变量的范围区间,使其包含常量值,从而避免二元逻辑判断恒真或恒假。

Demo

代码中,i定义为unsigned char,其取值范围在0~255之间,这将导致i<256恒成立,循环无法终止。

1
2
3
4
5
6
7
void foo() {
unsigned char i;
int a[256];
for(i=0;i<256;i++) {
a[i]=1;
}
}

修复

通过修改i变量类型为int,扩大其取值区间,保证循环可以顺利终止。

1
2
3
4
5
6
7
void foo() {
int i;
int a[256];
for(i=0;i<256;i++) {
a[i]=1;
}
}

0x14 参考

代码审计–40–新篇章之C/C++代码审计(一)