在Linux中使用C语言实现控制流保护(CFG)

安全 网站安全
最近版本的Windows有一个新的缓解措施叫做控制流保护(CFG)。接下来我们就讲讲如何在Linux中使用C语言实现控制流保护(CFG)。

在Linux中使用C语言实现控制流保护(CFG)

一、前言

最近版本的Windows有一个新的缓解措施叫做控制流保护(CFG)。在一个非直接调用之前——例如,函数指针和虚函数——针对有效调用地址的表检查目标地址。如果地址不是一个已知函数的入口,程序将会终止运行。

如果一个程序有一个缓冲区溢出漏洞,攻击者可以利用它覆盖一个函数地址,并且通过调用那个指针来控制程序执行流。这是ROP攻击的一种方法,攻击者构建一系列配件地址链,一个配件是一组包含ret指令的指令序列,这些指令都是原始程序中的,可以用来作为非直接调用的起点。执行过程会从一个配件跳到另一个配件中以便做攻击者想做的事,却不需要攻击这提供任何代码。

两种非常广的缓解ROP攻击的技术是地址空间布局随机化(ALSR)和栈保护。前者是随机化模块的加载基址以便达到不可预料的结果。在ROP攻击中的地址依赖实时内存布局,因此攻击者必须找到并利用信息泄漏来绕过ASLR。

关于栈保护,编译器在其他栈分配之上分配一个值,并设置为每个线程的随机值。如果过缓冲区溢出覆盖了函数返回地址,这个值将也被覆盖。在函数返回前,将校验这个值。如果不能与已知值匹配,程序将终止运行。

http://p5.qhimg.com/t0191f7827142776e18.png

CFG原理类似,在将控制传送到指针地址前做一个校验,只是不是校验一个值,而是校验目标地址本身。这个非常复杂,不像栈保护,需要平台协调。这个校验必须在所有的可靠的调用目标中被通知,不管是来自主程序还是动态库。

虽然没有广泛部署,但是值得一提的是Clang’s SafeStack。每个线程有两个栈:一个“安全栈”用来保存返回指针和其他可安全访问的值,另一个“非安全栈”保存buffer之类的数据。缓冲区溢出将破环其他缓冲区,但是不会覆盖返回地址,这样限制了破环的影响。

二、利用例子

使用一个小的C程序,demo.c:

  1. int 
  2.    main(void) 
  3.    { 
  4.        char name[8]; 
  5.        gets(name); 
  6.        printf("Hello, %s.\n", name); 
  7.        return 0; 
  8.    } 

它读取一个名字存到缓冲区中,并且以换行结尾打印出来。麻雀虽小五脏俱全。原生调用gets()不会校验缓冲区的边界,可以用来缓冲区溢出漏洞利用。很明显编译器和链接器都会抛出警告。

简单起见,假设程序包含危险函数。

  1. void     
  2.    self_destruct(void) 
  3.    { 
  4.        puts("**** GO BOOM! ****"); 
  5.    } 

攻击者用缓冲区溢出来调用这个危险函数。

为了使攻击简单,假设程序不使用ASLR(例如,在GCC/Clang中不使用-fpie和-pie编译选项)。首先,找到self_destruct()函数地址。

  1. $ readelf -a demo | grep self_destruct     
  2.     46: 00000000004005c5  10 FUNC  GLOBAL DEFAULT 13 self_destruct 

因为在64位系统上面,所以是64位的地址。Name缓冲区的大小事8字节,在汇编我看到一个额外的8字节分配上面,所以有16个字节填充,然后8字节覆盖self_destruct的返回指针。

  1. $ echo -ne 'xxxxxxxxyyyyyyyy\xc5\x05\x40\x00\x00\x00\x00\x00' > boom     
  2.     $ ./demo < boom 
  3.     Hello, xxxxxxxxyyyyyyyy?@. 
  4.     **** GO BOOM! **** 
  5.     Segmentation fault 

使用这个输入我已经成功利用了缓冲区溢出来控制了执行。当main试图回到libc时,它将会跳转到威胁代码,然后崩溃。打开堆栈保护可以阻止这种利用。

  1. $ gcc -Os -fstack-protector -o demo demo.c     
  2.    $ ./demo < boom 
  3.    Hello, xxxxxxxxaaaaaaaa?@. 
  4.    *** stack smashing detected ***: ./demo terminated 
  5.    ======= Backtrace: ========= 
  6.    ... lots of backtrace stuff ... 

栈保护成功阻止了利用。为了绕过过这个,我将不得不猜canary值或者发现可以利用的信息泄漏。

栈保护转化为程序看起来就是如下这样:

  1. int     
  2.    main(void) 
  3.    { 
  4.        long __canary = __get_thread_canary(); 
  5.        char name[8]; 
  6.        gets(name); 
  7.        printf("Hello, %s.\n", name); 
  8.        if (__canary != __get_thread_canary()) 
  9.            abort(); 
  10.        return 0; 
  11.    } 

然而,实际上不可能在C中实现堆栈保护,缓冲区溢出是不确定行为,并且canary仅对缓冲区溢出有效,还允许编译器优化它。

三、函数指针和虚函数

在攻击者成功上述利用后,上层管理加入了密码保护措施。看起来如下:

  1. void     
  2.     self_destruct(char *password) 
  3.     { 
  4.         if (strcmp(password, "12345") == 0) 
  5.             puts("**** GO BOOM! ****"); 
  6.     } 

这个密码是硬编码的,它是比较愚蠢,但是假设它不为攻击者所知。上层管理已经要求堆栈保护,因此假设已经开启。

另外,程序也做一点改变,现在用一个函数指针实现多态。

  1. struct greeter {     
  2.         char name[8]; 
  3.         void (*greet)(struct greeter *); 
  4.     }; 
  5.       
  6.     void 
  7.     greet_hello(struct greeter *g) 
  8.     { 
  9.         printf("Hello, %s.\n", g->name); 
  10.     } 
  11.       
  12.     void 
  13.     greet_aloha(struct greeter *g) 
  14.     { 
  15.         printf("Aloha, %s.\n", g->name); 
  16.     } 

现在有一个greeter对象和函数指针来实现运行时多态。把他想想为手写的C的虚函数。下面是新的main函数:

  1. int     
  2.     main(void) 
  3.     { 
  4.         struct greeter greeter = {.greet = greet_hello}; 
  5.         gets(greeter.name); 
  6.         greeter.greet(&greeter); 
  7.         return 0; 
  8.     } 

(在真实的程序中,其他东西会提供greeter并挑选它自己的函数指针)

而不是覆盖返回指针,攻击者有机会覆盖结构中的函数指针。让我们重新像之前一样利用。

  1. $ readelf -a demo | grep self_destruct     
  2.     54: 00000000004006a5  10 FUNC  GLOBAL DEFAULT  13 self_destruct 

我们不知道密码,但是我们确实知道密码校验是16字节。攻击应该跳过16字节,即跳过校验(0x4006a5+16=0x4006b5)。

  1. $ echo -ne 'xxxxxxxx\xb5\x06\x40\x00\x00\x00\x00\x00' > boom     
  2.    $ ./demo < boom 
  3.    **** GO BOOM! **** 

不管堆栈保护还是密码保护都么有帮助。堆栈保护仅仅保护返回指针,而不保护结构中的函数指针。

这就是CFG起作用的地方。开启了CFG,编译器会在调用greet()之前插入一个校验。它必须指向一个已知函数的开头,否则将想堆栈保护一样终止程序运行。因为self_destruct()不是函数的开头,但是利用后程序还是会终止。

然而,linux还没有CFG机制。因此我打算自己实现它。

四、函数地址表

正如文中顶端PDF链接中描述的,Windows上面的CFG使用bitmap实现。每个位代表8字节内存。如果过8字节包含了函数开头,这个位设置为1。校验一个指针意味着校验在bitmap中它关联的位。

关于我的CFG,我决定保持相同的8字节解决方案:目标地址的低3位将舍弃。其余24位用来作为bitmap的索引。所有指针中的其他位被忽略。24位的索引意味着bitmap最大只能是2MB。

24位对于32位系统已经足够了,但是在64位系统上面是不够的:一些地址不能代表函数的开头,但是设置他们的位为1.这是可以接受的,尤其是只有已知函数作为非直接调用的目标,降低了不利因素。

注意:根据指针转化为整数的位是未指定的且不可移植,但是这个实现不管在哪里都能工作良好。

下面是CFG的参数。我将他们封装为宏以便编译是方便。这个cfg_bits是支持bitmap数组的整数类型。CFG_RESOLUTION是舍弃的位数,一次“3”是8字节的一个粒度。

  1. typedef unsigned long cfg_bits;     
  2.     #define CFG_RESOLUTION  3 
  3.     #define CFG_BITS        24 

给一个函数指针f,下面的宏导出bitmap的索引。

  1. #define CFG_INDEX(f) \     
  2.       (((uintptr_t)f >> CFG_RESOLUTION) & ((1UL << CFG_BITS) - 1)) 

CFG bitmap只是一个整形数组。初始值为0。

  1. struct cfg {     
  2.         cfg_bits bitmap[(1UL << CFG_BITS) / (sizeof(cfg_bits) * CHAR_BIT)]; 
  3.     }; 

使用cfg_register()在bitmap中手动注册函数。

  1. void     
  2.    cfg_register(struct cfg *cfg, void *f) 
  3.    { 
  4.        unsigned long i = CFG_INDEX(f); 
  5.        size_t z = sizeof(cfg_bits) * CHAR_BIT; 
  6.        cfg->bitmap[i / z] |= 1UL << (i % z); 
  7.    } 

因为在运行时注册函数,需要与ASLR一致。如果ASLR开启了,bitmap每次运行都会不同。将bitmap的每个元素与一个随机数异或是值得的,加大攻击者的难度。在完成注册后,bitmap也需要调整为只读权限(mprotect())。

最后,校验函数被用于非直接调用之前。它确保了f先被传递给cfg_register()。因为它调用频繁,所以需要尽量快和简单。

  1. void     
  2.    cfg_check(struct cfg *cfg, void *f) 
  3.    { 
  4.        unsigned long i = CFG_INDEX(f); 
  5.        size_t z = sizeof(cfg_bits) * CHAR_BIT; 
  6.        if (!((cfg->bitmap[i / z] >> (i % z)) & 1)) 
  7.            abort(); 
  8.    } 

完成了,现在在main中使用它:

  1. struct cfg cfg;     
  2.     
  3.   int 
  4.   main(void) 
  5.   { 
  6.       cfg_register(&cfg, self_destruct);  // to prove this works 
  7.       cfg_register(&cfg, greet_hello); 
  8.       cfg_register(&cfg, greet_aloha); 
  9.     
  10.       struct greeter greeter = {.greet = greet_hello}; 
  11.       gets(greeter.name); 
  12.       cfg_check(&cfg, greeter.greet); 
  13.       greeter.greet(&greeter); 
  14.       return 0; 
  15.   } 

现在再次利用:

  1. $ ./demo < boom     
  2.     Aborted 

正常情况下self_destruct()不会被注册,因为它不是一个非直接调用的合法目标,但是利用依然不能起作用是因为它在self_destruct()中间被调用,在bitmap中它不是一个可靠的地址。校验将在利用前终止程序。

在真实的应用程序中,我将使用一个全局的CFG bitmap,在头文件中使用inline函数定义cfg_check()。

尽管不使用工具直接在C中实现是可能的,但是这将变得更加繁琐和意出错。正确的是该在编译器中实现CFG。

责任编辑:赵宁宁 来源: 安全客
相关推荐

2015-09-28 14:12:36

2014-05-26 09:50:19

访问控制列表ACL文件保护

2014-08-01 15:16:05

SwiftC语言

2013-02-21 17:02:00

C语言

2010-02-05 15:59:26

C++函数重载

2011-03-04 10:04:31

Linux文件操作命令

2020-08-12 08:56:30

代码凯撒密码函数

2023-12-07 12:59:46

C语言循环队列代码

2023-07-23 19:26:18

Linuxcat 命令

2023-07-04 16:36:03

Linuxcd 命令

2011-05-25 13:22:05

PHPJSON

2021-01-19 05:30:55

C# 8异步流IEnumerable

2013-05-14 10:13:06

WindowsLinux操作系统

2023-08-12 15:05:26

Linuxcp 命令

2023-07-04 15:11:30

TypeScript类型保护

2022-08-30 20:00:37

零信任Linkerd

2023-03-02 19:36:34

C语言

2010-06-02 09:14:53

GCC编译器Linux

2022-04-18 10:01:07

Go 语言汉诺塔游戏

2020-07-24 09:40:04

C语言OOP代码
点赞
收藏

51CTO技术栈公众号