本文最后更新于:2024年7月25日 下午
编写本文章缘由 在pwn学习过程中,我们可以了解到,由于早期语言更希望使用者编写程序时自己去进行内存管理并写出安全的程序,导致如类c语言早期开发时由于开发者编写的不安全代码逻辑,从而一些人可以恶意利用编写的漏洞进行攻击并可能破坏被攻击者的设备。
由于大多数代码编写后编译或执行等功能都是由编译器的开发者进行调整,所以本篇文章我们会尽量避免对于汇编等更底层的内容进行研究,只会对实现的层面进行探讨,对于汇编等内容的研究是并无意义的。
在本篇文章中我们也会总结一些编写程序的正确方法或一些特殊语法,方便大家在开发的路上也能够写出令自己满意的且安全的程序。
一些输入函数 主要提及一些常见的函数,其在非正确使用时可能并不安全。
C/C++ 类Linux 1 2 3 4 ssize_t read (int fd, void *buf, size_t count) ;
不限平台 1 2 3 4 5 int scanf (const char *format, ...) ; std::cin std::basic_istream cin.getline (string str); char *fgets (char *s, int size, FILE *stream) ;gets (char *str);
此上函数无一例外出现了一个很严重的问题:当其对C语言中被常见或常提到的“字符串”,即char类型的数组进行输入的时候,如若不做限制,只要输入的字量足够大,就一定会超出数组的范围导致溢出。
Pascal read readln
Go fmt.Scanf()
不安全的情况的例 1 2 3 4 5 6 7 #include <stdio.h> int main () { char str[50 ]; gets(str); return 0 ; }
这是一段看起来非常简单的程序,大多数人可能都写过类似的内容。假设这段程序在编译时编译参数没有开启一些常见的保护(如PIE和Canary),我们就可以通过非常简单的栈溢出操作去获得程序所在环境的shell。这并不需要攻击者很多时间,因为这是一个对二进制漏洞学习者来说非常基础的内容。
某些输出函数 在此并不多说,因为大多数的语言已经规避了格式化字符串漏洞,如:Ruby、Python,在此仅提及类C语言中的printf函数。
printf(const char *format, …);
可能大家并没有想到这个是什么漏洞,我在此举一例:
1 2 3 4 5 6 7 8 9 #include <stdio.h> #include <unistd.h> int main () { char str[20 ]; read(0 , str, 20 ); printf (str); return 0 ; }
是的,如上面这段代码,如果存在此漏洞,理论上我们可以修改内存中任意一处可写的区域。可能有人在怀疑,怎么会有人这么写呢?而在早期开发中,由于printf函数的第一个参数format是char *类型的,所以部分开发者直接向format参数传字符串,而printf可以读,甚至可以用%n这个格式化字符对内存进行覆盖实现写操作,这样就导致了别有用心的开发者可以直接利用这一段内容进行攻击。
如何在C++中自己实现printf 在cstdarg头文件中定义了
T va_arg(std::va_list ap, T);
va_arg宏展开成对应来自std::va_list ap的下个参数的T类型表达式,其中ap为std::va_list类型的实例,T为ap中下一个参数的类型,于是,printf函数就可以写成:
1 2 3 4 5 6 7 8 inline int __cdecl _printf(char const * const Format, ...) { int Result; va_list ArgList; va_start (ArgList, Format); Result = vfprintf (stdout, Format, ArgList); va_end (ArgList); return Result; }
选自 mq白cpp BV1Ky4y1o76s
如何在C++中实现类似于Python中的print 1 2 3 4 5 6 template <typename ... Args>void print (const std::string_view fmt_str, Args&&... args) { auto fmt_args{ std::make_format_args (args...) }; std::string outstr{ std::vformat (fmt_str, fmt_args) }; fputs (outs.c_str (), stdout); }
选自 mq白cpp BV1gx4y1g7AJ
一些额外的、相对来说并不安全的函数 在string头文件中有一个replace函数,这个函数用法非常的多,共有6种。只需要知道这个函数可以替换字符串中的一段字串换为另一段字符串即可,即程序中只要存在类似于如下内容:
1 2 3 4 5 6 char str[20 ];fgets (str, 20 , stdin); string a, b; a = "a" ; b = "bb" ;replace ((string *)(&str), (string *)(&a), (string *)(&b));
这样的话,如果向str中输入20个字符,如果其中有”a”这个字串,就会被替换为”bb”,进而造成非直接的栈溢出。
结构体(类)相关 1 2 3 4 5 6 7 8 9 struct A { void f () { cout << "Hello\n" ; } };int main () { A *a = new A; a->f (); A *a2 = nullptr ; a2->f (); }
所以在C++中设计了析构函数来避免其发生:
1 2 3 4 5 6 class A {public : void f () { cout << "Hello\n" ; } ~A (); }; A::~A (void ) { cout << "del A\n" ; }
堆相关 指针free后不置空 包含了delete后和realloc(0)后不置空,如以下程序代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 #include <stdio.h> #include <stdlib.h> #include <malloc.h> struct name { char *myname; void (*func)(char *str); }void print (char *str) { printf ("%s\n" , str);}int main () { name *a; a = (name *)malloc (sizeof (name)); a->func = print; a->myname = "hello" ; a->func("printfunc" ); free (a); a->func("2nd time 2 use but free" ); a = NULL ; printf ("next line will not be printed\n" ); a->func("hello" ); return 0 ; }
一些开发上的小技巧 C/C++ 判断当前程序运行位数
sizeof void
sizeof long
以上两个都是在32位环境下为4,在64位环境下为8
有时为了避免由于整形的位数带来的问题,会使用stdint.h头文件中的内容来规范标准。
判断系统大小端 大小端分别指数据的高字节保存在地址的高地址和低地址中。
1 2 3 4 5 6 7 8 bool checkEndian () { union { unsigned int a; unsigned char b; }c; c.a = 1 ; return 1 == c.b; }
以上代码表示如果返回1就为使用小端法的CPU,返回false就为使用大端法的CPU。
ub的避免 ub,即Undefined Behavior,是一类对程序无任何限制的行为。理论上纵容这种代码的编写是危险的。我们要多去阅读官方提供的开发者文档以及支持的项目文档等等,多接触更新的、更安全的、更全面的现代语言标准,不断提高自己的代码审计能力,在平时的编写过程中就对ub零容忍,才能写出完美的代码。
为什么现在的开发者讨厌甚至不愿意去接触类C语言? 其中有一个很重要的原因就是很讨厌其中的隐式转换。
在早期的语言设计中,术语习惯非常混乱,部分早期编译器甚至直接将const——我们常认为是常量的定义方式而定义的变量,直接扔在有读写权限的bss段中,而不是现在逐渐标准化后放在readonly区。在类C语言的发展长河中,直到ISO C++的出现,才出现了对于name(名称)、denote(指称)以及entity(实体)的语法范畴的构造。
有人给我发过这样的例子:
1 2 3 4 5 6 7 8 9 #include <stdio.h> void test (int arr[10 ]) { printf ("size = %d\n" , sizeof arr); }int main () { int arr[10 ]; test(arr); return 0 ; }
然后由于在64位程序下输出8,就混乱了。但其实是在main函数中传了一个&arr,而test函数又以数组类型接收,但由于ub,打印时候arr会被识别为指针——是的没错,这里ub了。在main函数中arr处甚至可以声明为arr[5]、arr[20]等等,传递时可能存在风险,但编译器并没有制止这种行为。
跟我比较熟的人可能听过我说过:数组名不是指针,也不是什么地址。这其实是在开发者文档里是可以找得到的,在C++ Primer Plus上也有这样一段:
1 2 3 4 5 6 7 8 对数组取地址时,数组名也不会被解释为其地址。等等,数组名难道不被解释为数组的地址吗?不完全如此:数组名被解释为其第一个元素的地址,而对数组名应用地址运算符时,得到的是整个数组的地址: short tell[10]; cout << tell << endl; // displays &tell[0] cout << &tell << endl; // displays address of whole array 从数字上说,这两个地址相同;但从概念上说,&tell[0](即tell)是一个2字节内存块的地址,而&tell是一个20字节内存块的地址。因此,表达式tell+1将地址值加2,而表达式&tell+2将地址加20。换句话说,tell是一个short指针(*short),而&tell是一个这样的指针,即指向包含20个元素的short数组(short(*)[20])。 您可能会问,前面有关&tell的类型描述是如何来的呢?首先,您可以这样声明和初始化这种指针: short (*pas)[20] = &tell; 如果省略括号,优先级规则将使得pas先与[20]结合,导致pas是一个short指针数组,它包含20个元素,因此括号是必不可少的。其次,如果要描述变量的类型,可将声明中的变量名删除。因此,pas的类型为short (*)[20]。另外,由于pas被设置为&tell,因此*pas与tell等价,所以(*pas)[0]为tell数组的第一个元素。
由于在C++中规范了这一类行为,所以我们可以利用一些从C++11起步的特性去判断类型了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void f () {};int main () { int arr[10 ]{}; using T1 = decltype (arr); using T2 = decltype (+arr); using T3 = decltype (f); using T4 = decltype (+f); printf ("%d\n" , sizeof (+f)); std::cout << std::boolalpha; std::cout << std::is_same<int , int >::value << '\n' ; std::cout << std::is_same<int , void >::value << '\n' ; std::cout << std::is_same<int , int []>::value << '\n' ; std::cout << std::is_same<int [], int *>::value << '\n' ; }
由于is_same_v模板是在ISO C++17于GCC中声明的,而在C++14及至今版本,使用变量模板以及类偏特化仍可以编写一个is_same_v模板:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 template <class T , class T2 >struct is_same { static constexpr bool value = false ; }template <class T >struct is_same <T, T> { static constexpr bool value = true ; }template <class T , class T2 >constexpr bool is_same_v = is_same<T, T2>::value;struct Test {};int main () { std::cout << is_same<int , int >::value << std::endl; std::cout << is_same_v<double , double > << std::endl; std::cout << is_same_v<Test, double > << std::endl; }
还有更多复杂的内容:
例如不求值表达式:
1 2 3 4 std::cout << sizeof (std::cout<<'*' <<&std::cout<<std::endl)<<'\n' ; std::cout << sizeof std::cout<<'*' <<&std::cout<<std::endl;
sizeof会把后面跟着的表达式的类型大小计算出来,括号中的内容编译后用结果替代掉原表达式。
总结 就写这么多了,从二进制漏洞的做题中有感,选出了对开发可能用得上的内容,希望大家未来开发自己的应用程序或者写脚本等的时候能够注重各个语言的标准与规范,对于内存的管理能够更加安全且更加高效,对于语言中变量的类型能够有一定的心得与体会。我们虽然在网络空间安全方向上进行深入发展,但开发是我们的基本能力,我们并不应该摒弃开发能力,毕竟真要行情不好还完全可以靠开发吃饭(划掉),毕竟有开发能力才会有一定的代码审计能力,才会比别人更快地发现并修复漏洞。
部分内容改自mq白cpp的bilibili个人空间https://space.bilibili.com/1292761396/dynamic 部分内容选自C++ Primer Plus 部分内容选自cppreference