OpenHarmony C&C++ 安全编程指南

打印 上一主题 下一主题

主题 806|帖子 806|积分 2418

函数

对所有外部数据进行合法性校验

【形貌】 外部数据的来源包罗但不限于:网络、用户输入、命令行、文件(包罗程序的配置文件)、环境变量、用户态数据(对于内核程序)、进程间通讯(包罗管道、消息、共享内存、socket、RPC等,特别必要留意的是装备内部不同单板间通讯也属于进程间通讯)、API参数、全局变量。
来自程序外部的数据通常被认为是不可信的,在使用这些数据之前,必要进行合法性校验。 假如不对这些外部数据进行校验,将可能导致不可预期的安全风险。
留意:不要使用断言查抄外部输入数据,断言应该用于防止不准确的程序假设,而不能用在发布版本上查抄程序运行过程中发生的错误。
对来自程序外部的数据要校验处理后才能使用。典范场景包罗:
作为数组索引 将不可信的数据作为数组索引,可能导致超出数组上限,从而造成非法内存访问。 作为内存偏移地点 将不可信数据作为指针偏移访问内存,可能造成非法内存访问,并可以造成进一步的危害,如任意地点读/写。 作为内存分配的尺寸参数 使用0长度分配内存可能造成非法内存访问;未限定分配内存巨细会造成过度资源斲丧。 作为循环条件 将不可信数据作为循环限定条件,可能会引发缓冲区溢出、内存越界读/写、死循环等题目。 作为除数 可能产生除零错误(被零除)。 作为命令行参数 可能产生命令注入毛病。 作为数据库查询语句的参数 可能产生SQL注入毛病。 作为输入/输出格式化字符串 可能产生格式化字符串毛病。 作为内存复制长度 可能造成缓冲区溢出题目。 作为文件路径 直接打开不可信路径,可能会导致目录遍历攻击,攻击者利用了无权利用的文件,使得系统被攻击者所控制。
输入校验包罗但不局限于:


  • API接口参数合法性
  • 校验数据长度
  • 校验数据范围
  • 校验数据范例和格式
  • 校验输入只包含可担当的字符(“白名单”形式),尤其必要留意一些特殊情况下的特殊字符。
外部数据校验原则
1.信任界限
由于外部数据不可信,因此系统在运行过程中,假如数据传输与处理跨越不同的信任界限,为了防止攻击伸张,必须对来自信任界限外的其他模块的数据进行合法性校验。
(a)so(或者dll)之间
so或dll作为独立的第三方模块,用于对外导出公共的api函数,供其他模块进行函数调用。so/dll无法确定上层调用者是否传递了合法参数,因此so/dll的公共函数必要查抄调用者提供参数的合法性。so/dll应该设计成低耦合、高复用性,只管有些软件的so/dll当前设计成只在本软件中使用,但仍然应该将不同的so/dll模块视为不同的信任界限。
(b)进程与进程之间
为防止通过高权限进程提权,进程与进程之间的IPC通讯(包罗单板之间的IPC通讯、不同主机间的网络通讯),应视为不同信任界限。
(c)应用层进程与利用系统内核
利用系统内核具有比应用层更高的权限,内核向应用层提供的接口,应该未来自应用层的数据作为不可信数据处理。
(d)可信执行环境表里环境
为防止攻击伸张至可信执行环境,TEE、SGX等对外提供的接口,应该未来自外部的数据作为不可信数据处理。
2.外部数据校验 外部数据进入到本模块后,必须颠末合法性校验才能使用。被校验后的合法数据,在本模块内,后续传递到内部其他子函数,不必要重复校验。
【反例】 函数Foo处理外部数据,由于buffer不一定是’\0’结尾, strlen 的返回值 nameLen 有可能高出 len,导致越界读取数据。
  1. void Foo(const unsigned char* buffer, size_t len)
  2. {
  3.     // buffer可能为空指针,不保证以'\0'结尾
  4.     const char* s = reinterpret_cast<const char*>(buffer);
  5.     size_t nameLen = strlen(s);
  6.     std::string name(s, nameLen);
  7.     Foo2(name);
  8.     ...
  9. }
复制代码
【正例】 对外部参数做合法性校验,本例中使用 strnlen 进行字符串长度盘算,缓解读越界风险。
  1. void Foo(const unsigned char* buffer, size_t len)
  2. {
  3.     // 必须做参数合法性校验
  4.     if (buffer == nullptr||len == 0||len >= MAX_BUFFER_LEN) {
  5.         ... // 错误处理
  6.     }
  7.     const char* s = reinterpret_cast<const char*>(buffer);
  8.     size_t nameLen = strnlen(s, len); // 使用strnlen缓解读越界风险
  9.     if (nameLen == len) {
  10.         ... // 错误处理
  11.     }
  12.     std::string name(s, nameLen);
  13.     ...
  14.     Foo2(name);
  15.     ...
  16. }
复制代码
  1. namespace ModuleA {
  2. // Foo2 为模块内部函数,约定为由调用者保证参数的合法性
  3. static void Foo2(const std::string& name)
  4. {
  5.     ...
  6.     Bar(name.c_str()); // 调用MODULE_B中的函数
  7. }
  8. // Foo 为模块的外部接口,需要校验参数的合法性
  9. void Foo(const unsigned char* buffer, size_t len)
  10. {
  11.     // 检查空指针、参数合法范围等
  12.     if (buffer == nullptr||len <= sizeof(int)) {
  13.         // 错误处理
  14.         ...
  15.     }
  16.     int nameLen = *(reinterpret_cast<const int*>(buffer)); // 从报文中获取name字符串长度
  17.     // nameLen 是不可信数据,必须检查合法性
  18.     if (nameLen <= 0||static_cast<size_t>(nameLen) > len - sizeof(int)) {
  19.         // 错误处理
  20.         ...
  21.     }
  22.     std::string name(reinterpret_cast<const char*>(buffer), nameLen);
  23.     Foo2(name); // 调用本模块内内部函数
  24.     ...
  25. }
  26. }
复制代码
以下是使用C语言编写的MODULE_B模块中的代码:
  1. // Bar 为 MODULE_B 模块的公共函数,
  2. // 其约定为,如果参数name不为nullptr,那么必须是一个具有’\0’结尾的合法字符串并且长度大于0
  3. void Bar(const char* name)
  4. {
  5.     // 必须做参数合法性校验
  6.     if (name == nullptr||name[0] == '\0') {
  7.         // 错误处理
  8.         ...
  9.     }
  10.     size_t nameLen = strlen(name);  // 不需要使用strnlen
  11.     ...
  12. }
复制代码
对于模块A来说, buffer 是外部不可信输入,必须做严格的校验,从 buffer 解析出来的 name,在解析过程中进行了合法性校验,在模块A内部属于合法数据,作为参数传递给内部子函数时不必要再做合法性校验(假如要继续对 name 内容进行解析,那么仍然必须对 name 内容进行校验)。 假如模块A中的 name 继续跨越信任面传递给其他模块(在本例中是直接调用模块B的公共函数,也可以是通过文件、管道、网络等方式),那么对于B模块来说, name 属于不可信数据,必须做合法性校验。


类的成员变量必须显式初始化

【形貌】 假如没有对类成员变量显示初始化,会使对象处于一种不确定状态。假如类的成员变量具有默认构造函数,那么可以不必要显式初始化。
【反例】
  1. class Message {
  2. public:
  3.     void Process()
  4.     {
  5.         ...
  6.     }
  7. private:
  8.     uint32_t msgId;                    // 不符合:成员变量没有被初始化
  9.     size_t msgLength;                  // 不符合:成员变量没有被初始化
  10.     unsigned char* msgBuffer;          // 不符合:成员变量没有被初始化
  11.     std::string someIdentifier;        // 默认构造函数仅会初始化该成员
  12. };
  13. Message message;                       // message成员变量没有被完全初始化
  14. message.Process();                     // 后续使用存在隐患
复制代码
【正例】 一种做法是在类成员变量声明时显示初始化。
  1. class Message {
  2. public:
  3.     void Process()
  4.     {
  5.         ...
  6.     }
  7. private:
  8.     uint32_t msgId{0};
  9.     size_t msgLength{0};
  10.     unsigned char* msgBuffer{nullptr};
  11.     std::string someIdentifier;        // 具有默认构造函数,不需要显式初始化
  12. };
复制代码
另一种做法是使用构造函数初始化列表初始化。
  1. class Message {
  2. public:
  3.     Message() : msgId(0), msgLength(0), msgBuffer(nullptr) {}
  4.     void Process()
  5.     {
  6.         ...
  7.     }
  8. private:
  9.     uint32_t msgId;
  10.     size_t msgLength;
  11.     unsigned char* msgBuffer;
  12.     std::string someIdentifier;        // 具有默认构造函数,不需要显式初始化
  13. };
复制代码
明白必要实现哪些特殊成员函数

【形貌】 三之法则(Rule of three): 若某个类必要用户定义的析构函数、用户定义的拷贝构造函或拷贝赋值利用符,则它根本三者全部都必要。
  1. class Foo {
  2. public:
  3.     Foo(const char* buffer, size_t size) { Init(buffer, size); }
  4.     Foo(const Foo& other) { Init(other.buf, other.size); }
  5.     Foo& operator=(const Foo& other)
  6.     {
  7.         Foo tmp(other);
  8.         Swap(tmp);
  9.         return *this;
  10.     }
  11.     ~Foo() { delete[] buf; }
  12.     void Swap(Foo& other) noexcept
  13.     {
  14.         using std::swap;
  15.         swap(buf, other.buf);
  16.         swap(size, other.size);
  17.     }
  18. private:
  19.     void Init(const char* buffer, size_t size)
  20.     {
  21.         this->buf = new char[size];
  22.         memcpy(this->buf, buffer, size);
  23.         this->size = size;
  24.     }
  25.     char* buf;
  26.     size_t size;
  27. };
复制代码
假如类对某种资源进行管理,而资源句柄是非类范例的对象(裸指针、文件形貌符等),则这些隐式定义的成员函数通常都不准确,其析构函数不做任何事,而拷贝构造函数/拷贝赋值利用符则进行“浅拷贝”。
通过可复制句柄来管理不可复制资源的类,可能必须将其拷贝赋值和拷贝构造函数声明为私有的并且不提供其定义,或将它们定义为delete的。
五之法则(Rule of five): 假如定义了析构函数、拷贝构造函数或拷贝赋值利用符,会阻止移动构造函数和移动赋值利用符的隐式定义,以是任何想要移动语义的类必须声明全部五个特殊成员函数。
  1. class Foo {
  2. public:
  3.     Foo(const char* buffer, size_t size) { Init(buffer, size); }
  4.     Foo(const Foo& other) { Init(other.buf, other.size); }
  5.     Foo& operator=(const Foo& other)
  6.     {
  7.         Foo tmp(other);
  8.         Swap(tmp);
  9.         return *this;
  10.     }
  11.     Foo(Foo&& other) noexcept : buf(std::move(other.buf)), size(std::move(other.size))
  12.     {
  13.         other.buf = nullptr;
  14.         other.size = 0;
  15.     }
  16.     Foo& operator=(Foo&& other) noexcept
  17.     {
  18.         Foo tmp(std::move(other));
  19.         Swap(tmp);
  20.         return *this;
  21.     }
  22.     ~Foo() { delete[] buf; }
  23.     void Swap(Foo& other) noexcept
  24.     {
  25.         using std::swap;
  26.         swap(buf, other.buf);
  27.         swap(size, other.size);
  28.     }
  29. private:
  30.     void Init(const char* buffer, size_t size)
  31.     {
  32.         this->buf = new char[size];
  33.         memcpy(this->buf, buffer, size);
  34.         this->size = size;
  35.     }
  36.     char* buf;
  37.     size_t size;
  38. };
复制代码
但是假如不提供移动构造函数和移动赋值利用符通常不会发生错误,但会导致失去优化机会。
零之法则(Rule of zero): 假如类不必要专门处理资源的所有权,那么就不应该有自定义的析构函数、拷贝/移动构造函数或拷贝/移动赋值利用符。
  1. class Foo {
  2. public:
  3.     Foo(const std::string& text) : text(text) {}
  4. private:
  5.     std::string text;
  6. };
复制代码
只要声明了拷贝构造函数、拷贝赋值利用符或析构函数,编译器将不会隐式生成移动构造函数和移动赋值利用符,导致该类的移动利用都变成了代价更高的复制利用。 只要声明了移动构造函数或移动赋值利用符,编译器会将隐式生成的拷贝构造函数或拷贝赋值利用符定义为delete的,导致改类只能被移动、不能被复制。 因此,只要声明了此中的任何一个函数,就应当声明其他全部函数,避免出现非预期的结果。
雷同地,假如基类必要定义public的虚析构函数,那么必要显示定义全部相关的特殊成员函数:
  1. class Base {
  2. public:
  3.     ...
  4.     Base(const Base&) = default;
  5.     Base& operator=(const Base&) = default;
  6.     Base(Base&&) = default;
  7.     Base& operator=(Base&&) = default;
  8.     virtual ~Base() = default;
  9.     ...
  10. };
复制代码
但是,假如基类声明了拷贝构造/拷贝赋值利用符,可能会发生切片,以是常常会将基类中的拷贝构造/拷贝赋值利用符显式定义为delete, 并且同时将其他的特殊成员函数也显式定义为delete:
  1. class Base {
  2. public:
  3.     ...
  4.     Base(const Base&) = delete;
  5.     Base& operator=(const Base&) = delete;
  6.     Base(Base&&) = delete;
  7.     Base& operator=(Base&&) = delete;
  8.     virtual ~Base() = default;
  9.     ...
  10. };
复制代码
基类中的拷贝构造函数、拷贝赋值利用符、移动构造函数、移动赋值利用符必须为非public函数或者为delete函数

【形貌】 假如把一个派生类对象直接赋值给基类对象,会发生切片,只拷贝或者移动了基类部分,侵害了多态行为。
【反例】 如下代码中,基类的拷贝构造函数和拷贝赋值利用符为default,假如派生类对象赋值给基类对象时会发生切片。 可以将此例中的拷贝构造函数和拷贝赋值利用符声明为delete,编译器可查抄出此类赋值行为。
  1. class Base {
  2. public:
  3.     Base() = default;
  4.     Base(const Base&) = default;
  5.     Base& operator=(const Base&) = default;
  6.     ...
  7.     virtual void Fun() { std::cout << "Base" << std::endl; }
  8. };
  9. class Derived : public Base {
  10.     ...
  11.     void Fun() override { std::cout << "Derived" << std::endl; }
  12. };
  13. void Foo(const Base& base)
  14. {
  15.     Base other = base;    // 不符合:发生切片
  16.     other.Fun();          // 调用的是Base类的Fun函数
  17. }
  18. Derived d;
  19. Foo(d);
复制代码
在移动构造函数和移动赋值利用符中必须将源对象的资源准确重置

【形貌】 移动构造函数和移动赋值利用符将资源的所有权从一个对象移动到另外一个资源。一旦资源被移动,则应将源对象的资源准确重置。这样可以防止源对象在析构函数中释放了被移动的资源。
在被移动的对象中允许保存部分非资源相关数据,但必须保证被移动的对象处于可被正常析构的状态。 因此,当一个对象被move以后,除非该对象处于明白指定的状态,否则不要依赖已move对象的值,否则可能产生非预期行为。
【反例】
  1. class Foo {
  2. public:
  3.     ...
  4.     Foo(Foo&& foo) noexcept : data(foo.data)
  5.     {
  6.     }
  7.     Foo& operator=(Foo&& foo)
  8.     {
  9.         data = foo.data;
  10.         return *this;
  11.     }
  12.     ~Foo()
  13.     {
  14.         delete[] data;
  15.     }
  16. private:
  17.     char* data = nullptr;
  18. };
复制代码
上述Foo的移动构造函数和移动赋值利用符没有准确将源对象的资源重置,源对象析构的时候会将资源释放,导致新创建的对象中担当的资源成为无效资源。
【正例】
  1. class Foo {
  2. public:
  3.     ...
  4.     Foo(Foo&& foo) noexcept : data(foo.data)
  5.     {
  6.         foo.data = nullptr;
  7.     }
  8.     Foo& operator=(Foo&& foo)
  9.     {
  10.         if (this == &foo) {
  11.             return *this;
  12.         }
  13.         delete[] data;
  14.         data = foo.data;
  15.         foo.data = nullptr;
  16.         return *this;
  17.     }
  18.     ~Foo()
  19.     {
  20.         delete[] data;
  21.     }
  22. private:
  23.     char* data = nullptr;
  24. };
复制代码
此外,不要依赖已经被move对象的值。 某些标准库std::string的实现可能对短字节做优化,在实现移动语义时可能不会修改被移动字符串的内容,导致如下代码输出不一定是预期的b, 有可能输出为ab,存在兼容性题目。
  1. std::string str{"a"};
  2. std::string other = std::move(str);
  3. str.append(1, 'b');
  4. std::cout << str << std::endl;
复制代码
通过基类指针释放派生类时,必须将基类中析构函数声明为虚函数

【形貌】 只有基类析构函数是虚函数时,才能保证通过多态调用的时候调用到派生类的析构函数。 假如没有将基类的析构函数声明为虚函数,当通过基类指针释放派生类时,只会调用基类的析构函数,不会调用派生类的析构函数,导致内存泄漏。
【反例】 没有将基类的析构函数声明为虚函数,导致了内存泄漏。
  1. class Base {
  2. public:
  3.     Base() = default;
  4.     ~Base() { std::cout << "~Base" << std::endl; }
  5.     virtual std::string GetVersion() = 0;
  6. };
  7. class Derived : public Base {
  8. public:
  9.     Derived()
  10.     {
  11.         const size_t numberCount = 100;
  12.         numbers = new int[numberCount];
  13.     }
  14.     ~Derived()
  15.     {
  16.         delete[] numbers;
  17.         std::cout << "~Derived" << std::endl;
  18.     }
  19.     std::string GetVersion()
  20.     {
  21.         return std::string("hello!");
  22.     }
  23. private:
  24.     int* numbers;
  25. };
  26. void Foo()
  27. {
  28.     Base* base = new Derived();
  29.     delete base;                // 调用的是 Base 的析构函数,造成资源泄漏
  30. }
复制代码
对象赋值或初始化避免切片利用

【形貌】
将派生类对象按值赋值给基类对象时会发生切片,侵害了多态行为。
假如确实必要将对象切片处理,建议定义一个显式利用完成这个功能,以避免理解错误,增加可维护性。
【反例】
  1. class Base {
  2.      virtual void Fun();
  3. };
  4. class Derived : public Base {
  5.     ...
  6. };
  7. void Foo(const Base& base)
  8. {
  9.     Base other = base;        // 不符合:发生切片
  10.     other.Fun();              // 调用的是Base类的Fun函数
  11. }
  12. Derived d;
  13. Base b{d};                    // 不符合:仅构造了Base部分
  14. b = d;                        // 不符合:仅赋值Base部分
  15. Foo(d);
复制代码
表达式与语句

确保对象在使用之前已被初始化

【形貌】 本条款中的“初始化”指的是通过定义时显示初始化、默认构造初始化、赋值等方式使对象拥有期望的值。 读取一个未初始化的值时,程序可能产生未定义行为,因此必要确保对象在使用之前已被初始化。
【反例】
  1. void Bar(int data);
  2. ...
  3. void Foo()
  4. {
  5.     int data;
  6.     Bar(data); // 不符合:未初始化就使用  
  7.     ...
  8. }
复制代码
假如有不同分支,要确保所有分支都得到初始化后才能使用。
  1. void Bar(int data);
  2. ...
  3. void Foo(int condition)
  4. {
  5.     int data;
  6.     if (condition > 0) {
  7.         data = CUSTOMIZED_SIZE;
  8.     }
  9.     Bar(data);      // 不符合:部分分支该值未初始化  
  10.     ...
  11. }
复制代码
【正例】
  1. void Bar(int data);
  2. ...
  3. void Foo()
  4. {
  5.     int data{0};    // 符合:显示初始化
  6.     Bar(data);
  7.     ...
  8. }
  9. void InitData(int& data);
  10. ...
  11. void Foo()
  12. {
  13.     int data;
  14.     InitData(data); // 符合:通过函数初始化
  15.     ...
  16. }
  17. std::string data;   // 符合:默认构造函数初始化
  18. ...
复制代码
避免使用reinterpret_cast

【形貌】 reinterpret_cast用于转换不相关范例。尝试用reinterpret_cast将一种范例强制转换另一种范例,这破坏了范例的安全性与可靠性,是一种不安全的转换。不同范例之间尽量避免转换。
避免使用const_cast

【形貌】 const_cast用于移除对象的const和volatile性子。
使用const_cast转换后的指针或者引用来修改const对象或volatile对象,程序会产生未定义行为。
【反例】
  1. const int i = 1024;
  2. int* p = const_cast<int*>(&i);
  3. *p = 2048;                              // 未定义行为
  4. class Foo {
  5. public:
  6.     void SetValue(int v) { value = v; }
  7. private:
  8.     int value{0};
  9. };
  10. int main()
  11. {
  12.     const Foo foo;
  13.     Foo* p = const_cast<Foo*>(&foo);
  14.     p->SetValue(2);                     // 未定义行为
  15.     return 0;
  16. }
复制代码
确保有符号整数运算不溢出

【形貌】 在C++标准中,有符号整数溢出会使程序产生未定义行为。 因此,不同的实现可以自由处理有符号整数溢出。例如:在将有符号整数范例定义为模数的实现中,编译器可以不检测整数溢出。
使用溢出后的数值可能导致程序缓冲区读写越界等风险。出于安全考虑,对外部数据中的有符号整数值在如下场景中使用时,必要确保运算不会导致溢出:


  • 指针运算的整数利用数(指针偏移值)
  • 数组索引
  • 变长数组的长度(及长度运算表达式)
  • 内存复制长度
  • 内存分配函数的参数
  • 循环判断条件
在精度低于int的整数范例上进行运算时,必要考虑整数提拔。程序员还必要掌握整数转换规则,包罗隐式转换规则,以便设计安全的算术运算。
【反例】 如下代码示例中,到场减法运算的整数是外部数据,在使用前未做校验,可能出现整数溢出,进而造成后续的内存复制利用出现缓冲区溢出。
  1. unsigned char* content = ... // 指向报文头的指针
  2. size_t contentSize = ...     // 缓冲区的总长度
  3. int totalLen = ...           // 报文总长度
  4. int skipLen = ...            // 从消息中解析出来的需要忽略的数据长度
  5. std::vector<unsigned char> dest;
  6. // 用 totalLen - skipLen 计算剩余数据长度,可能出现整数溢出
  7. std::copy_n(&content[skipLen], totalLen - skipLen, std::back_inserter(dest));
  8. ...
复制代码
【正例】 如下代码示例中,重构为使用size_t范例的变量表示数据长度,并校验外部数据长度是否在合法范围内。
  1. unsigned char* content = ... //指向报文头的指针
  2. size_t contentSize = ...     // 缓冲区的总长度
  3. size_t totalLen = ...        // 报文总长度
  4. size_t skipLen = ...         // 从消息中解析出来的需要忽略的数据长度
  5. if (skipLen >= totalLen||totalLen > contentSize) {
  6.     ... // 错误处理
  7. }
  8. std::vector<unsigned char> dest;
  9. std::copy_n(&content[skipLen], totalLen - skipLen, std::back_inserter(dest));
  10. ...
复制代码
【反例】 如下代码示例中,对来自外部数据的数值范围做了校验,但是由于second是int范例,而校验条件中错误的使用了std::numeric_limits<unsigned long>::max()进行限定,导致整数溢出。
  1. int second = ... // 来自外部数据
  2. // 错误的使用了unsigned long的取值范围做上限校验
  3. if (second < 0||second > (std::numeric_limits<unsigned long>::max() / 1000)) {
  4.     return -1;
  5. }
  6. int millisecond = second * 1000; // 可能出现整数溢出
  7. ...
复制代码
【正例】 一种改进方案是将second的范例修改为unsigned long范例,这种方案实用于修改了变量范例更符合业务逻辑的场景。
  1. unsigned long second = ... // 将类型重构为 unsigned long 类型
  2. if (second > (std::numeric_limits<unsigned long>::max() / 1000)) {
  3.     return -1;
  4. }
  5. int millisecond = second * 1000;
  6. ...
复制代码
另一种改进方案是将数值上限修改为std::numeric_limits<int>::max()。
  1. int second = ... // 来自外部数据
  2. if (second < 0||second > (std::numeric_limits<int>::max() / 1000)) {
  3.     return -1;
  4. }
  5. int millisecond = second * 1000;
复制代码
【影响】 整数溢出可能导致程序缓冲区溢出以及执行任意代码。
确保无符号整数运算不回绕

【形貌】 无符号整数的算术运算结果可能会发生整数回绕,使用回绕后的数值其可能导致程序缓冲区读写越界等风险。 出于安全考虑,对外部数据中的无符号整数值在如下场景中使用时,必要确保运算不会导致回绕:


  • 指针偏移值(指针算术运算的整数利用数)
  • 数组索引值
  • 内存拷贝的长度
  • 内存分配函数的参数
  • 循环判断条件
【反例】 如下代码示例中,校验下一个子报文的长度加上已处理报文的长度是否高出了整体报文的最大长度,在校验条件中的加法运算可能会出现整数回绕,造成绕过该校验的题目。
  1. size_t totalLen = ...              // 报文的总长度
  2. size_t readLen = 0;                // 记录已经处理报文的长度
  3. ...
  4. size_t pktLen = ParsePktLen();     // 从网络报文中解析出来的下一个子报文的长度
  5. if (readLen + pktLen > totalLen) { // 可能出现整数回绕
  6.   ... // 错误处理
  7. }
  8. ...
  9. readLen += pktLen;
  10. ...
复制代码
【正例】 由于readLen变量记录的是已经处理报文的长度,一定会小于totalLen,因此将代码中的加法运算修改为减法运算,不会导致条件绕过。
  1. size_t totalLen = ... // 报文的总长度
  2. size_t readLen = 0;   // 记录已经处理报文的长度
  3. ...
  4. size_t pktLen = ParsePktLen(); // 来自网络报文
  5. if (pktLen > totalLen - readLen) {
  6.   ... // 错误处理
  7. }
  8. ...
  9. readLen += pktLen;
  10. ...
复制代码
【反例】 如下代码示例中,校验len合法范围的运算可能会出现整数回绕,导致条件绕过。
  1. size_t len = ... // 来自用户态输入
  2. if (SCTP_SIZE_MAX - len < sizeof(SctpAuthBytes)) { // 减法操作可能出现整数回绕
  3.     ... // 错误处理
  4. }
  5. ... = kmalloc(sizeof(SctpAuthBytes) + len, gfp);   // 可能出现整数回绕
  6. ...
复制代码
【正例】 如下代码示例中,调整减法运算的位置(必要确保在编译期间减法表达式的值不回绕),避免整数回绕题目。
  1. size_t len = ... // 来自用户态输入
  2. if (len > SCTP_SIZE_MAX - sizeof(SctpAuthBytes)) { // 确保在编译期间减法表达式的值不翻转
  3.     ... // 错误处理
  4. }
  5. ... = kmalloc(sizeof(SctpAuthBytes) + len, gfp);
  6. ...
复制代码
【例外】 为准确执行程序,必要时无符号整数可能表现出模态(回绕)。建议将变量声显着白注释为支持模数行为,并且对该整数的每个利用也应明白注释为支持模数行为。
【影响】 整数回绕可能导致程序缓冲区溢出以及执行任意代码。
确保除法和余数运算不会导致除零错误(被零除)

【形貌】 整数的除法运算或取余运算的除数为0会导致程序产生未定义的行为。假如涉及到除法或者取余运算,必须确保除数不为0。
在二进制浮点数算数标准ISO/IEEE Std 754-1985中规定了浮点数被零除的行为及结果,但是仍然取决于程序所运行的软硬件环境是否依照该标准。 因此,在做浮点数被零除的运算前,应确保软硬件环境已依照二进制浮点数算数标准,否则仍然存在未定义行为。
【反例】
  1. size_t a = ReadSize();  // 来自外部数据
  2. size_t b = 1000 / a;    // 不符合:a可能是0
  3. size_t c = 1000 % a;    // 不符合:a可能是0
  4. ...
复制代码
【正例】 如下代码示例中,添加a是否为0的校验,防止除零错误。
  1. size_t a = ReadSize();  // 来自外部数据
  2. if (a == 0) {
  3.     ... // 错误处理
  4. }
  5. size_t b = 1000 / a;    // 符合:确保a不为0
  6. size_t c = 1000 % a;    // 符合:确保a不为0
  7. ...
复制代码
【影响】 除零错误可能导致拒绝服务。
只能对无符号整数进行位运算

【形貌】 对有符号整数进行位运算时可能产生未定义行为,本条款要求只能对无符号整数进行位运算,避免出现未定义行为。 此外,对精度低于int范例的无符号整数进行位运算时,编译器会进行整数提拔,再对提拔后的整数进行位运算,因此要特别留意对于这类无符号整数的位运算,避免出现非预期的结果。 本条款涉及的位利用符包罗:


  • ~(求反)
  • &(与)
  • |(或)
  • ^(异或)
  • >>(右移位)
  • <<(左移位)
  • &=
  • ^=
  • |=
  • >>=
  • <<=
在C++20中有符号整数的移位利用具有良好的定义,可以对有符号整数进行移位运算。
【反例】 在C++20之前,如下代码中的右移利用data >> 24可以实现为算术(有符号)移位或逻辑(无符号)移位;在左移利用value << data中,假如value为负数或者左移后的结果超出其整数提拔后范例的表示范围,会导致程序产生未定义行为。
  1. int32_t data = ReadByte();
  2. int32_t value = data >> 24;   // 对有符号整数进行右移运算,其结果是实现定义的
  3. ... // 检查 data 的合法范围
  4. int32_t mask = value << data; // 对有符号整数进行左移运算,程序可能产生未定义行为
复制代码
【正例】
  1. uint32_t data = static_cast<uint32_t>(ReadByte());
  2. uint32_t value = data >> 24;  // 只对无符号整数进行位运算
  3. ... // 检查 data 的合法范围
  4. uint32_t mask  = value << data;
复制代码
对于精度低于int的无符号整数进行位运算,由于整数提拔,其结果可能是非预期的,应将利用结果立即转换为期望的范例, 避免因整数提拔而导致非预期结果。
【反例】
  1. uint8_t mask = 1;
  2. uint8_t value = (~mask) >> 4; // 不符合:~运算的结果会包含高位数据,可能不符合预期
复制代码
【正例】
  1. uint8_t mask = 1;
  2. uint8_t value = (static_cast<uint8_t>(~mask)) >> 4; // 符合:~运算后立即转换为期望的类型
复制代码
【例外】


  • 作为位标记使用的有符号整数常量或罗列值,可以作为 & 和|利用符的利用数。
  1. int fd = open(fileName, O_CREAT|O_EXCL, S_IRWXU|S_IRUSR);
复制代码


  • 一个在编译时就可以确定的有符号正整数,可以作为移位利用符的右利用数。
  1. constexpr int SHIFT_BITS = 3;
  2. ...
  3. uint32_t id = ...;
  4. uint32_t type = id >> SHIFT_BITS;
复制代码
资源管理

外部数据作为数组索引或者内存利用长度时,必要校验其合法性

【形貌】 外部数据作为数组索引对内存进行访问时,必须对数据的巨细进行严格的校验,确保数组索引在有效范围内,否则会导致严重的错误。 将数据复制到容量不足以容纳该数据的内存中会导致缓冲区溢出。为了防止此类错误,必须根据目的容量的巨细限定被复制的数据巨细,或者必须确保目的容量充足大以容纳要复制的数据。
【反例】 如下代码示例中,SetDevId()函数存在差一错误,当 index 等于 DEV_NUM 时,恰好越界写一个元素。
  1. struct Dev {
  2.     int id;
  3.     char name[MAX_NAME_LEN];
  4. };
  5. static Dev devs[DEV_NUM];
  6. int SetDevId(size_t index, int id)
  7. {
  8.     if (index > DEV_NUM) {         // 存在差一错误
  9.         ... // 错误处理  
  10.     }
  11.     devs[index].id = id;
  12.     return 0;
  13. }
复制代码
【正例】 如下代码示例中,修改校验索引的条件,避免差一错误。
  1. struct Dev {
  2.     int id;
  3.     char name[MAX_NAME_LEN];
  4. };
  5. static Dev devs[DEV_NUM];
  6. int SetDevId(size_t index, int id)
  7. {
  8.     if (index >= DEV_NUM) {
  9.         ... // 错误处理  
  10.     }
  11.     devs[index].id = id;
  12.     return 0;
  13. }
复制代码
【反例】 外部输入的数据不一定会直接作为内存复制长度使用,还可能会间接到场内存复制利用。 如下代码示例中,inputTable.count来自装备外部报文,虽然没有直接作为内存复制长度使用,而是作为for循环体的上限使用,间接到场了内存复制利用。由于没有校验其巨细,可造成缓冲区溢出:
  1. struct ValueTable {  
  2.     size_t count;  
  3.     int val[MAX_NUMBERS];  
  4. };
  5. void ValueTableDup(const ValueTable& inputTable)  
  6. {  
  7.     ValueTable outputTable = {0, {0}};
  8.     ...  
  9.     for (size_t i = 0; i < inputTable.count; i++) {  
  10.         outputTable.val[i] = inputTable.val[i];  
  11.     }  
  12.     ...  
  13. }  
复制代码
【正例】 如下代码示例中,对inputTable.count做了校验。
  1. struct ValueTable {  
  2.     size_t count;  
  3.     int val[MAX_NUMBERS];  
  4. };
  5. void ValueTableDup(const ValueTable& inputTable)  
  6. {  
  7.     ValueTable outputTable = {0, {0}};  
  8.     ...  
  9.     // 根据业务场景,对来自外部报文的循环长度inputTable.count
  10.     // 与outputTable.val数组大小做校验,避免造成缓冲区溢出
  11.     if (inputTable->count >
  12.         sizeof(outputTable.val) / sizeof(outputTable.val[0])) {
  13.         ... // 错误处理
  14.     }
  15.     for (size_t i = 0; i < inputTable.count; i++) {  
  16.         outputTable.val[i] = inputTable.val[i];  
  17.     }  
  18.     ...  
  19. }  
复制代码
【影响】 假如复制数据的长度是外部可控的,则复制数据的过程中可能出现缓冲区溢出,在某些情况下可以造成任意代码执行毛病。
内存申请前,必须对申请内存巨细进行合法性校验

【形貌】 当申请内存巨细由程序外部输入时,内存申请前,要求对申请内存巨细进行合法性校验,防止申请0长度内存,或者过多地、非法地申请内存。 因为内存的资源是有限的,是可以被耗尽的。当申请内存的数值过大(可能一次就申请了非常大的超预期的内存;也可能循环中多次申请内存),很可能会造成非预期的资源耗尽。 巨细不准确的参数、不妥的范围查抄、整数溢出或者截断都可能造成现实分配的缓冲区不符合预期。假如申请内存受攻击者控制,还可能会发生缓冲区溢出等安全题目。
【反例】 如下代码示例中,将动态分配size巨细的内存。但是未对size做合法性校验。
  1. // 这里的size在传入DoSomething()函数之前还未做过合法性校验  
  2. int DoSomething(size_t size)
  3. {
  4.     ...
  5.     char* buffer = new char[size]; // 本函数内,size使用前未做校验  
  6.     ...
  7.     delete[] buffer;
  8. }
复制代码
【正例】 如下代码示例中,动态分配size巨细的内存前,进行了符合程序必要的合法性校验。
  1. // 这里的size在传入DoSomething()函数之前还未做过合法性校验  
  2. int DoSomething(size_t size)
  3. {
  4.     // 本函数内,对size做合法性校验,FOO_MAX_LEN被定义为符合程序设计预期的最大内存空间
  5.     if (size == 0||size > FOO_MAX_LEN) {
  6.         ... // 错误处理  
  7.     }
  8.     char* buffer = new char[size];
  9.     ...
  10.     delete[] buffer;
  11. }
复制代码
【影响】 假如申请内存的巨细是外部可控的,可能导致资源耗尽,造成拒绝服务。
在传递数组参数时,不应单独传递指针

【形貌】 当函数参数范例为数组(不是数组的引用)或者指针时,若调用者传入数组,则在参数传递时数组会退化为指针,其数组长度信息会丢失,容易引发越界读写等题目。 假如函数只接收固定长度的数组为参数,可以定义参数范例为数组引用或者std::array。 假如函数担当的是不带长度的指针,那么应该把长度作为另外一个参数也传递进去。
【反例】
  1. constexpr int MAX_LEN = 1024;
  2. constexpr int SIZE = 10;
  3. void UseArr(int arr[])
  4. {
  5.     for (int i = 0; i < MAX_LEN; i++) {
  6.         std::cout << arr[i] << std::endl;
  7.     }
  8. }
  9. void Test()
  10. {
  11.     int arr[SIZE] = {0};
  12.     UseArr(arr);
  13. }
复制代码
【正例】
可以把指针和长度合起来做成一个范例,方便使用。例如:雷同下面的简单封装:
  1. template <typename T>
  2. class Slice {
  3. public:
  4.     template <size_t N>
  5.     Slice(T (&arr)[N]) : data(arr), len(N) {}
  6.     template <size_t N>
  7.     Slice(std::array<T, N> arr) : data(arr.data()), len(N) {}
  8.     Slice(T* arr, size_t n) : data(arr), len(n) {}
  9.     ...
  10. private:
  11.     T* data;
  12.     size_t len;
  13. };
  14. void UseArr(Slice<int> arr)
  15. {
  16.     for (int i = 0; i < arr.size(); i++) {
  17.         std::cout << arr[i] << std::endl;
  18.     }
  19. }
  20. constexpr int SIZE = 10;
  21. void Test()
  22. {
  23.     int arr[SIZE] = {0};
  24.     Slice<int> s{arr};
  25.     UseArr(s);
  26. }
复制代码
假如项目允许的话,推荐使用成熟的库来做这个事情,例如C++20中的std::span范例。
在不使用这些工具类的情况下,可以把指针和长度作为两个参数传递。
  1. void UseArr(int arr[], size_t len)
  2. {
  3.     for (int i = 0; i < len; i++) {
  4.         std::cout << arr[i] << std::endl;
  5.     }
  6. }
  7. constexpr int SIZE = 10;
  8. void Test()
  9. {
  10.     int arr[SIZE] = {0};
  11.     UseArr(arr, sizeof(arr));
  12. }
复制代码
当lambda会逃逸出函数表面时,克制按引用捕获局部变量

【形貌】 假如一个 lambda 不止在局部范围内使用,克制按引用捕获局部变量,比如它被传递到了函数的外部,或者被传递给了其他线程的时候。lambda按引用捕获就是把局部对象的引用存储起来。假如 lambda 的生命周期会高出局部变量生命周期,则可能导致内存不安全。
【反例】
  1. void Foo()
  2. {
  3.     int local = 0;
  4.     // 按引用捕获 local,当函数返回后,local 不再存在,因此 Process() 的行为未定义
  5.     threadPool.QueueWork([&] { Process(local); });
  6. }
复制代码
【正例】
  1. void Foo()
  2. {
  3.     int local = 0;
  4.     // 按值捕获 local, 在Process() 调用过程中,local 总是有效的
  5.     threadPool.QueueWork([local] { Process(local); });
  6. }
复制代码
指向资源句柄或形貌符的变量,在资源释放后立即赋予新值

【形貌】 指向资源句柄或形貌符的变量包罗指针、文件形貌符、socket形貌符以及其他指向资源的变量。 以指针为例,当指针乐成申请了一段内存之后,在这段内存释放以后,假如其指针未立即设置为nullptr,也未分配一个新的对象,那这个指针就是一个悬空指针。 假如再对悬空指针利用,可能会发生重复释放或访问已释放内存的题目,造成安全毛病。 消减该毛病的有效方法是将释放后的指针立即设置为一个确定的新值,例如设置为nullptr。对于全局性的资源句柄或形貌符,在资源释放后,应该立刻设置新值,以避免使用其已释放的无效值;对于只在单个函数内使用的资源句柄或形貌符,应确保资源释放后其无效值不被再次使用。
【反例】 如下代码示例中,根据消息范例处理消息,处理完后释放掉body指向的内存,但是释放后未将指针设置为nullptr。假如另有其他函数再次处理该消息结构体时,可能出现重复释放内存或访问已释放内存的题目。
  1. int Fun()
  2. {
  3.     SomeStruct *msg = nullptr;
  4.     ... // 使用new分配msg、msg->body的内存空间并初始化msg
  5.     if (msg->type == MESSAGE_A) {
  6.         ...
  7.         delete msg->body;         // 不符合:释放内存后,未置空
  8.     }
  9.     ...
  10.     // 将msg存入全局队列,后续可能使用已释放的body成员
  11.     if (!InsertMsgToQueue(msg)) {
  12.         delete msg->body;         // 可能再次释放了body的内存
  13.         delete msg;
  14.         return -1;
  15.     }
  16.     return 0;
  17. }
复制代码
【正例】 如下代码示例中,立即对释放后的指针设置为nullptr,避免重复释放指针。
  1. int Fun()
  2. {
  3.     SomeStruct *msg = nullptr;
  4.     ... // 使用new分配msg、msg->body的内存空间并初始化msg
  5.     if (msg->type == MESSAGE_A) {
  6.         ...
  7.         delete msg->body;
  8.         msg->body = nullptr;
  9.     }
  10.     ...
  11.     // 将msg存入全局队列
  12.     if (!InsertMsgToQueue(msg)) {
  13.         delete msg->body;         // 马上离开作用域,不必赋值 nullptr
  14.         delete msg;               // 马上离开作用域,不必赋值 nullptr
  15.         return -1;
  16.     }
  17.     return 0;
  18. }
复制代码
默认的内存释放函数针对空指针不执行任何动作。
【反例】 如下代码示例中文件形貌符关闭后未赋新值。
  1. SOCKET s = INVALID_SOCKET;
  2. int fd = -1;
  3. ...
  4. closesocket(s);
  5. ...
  6. close(fd);
  7. ...
复制代码
【正例】 如下代码示例中,在资源释放后,对应的变量应该立即赋予新值。
  1. SOCKET s = INVALID_SOCKET;
  2. int fd = -1;
  3. ...
  4. closesocket(s);
  5. s = INVALID_SOCKET;
  6. ...
  7. close(fd);
  8. fd = -1;
  9. ...
复制代码
【影响】 再次使用已经释放的内存,或者再次释放已经释放的内存,或其他使用已释放资源的行为,可能导致拒绝服务或执行任意代码。
new和delete配对使用,new[]和delete[]配对使用

【形貌】 使用 new 利用符创造的对象,只能使用 delete 利用符来销毁。 使用 new[] 创造的对象数组只能由 delete[] 利用符来销毁。
【反例】
  1. class C {
  2. public:
  3.     C(size_t len) : arr(new int[len]) {}
  4.     ~C()
  5.     {
  6.         delete arr; // 此处应该是 delete[] arr;
  7.     }
  8. private:
  9.     int* arr;
  10. };
复制代码
【正例】
  1. class C {
  2. public:
  3.     C(size_t len) : arr(new int[len]) {}
  4.     ~C() { delete[] arr; }
  5. private:
  6.     int* arr;
  7. };
复制代码
自定义new/delete利用符必要配对定义,且行为与被替换的利用符一致

【形貌】 自定义利用符的时候,new 和 delete 要配对定义,new[] 和 delete[] 要配对定义。 自定义 new/delete 利用符的行为要与被替换的 new/delete 的行为一致。
【反例】
  1. // 如果自定义了 operator new,必须同时自定义对应的 operator delete
  2. struct S {
  3.     static void* operator new(size_t sz)
  4.     {
  5.         ... // 自定义操作
  6.         return ::operator new(sz);
  7.     }
  8. };
复制代码
【正例】
  1. struct S {
  2.     static void* operator new(size_t sz)
  3.     {
  4.         ... // 自定义操作
  5.         return ::operator new(sz);
  6.     }
  7.     static void operator delete(void* ptr, size_t sz)
  8.     {
  9.         ... // 自定义操作
  10.         ::operator delete(ptr);
  11.     }
  12. };
复制代码
默认的 new 利用符在内存分配失败时,会抛出std::bad_alloc非常,而使用了std::nothrow参数的 new 利用符在内存分配失败时,会返回 nullptr。 自定义的 new/delete 利用符要和内置的利用符行为保持一致。
【反例】
  1. // 在内存管理模块头文件中声明的函数
  2. extern void* AllocMemory(size_t size);   // 分配失败返回 nullptr
  3. void* operator new(size_t size)
  4. {
  5.     return AllocMemory(size);
  6. }
复制代码
【正例】
  1. // 在内存管理模块头文件中声明的函数
  2. extern void* AllocMemory(size_t size);   // 分配失败返回 nullptr
  3. void* operator new(size_t size)
  4. {
  5.     void* ret = AllocMemory(size);
  6.     if (ret != nullptr) {
  7.         return ret;
  8.     }
  9.     throw std::bad_alloc();              // 分配失败抛出异常
  10. }
  11. void* operator new(size_t size, const std::nothrow_t& tag)
  12. {
  13.     return AllocMemory(size);
  14. }
复制代码
错误处理

抛非常时,抛对象本身,而不是指向对象的指针

【形貌】 C++中推荐的抛非常方式是抛对象本身,而不是指向对象的指针。
用throw语句抛出非常的时候,会构造一个暂时对象,称为“非常对象(exception object)”。这个非常对象的生命周期在C++语言中很明白:非常对象在throw时被构造;在某个捕获它的catch语句以throw以外的方式结束(即没有重新抛出)时,或者指向这个非常的std::exception_ptr对象被析构时析构。
抛出指针,会使回收被抛出对象的责任不明白。捕获非常的地方是否有任务对该指针进行delete利用,取决于该对象是如何分配的(例如静态变量,或者用new分配),以及这个对象是否被共享了。但是指针范例本身并不能表明这个对象的生命周期以及所有权,也就无法判断是否应该delete。假如应该delete却没有做,会造成内存泄露;假如不该delete却做了,会造成重复释放。
【反例】
不要抛指针。
  1. static SomeException exc1("reason 1");
  2. try {
  3.     if (SomeFunction()) {
  4.         throw &exc1;                         // 不符合:这是静态对象的指针,不应该delete
  5.     } else {
  6.         throw new SomeException("reason 2"); // 不符合:这是动态分配的,应该delete
  7.     }
  8. } catch (const SomeException* e) {
  9.     delete e;                                // 不符合:这里不能确定是否需要delete
  10. }
复制代码
【正例】
永远抛非常对象本身。
  1. try {
  2.     if (SomeFunction()) {
  3.         throw SomeException("reason 1");
  4.     } else {
  5.         throw SomeException("reason 2");
  6.     }
  7. } catch (const SomeException& e) {
  8.     ...                                      // 符合:这里可以确定不需要delete
  9. }
复制代码
克制从析构函数中抛出非常

【形貌】
析构函数默认自带noexcept属性,假如析构函数抛出非常,会直接导致std::terminate。自C++11起,允许析构函数被标记为noexcept(false),但即便如此,假如析构函数在stack unwinding的过程中被调用(例如另一个非常抛出,导致栈上的局部变量被析构),结果也是std::terminate,而析构函数最大的作用就是在不论正常返回照旧抛出非常的情况下都能清理局部变量。因此,让析构函数抛出非常一般都是欠好的。
标准库

克制从空指针创建std::string

【形貌】 将空指针传递给std::string构造函数,会解引用空指针,从而导致程序产生未定义行为。
【反例】
  1. void Foo()
  2. {
  3.     const char* path = std::getenv("PATH");
  4.     std::string str(path);                  // 错误:这里没有判断getenv的返回值是否为nullptr
  5.     std::cout << str << std::endl;
  6. }
复制代码
【正例】
  1. void Foo()
  2. {
  3.     const char* path = std::getenv("PATH");
  4.     if (path == nullptr) {
  5.         ... // 报告错误
  6.         return;
  7.     }
  8.     std::string str(path);
  9.     ...
  10.     std::cout << str << std::endl;
  11. }
  12. void Foo()
  13. {
  14.     const char* path = std::getenv("PATH");
  15.     std::string str(path == nullptr ? path : "");
  16.     ... // 判断空字符串
  17.     std::cout << str << std::endl;                // 必要时判断空字符串
  18. }
复制代码
不要保存std::string范例的c_str和data成员函数返回的指针

【形貌】 为保证调用std::string对象的c_str()和data()成员函数返回的引用值结果的有效性,不应保存std::string范例的c_str()和data()的结果,而是在每次必要时直接调用(调用的开销会被编译器内联优化)。否则,当调用此std::string对象的修改方法修改对象后,或超出std::string对象作用域时,之前存储的指针将会失效。使用失效的指针将导致未定义行为。
【反例】
  1. void Bar(const char*  data)
  2. {
  3.     ...
  4. }
  5. void Foo1()
  6. {
  7.     std::string name{"demo"};
  8.     const char* text = name.c_str();          // 表达式结束以后,name的生命周期还在,指针有效
  9.     // 如果中间调用了std::string的非const成员函数,导致name被修改,例如operator[], begin()等,
  10.     // 可能会导致text的内容不可用,或者不是原来的字符串
  11.     name = "test";
  12.     name[1] = '2';
  13.     ...
  14.     Bar(text);                                // 此处text已不再指向合法内存空间
  15. }
  16. void Foo2()
  17. {
  18.     std::string name{"demo"};
  19.     std::string test{"test"};
  20.     const char* text = (name + test).c_str(); // 表达式结束以后,+号产生的临时对象被销毁
  21.     ...
  22.     Bar(text);                                // 此处text已不再指向合法内存空间
  23. }
  24. void Foo3(std::string& s)
  25. {
  26.     const char* data = s.data();
  27.     ...
  28.     s.replace(0, 3, "***");
  29.     ...
  30.     Bar(data);                                // 此处text已不再指向合法内存空间
  31. }
复制代码
【正例】
  1. void Foo1()
  2. {
  3.     std::string name{"demo"};
  4.     name = "test";
  5.     name[1] = '2';
  6.     ...
  7.     Bar(name.c_str());
  8. }
  9. void Foo2()
  10. {
  11.     std::string name{"demo"};
  12.     std::string test{"test"};
  13.     name += test;
  14.     ...
  15.     Bar(name.c_str());
  16. }
  17. void Foo3(std::string& s)
  18. {
  19.     ...
  20.     s.replace(0, 3, "***");
  21.     ...
  22.     Bar(s.data());
  23. }
复制代码
【例外】 在少数对性能要求非常高的代码中,为了适配已有的只担当const char*范例入参的函数,可以暂时保存std::string对象的c_str()方法返回的指针。但是必须严格保证std::string对象的生命周期长于所保存指针的生命周期,并且保证在所保存指针的生命周期内,std::string对象不会被修改。
确保用于字符串利用的缓冲区有充足的空间容纳字符数据和结束符,并且字符串以null结束符结束

【形貌】 C风格字符串是一个一连的字符序列,由字符序列中的第一个出现的null字符终止并包含该null字符。
复制或存储C风格字符串时,必须确保缓冲区有充足的空间容纳字符序列包罗null结束符,并且字符串以null结束符结束,否则可能会导致缓冲区溢出题目:


  • 优先使用std::string表示字符串,std::string表示字符串利用更简便,更容易被准确的使用,避免由于C风格字符串使用不妥而导致溢出、没有null结束符的题目。
  • 使用C/C++标准库提供的C风格字符串利用函数时,必要确保输入的字符串以null结束符结束、不能超出字符串缓冲区的范围读写字符串、确保进存储利用后的字符串以null结束符结束。
【反例】
  1. char buf[BUFFER_SIZE];
  2. std::cin >> buf;
  3. void Foo(std::istream& in)
  4. {
  5.     char buffer[BUFFER_SIZE];
  6.     if (!in.read(buffer, sizeof(buffer))) { // 注意:in.read()不能保证'\0'结尾
  7.         ... // 错误处理
  8.         return;
  9.     }
  10.     std::string str(buffer);                // 不符合:字符串没有结尾的'\0'
  11.     ...
  12. }
  13. void Foo(std::istream& in)
  14. {
  15.     std::string s;
  16.     in >> s;                    // 不符合:没有限制待读取的长度,可能导致资源消耗或攻击
  17.     ...
  18. }
复制代码
【正例】
  1. char buf[BUFFER_SIZE] = {0};
  2. std::cin.width(sizeof(buf) - 1); // 注意需要缓冲区长度-1,以留出字符串末尾'\0'的空间
  3. std::cin >> buf;
  4. void Foo(std::istream& in)
  5. {
  6.     char buffer[BUFFER_SIZE];
  7.     if (!in.read(buffer, sizeof(buffer)) { // 注意in.read()不能保证'\0'结尾
  8.         ... // 错误处理
  9.         return;
  10.     }
  11.     std::string str(buffer, in.gcount()); // 让std::string构造函数,只读取指定长度的字符内容
  12.     ...
  13. }
  14. void Foo(std::istream& in)
  15. {
  16.     std::string s;
  17.     in.width(MAX_NEED_SIZE);
  18.     in >> s;                             // 符合:已经限制读取的最大长度
  19.     ...
  20. }
复制代码
【影响】 未对外部数据中的整数值进行限定可能导致拒绝服务,缓冲区溢出,信息泄露,或执行任意代码。
克制使用std::string存储敏感信息

【形貌】 std::string类是C++内部定义的字符串管理类,假如口令等敏感信息通过std::string进行利用,在程序运行过程中,敏感信息可能会散落到内存的各个地方,并且无法清除。
【反例】 如下代码中,Foo函数中获取暗码,保存到std::string变量password中,随后传递给VerifyPassword函数,在这个过程中,password现实上在内存中出现了两份。
  1. bool VerifyPassword(std::string password)
  2. {
  3.     ...
  4. }
  5. void Foo()
  6. {
  7.     std::string password = GetPassword();
  8.     VerifyPassword(password);
  9. }
复制代码
【影响】 未及时清理敏感信息,可能导致信息泄露。
外部数据用于容器索引或迭代器时必须确保在有效范围内

【形貌】 外部数据是不可信数据,当将外部数据用于容器或数组的索引时,应确保其值在容器或数组可被访问元素的有效范围内;当将外部数据用于迭代器偏移时,应确保偏移后的迭代器值在与迭代器关联容器(从容器对象c的begin()方法创建)的[begin(), end())之间(即大于等于c.begin(),小于等于c.end())。
对于具有at()方法的容器(如std::vector, std::set, std::map),对应索引越界或键值内容不存在时,方法将抛出非常;而其对应的operator[]出现索引越界时,将导致未定义行为;或者因键值内容不存在而构造对应键值的默认值不乐成时,也将导致未定义行为。
【反例】
  1. int main()
  2. {
  3.     // 得到一个来自外部输入的整数 (index)
  4.     int index;
  5.     if (!(std::cin >> index)) {
  6.         ... // 错误处理
  7.         return -1;
  8.     }
  9.     std::vector<char> c{'A', 'B', 'C', 'D'};
  10.     // 不符合:没有正确校验index的范围,溢出读取:需要确保index在容器元素的位置范围
  11.     std::cout << c[index] << std::endl;
  12.     // 不符合:需要确保index在容器/数组元素的位置范围
  13.     for (auto pos = std::cbegin(c) + index; pos < std::cend(c); ++pos) {
  14.         std::cout << *pos << std::endl;
  15.     }
  16.     return 0;
  17. }
  18. void Foo(size_t n)
  19. {
  20.     std::vector<int> v{0, 1, 2, 3};
  21.     // n为外部的API传入的索引,可能导致越界访问
  22.     for_each_n(v.cbegin(), n, [](int x) { std::cout << x; });
  23. }
复制代码
【正例】
  1. int main()
  2. {
  3.     // 得到一个来自外部输入的整数 (index)
  4.     int index;
  5.     if (!(std::cin >> index)) {
  6.         ... // 错误处理
  7.         return -1;
  8.     }
  9.     // 这里仅以std::vector来举例,std::cbegin(c)等代码也适用于std::string字符串、
  10.     // 和C数组(但不适应于char*变量以及char*表示的静态字符串)
  11.     std::vector<char> c{'A', 'B', 'C', 'D'};
  12.     try {
  13.         std::cout << c.at(index) << std::endl; // 符合:索引越界时,at函数将抛出异常
  14.     } catch (const std::out_of_range& e) {
  15.         ... // 越界异常处理
  16.     }
  17.     // 后续代码必须使用检验合法的 index 进行容器元素索引或迭代器偏移
  18.     // 正确校验index的范围:已确保index在容器元素的位置范围
  19.     if (index < 0||index >= c.size()) {
  20.         ... // 错误处理
  21.         return -1;
  22.     }
  23.     std::cout << c[index] << std::endl;        // 符合:已检验index的范围
  24.     // 符合:已检验index
  25.     for (auto pos = std::cbegin(c) + index; pos < std::cend(c); ++pos) {
  26.         std::cout << *pos << std::endl;
  27.     }
  28.     return 0;
  29. }
  30. void Foo(size_t n)
  31. {
  32.     std::vector<int> v{0, 1, 2, 3};
  33.     // 必须确保for_each_n的迭代范围[first, first + count)有效
  34.     if (n > v.size()) {
  35.         ... // 错误处理
  36.         return;
  37.     }
  38.     for_each_n(v.cbegin(), n, [](int x) { std::cout << x; });
  39. }
复制代码
调用格式化输入/输出函数时,使用有效的格式字符串

【形貌】 使用C风格的格式化输入/输出函数时,必要确保格式串是合法有效的,并且格式串与相应的实参范例是严格匹配的,否则会使程序产生非预期行为。
除C风格的格式化输入/输出函数以外,C++中雷同的函数也必要确保使用有效的格式串,如C++20的std::format()函数。
对于自定义C风格的格式化函数,可以使用编译器支持的属性自动查抄使用自定义格式化函数的准确性。 例如:GCC支持自动检测雷同printf, scanf, strftime, strfmon的自定义格式化函数,参考GCC手册的Common Function Attributes:
  1. extern int CustomPrintf(void* obj, const char* format, ...)
  2.     __attribute__ ((format (printf, 2, 3)));
复制代码
【反例】 如下代码示例中,格式化输入一个整数到macAddr变量中,但是macAddr为unsigned char范例,而%x对应的是int范例参数,函数执行完成后会发生写越界。
  1. unsigned char macAddr[6];
  2. ...
  3. // macStr中的数据格式为 e2:42:a4:52:1e:33
  4. int ret = sscanf(macStr, "%x:%x:%x:%x:%x:%x\n",
  5.                   &macAddr[0], &macAddr[1],
  6.                   &macAddr[2], &macAddr[3],
  7.                   &macAddr[4], &macAddr[5]);
  8. ...
复制代码
【正例】 如下代码中,使用%hhx确保格式串与相应的实参范例严格匹配。
  1. unsigned char macAddr[6];
  2. ...
  3. // macStr中的数据格式为 e2:42:a4:52:1e:33
  4. int ret = sscanf(macStr, "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx\n",
  5.                   &macAddr[0], &macAddr[1],
  6.                   &macAddr[2], &macAddr[3],
  7.                   &macAddr[4], &macAddr[5]);
  8. ...
复制代码
注:在C++中不推荐使用sscanf, sprintf等C库函数,可以替换为:std::istringstream, std:stringstream, std::stringstream等。
【影响】 错误的格式串可能造成内存破坏或者程序非常终止。
调用格式化输入/输出函数时,克制format参数受外部数据控制

【形貌】 调用格式化函数时,假如format参数由外部数据提供,或由外部数据拼接而来,会造成字符串格式化毛病。 以C标准库的格式化输出函数为例,当其format参数外部可控时,攻击者可以使用%n转换符向指定地点写入一个整数值、使用%x或%d转换符查察栈或寄存器内容、使用%s转换符造成进程瓦解等。
常见格式化函数有:


  • 格式化输出函数: sprintf, vsprintf, snprintf, vsnprintf等等
  • 格式化输入函数: sscanf, vsscanf, fscanf, vscanf等等
  • 格式化错误消息函数: err(), verr(), errx(), verrx(), warn(), vwarn(), warnx(), vwarnx(), error(), error_at_line()
  • 格式化日志函数: syslog(), vsyslog()
  • C++20提供的std::format()
调用格式化函数时,应使用常量字符串作为格式串,克制格式串外部可控:
  1. Box<int> v{MAX_COUNT};
  2. std::cout << std::format("{:#x}", v);
复制代码
【反例】 如下代码示例中,使用Log()函数直接打印外部数据,可能出现格式化字符串毛病。
  1. void Foo()
  2. {
  3.     std::string msg = GetMsg();
  4.     ...
  5.     syslog(priority, msg.c_str());       // 不符合:存在格式化字符串漏洞  
  6. }
复制代码
【正例】 下面是推荐做法,使用%s转换符打印外部数据,避免格式化字符串毛病。
  1. void Foo()
  2. {
  3.     std::string msg = GetMsg();
  4.     ...
  5.     syslog(priority, "%s", msg.c_str()); // 符合:这里没有格式化字符串漏洞  
  6. }
复制代码
【影响】 假如格式串被外部可控,攻击者可以使进程瓦解、查察栈内容、查察内存内容或者在任意内存位置写入数据,进而以被攻击进程的权限执行任意代码。
克制外部可控数据作为进程启动函数的参数或者作为dlopen/LoadLibrary等模块加载函数的参数

【形貌】 本条款中进程启动函数包罗system、popen、execl、execlp、execle、execv、execvp等。 system()、popen()等函数会创建一个新的进程,假如外部可控数据作为这些函数的参数,会导致注入毛病。 使用execl()等函数执行新进程时,假如使用shell启动新进程,则同样存在命令注入风险。 使用execlp()、execvp()、execvpe()函数依赖于系统的环境变量PATH来搜刮程序路径,使用它们时应充分考虑外部环境变量的风险,或避免使用这些函数。
因此,总是优先考虑使用C标准函数实现必要的功能。假如确实必要使用这些函数,应使用白名单机制确保这些函数的参数不受任何外来数据的影响。
dlopen、LoadLibrary函数会加载外部模块,假如外部可控数据作为这些函数的参数,有可能会加载攻击者事先预制的模块。假如要使用这些函数,可以采取如下措施之一:


  • 使用白名单机制,确保这些函数的参数不受任何外来数据的影响。
  • 使用数字署名机制掩护要加载的模块,充分保证其完整性。
  • 在装备本地加载的动态库通过权限与访问控制措施保证了本身安全性后,通过特定目录自动被程序加载。
  • 在装备本地的配置文件通过权限与访问控制措施保证了本身安全性后,自动加载配置文件中指定的动态库。
【反例】 如下代码从外部获取数据后直接作为LoadLibrary函数的参数,有可能导致程序被植入木马。
  1. char* msg = GetMsgFromRemote();
  2. LoadLibrary(msg);
复制代码
如下代码示例中,使用 system() 函数执行 cmd 命令串来自外部,攻击者可以执行任意命令:
  1. std::string cmd = GetCmdFromRemote();
  2. system(cmd.c_str());
复制代码
如下代码示例中,使用 system() 函数执行 cmd 命令串的一部分来自外部,攻击者可能输入 some dir;reboot字符串,创造成系统重启:
  1. std::string name = GetDirNameFromRemote();
  2. std::string cmd{"ls " + name};
  3. system(cmd.c_str());
复制代码
使用exec系列函数来避免命令注入时,留意exec系列函数中的path、file参数克制使用命令解析器(如/bin/sh)。
  1. int execl(const char* path, const char* arg, ...);
  2. int execlp(const char* file, const char* arg, ...);
  3. int execle(const char* path, const char* arg, ...);
  4. int execv(const char* path, char* const argv[]);
  5. int execvp(const char* file, char* const argv[]);
  6. int execvpe(const char* file, char* const argv[], char* const envp[]);
复制代码
例如,克制如下使用方式:
  1. std::string cmd = GetDirNameFromRemote();
  2. execl("/bin/sh", "sh", "-c", cmd.c_str(), nullptr);
复制代码
可以使用库函数,或者可以通过编写少量的代码来避免使用system函数调用命令,如mkdir()函数可以实现mkdir命令的功能。 如下代码中,应该避免使用cat命令实现文件内容复制的功能。
  1. int WriteDataToFile(const char* dstFile, const char* srcFile)
  2. {
  3.     ...  // 入参的合法性校验
  4.     std::ostringstream oss;
  5.     oss << "cat " << srcFile << " > " << dstFile;
  6.     std::string cmd{oss.str()};
  7.     system(cmd.c_str());
  8.     ...
  9. }
复制代码
【正例】
如下代码中,通过少量的代码来实现。如下代码实现了文件复制的功能,避免了对cat或cp命令的调用。必要留意的是,为简化形貌,下面代码未考虑信号中断的影响。
  1. bool WriteDataToFile(const std::string& dstFilePath, const std::string& srcFilePath)
  2. {
  3.     const int bufferSize = 1024;
  4.     std::vector<char> buffer (bufferSize + 1, 0);
  5.     std::ifstream srcFile(srcFilePath, std::ios::binary);
  6.     std::ofstream dstFile(dstFilePath, std::ios::binary);
  7.     if (!dstFile||!dstFile) {
  8.         ... // 错误处理
  9.         return false;
  10.     }
  11.     while (true) {
  12.         // 从srcFile读取内容分块
  13.         srcFile.read(buffer.data(), bufferSize);
  14.         std::streamsize size = srcFile ? bufferSize : srcFile.gcount();
  15.         // 写入分块内容到dstFile
  16.         if (size > 0 && !dstFile.write(buffer.data(), size)) {
  17.             ... // 错误处理
  18.             break;
  19.         }
  20.         if (!srcFile) {
  21.             ... // 检查错误:当不是eof()时记录错误
  22.             break;
  23.         }
  24.     }
  25.     // srcFile 和 dstFile 在退出作用域时会自动被关闭
  26.     return true;
  27. }
复制代码
可以通过库函数简单实现的功能(如上例),必要避免调用命令处理器来执行外部命令。 假如确实必要调用单个命令,应使用exec*函数来实现参数化调用,并对调用的命令实施白名单管理。同时应避免使用execlp、execvp、execvpe函数,因为这几个函数依赖外部的PATH环境变量。 此时,外部输入的fileName仅作为some_tool命令的参数,没有命令注入的风险。
  1. pid_t pid;
  2. char* const envp[] = {nullptr};
  3. ...
  4. std::string fileName = GetDirNameFromRemote();
  5. ...
  6. pid = fork();
  7. if (pid < 0) {
  8.     ...
  9. } else if (pid == 0) {
  10.     // 使用some_tool对指定文件进行加工
  11.     execle("/bin/some_tool", "some_tool", fileName.c_str(), nullptr, envp);
  12.     _Exit(-1);
  13. }
  14. ...
  15. int status;
  16. waitpid(pid, &status, 0);
  17. std::ofstream ofs(fileName, std::ios::in);
  18. ...
复制代码
在必须使用system等命令解析器执行命令时,应对输入的命令字符串基于合理的白名单查抄,避免命令注入。
  1. std::string cmd = GetCmdFromRemote();
  2. // 使用白名单检查命令是否合法,仅允许"some_tool_a", "some_tool_b"命令,外部无法随意控制
  3. if (!IsValidCmd(cmd.c_str())) {
  4.     ... // 错误处理
  5. }
  6. system(cmd.c_str());
  7. ...
复制代码
【影响】


  • 假如传递给system()、popen()或其他命令处理函数的命令字符串是外部可控的,则攻击者可能会以被攻击进程的权限执行系统上存在的任意命令。
  • 假如动态库文件是外部可控的,则攻击者可替换该库文件,在某些情况下可以造成任意代码执行毛病。
其他C语言编程规范

克制通过对数组范例的函数参数变量进行sizeof来获取数组巨细

【形貌】
使用sizeof利用符求其利用数的巨细(以字节为单元),其利用数可以是一个表达式或者加上括号的范例名称,例如:sizeof(int)或sizeof(int *)。 参考C11标准6.5.3.4中的脚注103:
   当将sizeof应用于具有数组或函数范例的参数时,sizeof利用符将得出调整后的(指针)范例的巨细。
  函数参数列表中声明为数组的参数会被调整为相应范例的指针。例如:void Func(int inArray[LEN])函数参数列表中的inArray虽然被声明为数组,但是现实上会被调整为指向int范例的指针,即调整为void Func(int *inArray)。 在这个函数内使用sizeof(inArray)等同于sizeof(int *),得到的结果通常与预期不相符。例如:在IA-32架构上,sizeof(inArray) 的值是 4,并不是inArray数组的巨细。
【反例】 如下代码示例中,函数ArrayInit的功能是初始化数组元素。该函数有一个声明为int inArray[]的参数,被调用时传递了一个长度为256的int范例数组data。 ArrayInit函数实现中使用sizeof(inArray) / sizeof(inArray[0])方法来盘算入参数组中元素的数目。 但由于inArray是函数参数,以是具有指针范例,结果,sizeof(inArray)等同于sizeof(int *)。 无论传递给ArrayInit函数的数组现实长度如何,表达式的sizeof(inArray) / sizeof(inArray[0])盘算结果均为1,与预期不符。
  1. #define DATA_LEN 256
  2. void ArrayInit(int inArray[])
  3. {
  4.     // 不符合:这里使用sizeof(inArray)计算数组大小
  5.     for (size_t i = 0; i < sizeof(inArray) / sizeof(inArray[0]); i++) {
  6.         ...
  7.     }
  8. }
  9. void FunctionData(void)
  10. {
  11.     int data[DATA_LEN];
  12.     ...
  13.     ArrayInit(data); // 调用ArrayInit函数初始化数组data数据
  14.     ...
  15. }
复制代码
【正例】 如下代码示例中,修改函数定义,添加数组长度参数,并在调用处准确传入数组长度。
  1. #define DATA_LEN 256
  2. // 函数说明:入参len是入参inArray数组的长度
  3. void ArrayInit(int inArray[], size_t len)
  4. {
  5.     for (size_t i = 0; i < len; i++) {
  6.         ...
  7.     }
  8. }
  9. void FunctionData(void)
  10. {
  11.     int data[DATA_LEN];
  12.     ArrayInit(data, sizeof(data) / sizeof(data[0]));
  13.     ...
  14. }
复制代码
【反例】 如下代码示例中,sizeof(inArray)不等于ARRAY_MAX_LEN * sizeof(int),因为将sizeof利用符应用于声明为具有数组范例的参数时,即使参数声明指定了长度,也会被调整为指针,sizeof(inArray)等同于 sizeof(int *):
  1. #define ARRAY_MAX_LEN 256
  2. void ArrayInit(int inArray[ARRAY_MAX_LEN])
  3. {
  4.     // 不符合:sizeof(inArray),得到的长度是指针的大小,不是数组的长度,和预期不符。
  5.     for (size_t i = 0; i < sizeof(inArray) / sizeof(inArray[0]); i++) {
  6.         ...
  7.     }
  8. }
  9. int main(void)
  10. {
  11.     int masterArray[ARRAY_MAX_LEN];
  12.     ...
  13.     ArrayInit(masterArray);
  14.     return 0;
  15. }
复制代码
【正例】 如下代码示例中,使用入参len表示指定数组的长度:
  1. #define ARRAY_MAX_LEN 256
  2. // 函数说明:入参len是入参数组的长度
  3. void ArrayInit(int inArray[], size_t len)
  4. {
  5.     for (size_t i = 0; i < len; i++) {
  6.         ...
  7.     }
  8. }
  9. int main(void)
  10. {
  11.     int masterArray[ARRAY_MAX_LEN];
  12.     ArrayInit(masterArray, ARRAY_MAX_LEN);
  13.     ...
  14.     return 0;
  15. }
复制代码
克制通过对指针变量进行sizeof利用来获取数组巨细

形貌】 将指针当做数组进行sizeof利用时,会导致现实的执行结果与预期不符。例如:变量定义 char *p = array,此中array的定义为char array[LEN],表达式sizeof(p)得到的结果与 sizeof(char *)雷同,并非array的长度。
【反例】 如下代码示例中,buffer和path分别是指针和数组,程序员想对这2个内存进行清0利用,但由于程序员的疏忽,将内存巨细误写成了sizeof(buffer),与预期不符。
  1. char path[MAX_PATH];
  2. char *buffer = (char *)malloc(SIZE);
  3. ...
  4. ...
  5. memset(path, 0, sizeof(path));
  6. // sizeof与预期不符,其结果为指针本身的大小而不是缓冲区大小
  7. memset(buffer, 0, sizeof(buffer));
复制代码
【正例】 如下代码示例中,将sizeof(buffer)修改为申请的缓冲区巨细:
  1. char path[MAX_PATH];
  2. char *buffer = (char *)malloc(SIZE);
  3. ...
  4. ...
  5. memset(path, 0, sizeof(path));
  6. memset(buffer, 0, SIZE); // 使用申请的缓冲区大小
复制代码
克制直接使用外部数据拼接SQL命令

【形貌】 SQL注入是指SQL查询被恶意更改成一个与程序预期完全不同的查询。执行更改后的查询可能会导致信息泄露或者数据被篡改。而SQL注入的根源就是使用外部数据来拼接SQL语句。C/C++语言中常见的使用外部数据拼接SQL语句的场景有(包罗但不局限于):


  • 毗连MySQL时调用mysql_query(),Execute()时的入参
  • 毗连SQL Server时调用db-library驱动的dbsqlexec()的入参
  • 调用ODBC驱动的SQLprepare()毗连数据库时的SQL语句的参数
  • C++程序调用OTL类库中的otl_stream(),otl_column_desc()时的入参
  • C++程序毗连Oracle数据库时调用ExecuteWithResSQL()的入参
防止SQL注入的方法重要有以下几种:


  • 参数化查询(通常也叫作预处理语句):参数化查询是一种简单有效的防止SQL注入的查询方式,应该被优先考虑使用。支持的数据库有MySQL,Oracle(OCI)。
  • 参数化查询(通过ODBC驱动):支持ODBC驱动参数化查询的数据库有Oracle、SQLServer、PostgreSQL和GaussDB。
  • 对外部数据进行校验(对于每个引入的外部数据推荐“白名单”校验)。
  • 对外部数据中的SQL特殊字符进行转义。
【反例】 下列代码拼接用户输入,没有进行输入查抄,存在SQL注入风险:
  1. char name[NAME_MAX];
  2. char sqlStatements[SQL_CMD_MAX];
  3. int ret = GetUserInput(name, NAME_MAX);
  4. ...
  5. ret = sprintf(sqlStatements,
  6.                 "SELECT childinfo FROM children WHERE name= ‘%s’",
  7.                 name);
  8. ...
  9. ret = mysql_query(&myConnection, sqlStatements);
  10. ...
复制代码
【正例】 使用预处理语句进行参数化查询可以防御SQL注入攻击:
  1. char name[NAME_MAX];
  2. ...
  3. MYSQL_STMT *stmt = mysql_stmt_init(myConnection);
  4. char *query = "SELECT childinfo FROM children WHERE name= ?";
  5. if (mysql_stmt_prepare(stmt, query, strlen(query))) {
  6.     ...
  7. }
  8. int ret = GetUserInput(name, NAME_MAX);
  9. ...
  10. MYSQL_BIND params[1];
  11. (void)memset(params, 0, sizeof(params));
  12. ...
  13. params[0].bufferType = MYSQL_TYPE_STRING;
  14. params[0].buffer = (char *)name;
  15. params[0].bufferLength = strlen(name);
  16. params[0].isNull = 0;
  17. bool isCompleted = mysql_stmt_bind_param(stmt, params);
  18. ...
  19. ret = mysql_stmt_execute(stmt);
  20. ...
复制代码
【影响】
假如拼接SQL语句的字符串是外部可控的,则攻击者可以通过注入特定的字符串诱骗程序执行恶意的SQL命令,造成信息泄露、权限绕过、数据被篡改等题目。
内存中的敏感信息使用完毕后立即清0

【形貌】 内存中的口令、密钥等敏感信息使用完毕后立即清0,避免被攻击者获取或者无意间泄露给低权限用户。这里所说的内存包罗但不限于:


  • 动态分配的内存
  • 静态分配的内存
  • 自动分配(堆栈)内存
  • 内存缓存
  • 磁盘缓存
【反例】 通常内存在释放前不必要清除内存数据,因为这样在运行时会增加额外开销,以是在这段内存被释放之后,之前的数据照旧会保存在此中。假如这段内存中的数据包含敏感信息,则可能会意外泄露敏感信息。为了防止敏感信息泄露,必须先清除内存中的敏感信息,然后再释放。 在如下代码示例中,存储在所引用的动态内存中的敏感信息secret被复制到新动态分配的缓冲区newSecret,终极通过free()释放。因为释放前未清除这块内存数据,这块内存可能被重新分配到程序的另一部分,之前存储在newSecret中的敏感信息可能会无意中被泄露。
  1. char *secret = NULL;
  2. /*
  3. * 假设 secret 指向敏感信息,敏感信息的内容是长度小于SIZE_MAX个字符,
  4. * 并且以null终止的字节字符串
  5. */
  6. size_t size = strlen(secret);
  7. char *newSecret = NULL;
  8. newSecret = (char *)malloc(size + 1);
  9. if (newSecret == NULL) {
  10.     ... // 错误处理
  11. } else {
  12.     errno_t ret = strcpy(newSecret, secret);
  13.     ... // 处理 ret
  14.     ... // 处理 newSecret...
  15.     free(newSecret);
  16.     newSecret = NULL;
  17. }
  18. ...
复制代码
【正例】 如下代码示例中,为了防止信息泄露,应先清除包含敏感信息的动态内存(用’\0’字符填充空间),然后再释放它。
  1. char *secret = NULL;
  2. /*
  3. * 假设 secret 指向敏感信息,敏感信息的内容是长度小于SIZE_MAX个字符,
  4. * 并且以null终止的字节字符串
  5. */
  6. size_t size = strlen(secret);
  7. char *newSecret = NULL;
  8. newSecret = (char *)malloc(size + 1);
  9. if (newSecret == NULL) {
  10.     ... // 错误处理
  11. } else {
  12.     errno_t ret = strcpy(newSecret,  secret);
  13.     ... // 处理 ret
  14.     ... // 处理 newSecret...
  15.     (void)memset(newSecret,  0, size + 1);
  16.     free(newSecret);
  17.     newSecret = NULL;
  18. }
  19. ...
复制代码
【正例】 下面是另外一个涉及敏感信息清理的场景,在代码获取到暗码后,将暗码保存到password中,进行暗码验证,使用完毕后,通过memset()函数对password清0。
  1. int Foo(void)
  2. {
  3.     char password[MAX_PWD_LEN];
  4.     if (!GetPassword(password, sizeof(password))) {
  5.         ...
  6.     }
  7.     if (!VerifyPassword(password)) {
  8.         ...
  9.     }
  10.     ...
  11.     (void)memset(password,  0, sizeof(password));
  12.     ...
  13. }
复制代码
要特别留意:对敏感信息清理的时候要同时防止因编译器优化而使清理代码无效。
例如,下列代码使用了可能被编译器优化掉的语句。
  1. int SecureLogin(void)
  2. {
  3.     char pwd[PWD_SIZE];
  4.     if (RetrievePassword(pwd, sizeof(pwd))) {
  5.         ... // 口令检查及其他处理
  6.     }
  7.     memset(pwd, 0, sizeof(pwd)); // 编译器优化有可能会使该语句失效
  8.     ...
  9. }
复制代码
某些编译器在优化时候不会执行它认为不会改变程序执行结果的代码,因此memset()利用会被优化掉。
假如编译器支持#pragma指令,那么可以使用该指令指示编译器不作优化。
  1. void SecureLogin(void)
  2. {
  3.     char pwd[PWD_SIZE];
  4.     if (RetrievePassword(pwd, sizeof(pwd))) {
  5.         ... // 口令检查及其他处理
  6.     }
  7.     #pragma optimize("", off)
  8.     // 清除内存
  9.     ...
  10.     #pragma optimize("", on)
  11.     ...
  12. }
复制代码
【影响】
未及时清理敏感信息,可能导致信息泄露。
创建文件时必须显式指定符合的文件访问权限

【形貌】 创建文件时,假如不显式指定符合访问权限,可能会让未经授权的用户访问该文件,造成信息泄露,文件数据被篡改,文件中被注入恶意代码等风险。
虽然文件的访问权限也依赖于文件系统,但是当前很多文件创建函数(例如POSIX open函数)都具有设置(或影响)文件访问权限的功能,以是当使用这些函数创建文件时,必须显式指定符合的文件访问权限,以防止意外访问。
【反例】 使用POSIX open()函数创建文件但未显示指定该文件的访问权限,可能会导致文件创建时具有过高的访问权限。这可能会导致毛病(例如CVE-2006-1174)。
  1. void Foo(void)
  2. {
  3.     int fd = -1;
  4.     char *filename = NULL;
  5.     ... // 初始化 filename
  6.     fd = open(filename, O_CREAT|O_WRONLY); // 没有显式指定访问权限
  7.     if (fd == -1) {
  8.         ... // 错误处理
  9.     }
  10.     ...
  11. }
复制代码
【正例】 应该在open的第三个参数中显式指定新创建文件的访问权限。可以根据文件现实的应用情况设置何种访问权限。
  1. void Foo(void)
  2. {
  3.     int fd = -1;
  4.     char *filename = NULL;
  5.     ... // 初始化 filename 和指定其访问权限
  6.     // 此处根据文件实际需要,显式指定其访问权限
  7.     int fd = open(filename, O_CREAT|O_WRONLY, S_IRUSR|S_IWUSR);
  8.     if (fd == -1) {
  9.         ... // 错误处理
  10.     }
  11.     ...
  12. }
复制代码
【影响】
创建访问权限弱的文件,可能会导致对这些文件的非法访问。
使用文件路径前必须进行规范化并校验

【形貌】 当文件路径来自外部数据时,必须对其做合法性校验,假如不校验,可能造成系统文件的被任意访问。但是克制直接对其进行校验,准确做法是在校验之前必须对其进行路径规范化处理。这是因为同一个文件可以通过多种形式的路径来形貌和引用,例如既可以是绝对路径,也可以是相对路径;而且路径名、目录名和文件名可能包含使校验变得困难和不准确的字符(如:“.”、“..”)。此外,文件还可以是符号链接,这进一步暗昧了文件的现实位置或标识,增加了校验的难度和校验准确性。以是必须先将文件路径规范化,从而更容易校验其路径、目录或文件名,增加校验准确性。
因为规范化机制在不同的利用系统和文件系统之间可能有所不同,以是最好使用符合当前系统特性的规范化机制。
一个简单的案例分析如下:
  1. 当文件路径来自外部数据时,需要先将文件路径规范化,如果没有作规范化处理,攻击者就有机会通过恶意构造文件路径进行文件的越权访问。
  2. 例如,攻击者可以构造“../../../etc/passwd”的方式进行任意文件访问。
复制代码
【反例】 在此错误的示例中,inputFilename包含一个源于受污染源的文件名,并且该文件名已打开以进行写入。在使用此文件名利用之前,应该对其进行验证,以确保它引用的是预期的有效文件。 不幸的是,inputFilename引用的文件名可能包含特殊字符,例如目录字符,这使验证变得困难,以致不可能。而且,inputFilename中可能包含可以指向任意文件路径的符号链接,即使该文件名通过了验证,也会导致该文件名是无效的。 这种场景下,对文件名的直接验证即使被执行也是得不到预期的结果,对fopen()的调用可能会导致访问一个意外的文件。
  1. ...
  2. if (!verify_file(inputFilename) {    // 没有对inputFilename做规范化,直接做校验
  3.     ... // 错误处理
  4. }
  5. if (fopen(inputFilename, "w") == NULL) {
  6.     ... // 错误处理
  7. }
  8. ...
复制代码
【正例】 规范化文件名是具有一定难度的,因为这必要了解底层文件系统。 POSIX realpath()函数可以帮助将路径名转换为规范形式。参考信息技术标准-POSIX®,根本规范第7期[IEEE std 1003.1:2013]:


  • 该realpath()函数应从所指向的路径名派生一个filename的绝对路径名,两者指向同一文件,绝对路径其文件名不涉及“ .”,“ ..”或符号链接。 在规范化路径之后,还必须执行进一步的验证,例如确保两个一连的斜杠或特殊文件不会出现在文件名中。有关如何执行路径名解析的更多具体信息,请拜见[IEEE Std 1003.1: 2013]第4.12节“路径名解析”。 使用realpath()函数有很多必要留意的地方。 在了解了以上原理之后,对上面的错误代码示例,我们采取如下解决方案:
  1. char *realpathRes = NULL;
  2. ...
  3. // 在校验之前,先对inputFilename做规范化处理
  4. realpathRes = realpath(inputFilename, NULL);
  5. if (realpathRes == NULL) {
  6.     ... // 规范化的错误处理
  7. }
  8. // 规范化以后对路径进行校验
  9. if (!verify_file(realpathRes) {
  10.     ... // 校验的错误处理
  11. }
  12. // 使用
  13. if (fopen(realpathRes, "w") == NULL) {
  14.     ... // 实际操作的错误处理
  15. }
  16. ...
  17. free(realpathRes);
  18. realpathRes = NULL;
  19. ...
复制代码
【正例】 根据我们的现实场景,我们还可以采取的第二套解决方案,分析如下: 假如PATH_MAX被定义为 limits.h 中的一个常量,那么使用非空的resolved_path调用realpath()也是安全的。 在本例中realpath()函数期望resolved_path引用一个字符数组,该字符数组充足大,可以容纳规范化的路径。 假如定义了PATH_MAX,则分配一个巨细为PATH_MAX的缓冲区来保存realpath()的结果。准确代码示例如下:
  1. char *realpathRes = NULL;
  2. char *canonicalFilename = NULL;
  3. size_t pathSize = 0;
  4. ...
  5. pathSize = (size_t)PATH_MAX;
  6. if (VerifyPathSize(pathSize)) {
  7.     canonicalFilename = (char *)malloc(pathSize);
  8.     if (canonicalFilename == NULL) {
  9.         ... // 错误处理
  10.     }
  11.     realpathRes = realpath(inputFilename, canonicalFilename);
  12. }
  13. if (realpathRes == NULL) {
  14.     ... // 错误处理
  15. }
  16. if (VerifyFile(realpathRes)) {
  17.     ... // 错误处理
  18. }
  19. if (fopen(realpathRes, "w") == NULL ) {
  20.     ... // 错误处理
  21. }
  22. ...
  23. free(canonicalFilename);
  24. canonicalFilename = NULL;
  25. ...
复制代码
【反例】 下面的代码场景是从外部获取到文件名称,拼接成文件路径后,直接对文件内容进行读取,导致攻击者可以读取到任意文件的内容:
  1. char *filename = GetMsgFromRemote();
  2. ...
  3. int ret = sprintf(untrustPath,  "/tmp/%s", filename);
  4. ...
  5. char *text = ReadFileContent(untrustPath);
复制代码
【正例】 准确的做法是,对路径进行规范化后,再判断路径是否是本程序所认为的合法的路径:
  1. char *filename = GetMsgFromRemote();
  2. ...
  3. sprintf(untrustPath,  "/tmp/%s", filename);
  4. char path[PATH_MAX];
  5. if (realpath(untrustPath, path) == NULL) {
  6.     ... // 处理错误
  7. }
  8. if (!IsValidPath(path)) {    // 检查文件的位置是否正确
  9.     ... // 处理错误
  10. }
  11. char *text = ReadFileContent(path);
复制代码
【例外】
运行于控制台的命令行程序,通过控制台手工输入文件路径,可以作为本条款例外。
  1. int main(int argc, char **argv)
  2. {
  3.     int fd = -1;
  4.     if (argc == 2) {
  5.         fd = open(argv[1], O_RDONLY);
  6.         ...
  7.     }
  8.     ...
  9.     return 0;
  10. }
复制代码
【影响】
未对不可信的文件路径进行规范化和校验,可能造成对任意文件的访问。
不要在共享目录中创建暂时文件

【形貌】 共享目录是指其它非特权用户可以访问的目录。程序的暂时文件应当是程序自身独享的,任何将自身暂时文件置于共享目录的做法,将导致其他共享用户得到该程序的额外信息,产生信息泄露。因此,不要在任何共享目录创建仅由程序自身使用的暂时文件。
暂时文件通常用于辅助保存不能驻留在内存中的数据或存储暂时的数据,也可用作进程间通讯的一种手段(通过文件系统传输数据)。例如,一个进程在共享目录中创建一个暂时文件,该文件名可能使用了众所周知的名称或者一个暂时的名称,然后就可以通过该文件在进程间共享信息。这种通过在共享目录中创建暂时文件的方法实现进程间共享的做法很危险,因为共享目录中的这些文件很容易被攻击者挟制或利用。这里有几种缓解策略:

  • 使用其他低级IPC(进程间通讯)机制,例如套接字或共享内存。
  • 使用更高级别的IPC机制,例如远程过程调用。
  • 使用仅能由程序本身访问的安全目录(多线程/进程下留意防止条件竞争)。
同时,下面列出了几项暂时文件创建使用的方法,产品根据具体场景执行以下一项或者几项,同时产品也可以自定义符合的方法。

  • 文件必须具有符合的权限,只有符合权限的用户才能访问
  • 创建的文件名是唯一的、或不可预测的
  • 仅当文件不存在时才创建打开(原子创建打开)
  • 使用独占访问打开,避免竞争条件
  • 在程序退出之前移除
同时也必要留意到,当某个目录被开放读/写权限给多个用户或者一组用户时,该共享目录潜伏的安全风险远宏大于访问该目录中暂时文件这个功能的本身。
在共享目录中创建暂时文件很容易受到威胁。例如,用于本地挂载的文件系统的代码在与远程挂载的文件系统一起共享使用时可能会受到攻击。安全的解决方案是不要在共享目录中创建暂时文件。
【反例】 如下代码示例,程序在系统的共享目录/tmp下创建暂时文件来保存暂时数据,且文件名是硬编码的。 由于文件名是硬编码的,因此是可预测的,攻击者只需用符号链接替换文件,然后链接所引用的目的文件就会被打开并写入新内容。
  1. void ProcData(const char *filename)
  2. {
  3.     FILE *fp = fopen(filename, "wb+");
  4.     if (fp == NULL) {
  5.         ... // 错误处理
  6.     }
  7.     ... // 写文件
  8.     fclose(fp);
  9. }
  10. int main(void)
  11. {
  12.     // 不符合:1.在系统共享目录中创建临时文件;2.临时文件名硬编码
  13.     char *pFile = "/tmp/data";
  14.     ...
  15.     ProcData(pFile);
  16.     ...
  17.     return 0;
  18. }
复制代码
【准确案例】
  1. 不应在该目录下创建仅由程序自身使用的临时文件。
复制代码
【影响】
不安全的创建暂时文件,可能导致文件非法访问,并造成本地系统上的权限提拔。
不要在信号处理函数中访问共享对象

【形貌】 假如在信号处理程序中访问和修改共享对象,可能会造成竞争条件,使数据处于不确定的状态。 这条规则有两个不实用的场景(参考C11标准5.1.2.3第5段):


  • 读写不必要加锁的原子对象;
  • 读写volatile sig_atomic_t范例的对象,因为具有volatile sig_atomic_t范例的对象即使在出现异步中断的时候也可以作为一个原子实体访问,是异步安全的。
【反例】 在这个信号处理过程中,程序打算将g_msg作为共享对象,当产生SIGINT信号时更新共享对象的内容,但是该g_msg变量范例不是volatile sig_atomic_t,以是不是异步安全的。
  1. #define MAX_MSG_SIZE 32
  2. static char g_msgBuf[MAX_MSG_SIZE] = {0};
  3. static char *g_msg = g_msgBuf;
  4. void SignalHandler(int signum)
  5. {
  6.     // 下面代码操作g_msg不合规,因为不是异步安全的
  7.     (void)memset(g_msg,0, MAX_MSG_SIZE);
  8.     errno_t ret = strcpy(g_msg,  "signal SIGINT received.");
  9.     ... // 处理 ret
  10. }
  11. int main(void)
  12. {
  13.     errno_t ret = strcpy(g_msg,  "No msg yet."); // 初始化消息内容
  14.     ... // 处理 ret
  15.     signal(SIGINT, SignalHandler); // 设置SIGINT信号对应的处理函数
  16.     ... // 程序主循环代码
  17.     return 0;
  18. }
复制代码
【正例】 如下代码示例中,在信号处理函数中仅将volatile sig_atomic_t范例作为共享对象使用。
  1. #define MAX_MSG_SIZE 32
  2. volatile sig_atomic_t g_sigFlag = 0;
  3. void SignalHandler(int signum)
  4. {
  5.     g_sigFlag = 1; // 符合
  6. }
  7. int main(void)
  8. {
  9.     signal(SIGINT, SignalHandler);
  10.     char msgBuf[MAX_MSG_SIZE];
  11.     errno_t ret = strcpy(msgBuf, "No msg yet."); // 初始化消息内容
  12.     ... // 处理 ret
  13.     ... // 程序主循环代码
  14.     if (g_sigFlag == 1) {  // 在退出主循环之后,根据g_sigFlag状态再刷新消息内容
  15.         ret = strcpy(msgBuf,  "signal SIGINT received.");
  16.         ... // 处理 ret
  17.     }
  18.     return 0;
  19. }
复制代码
【影响】
在信号处理程序中访问或修改共享对象,可能造成以不一致的状态访问数据。
禁用rand函数产生用于安全用途的伪随机数

【形貌】 C语言标准库rand()函数生成的是伪随机数,以是不能保证其产生的随机数序列质量。根据C11标准,rand()函数产生的随机数范围是[0, RAND_MAX(0x7FFF)],因为范围相对较短,以是这些数字可以被预测。 以是克制使用rand()函数产生的随机数用于安全用途,必须使用安全的随机数产生方式。
典范的安全用途场景包罗(但不限于)以下几种:


  • 会话标识SessionID的生成;
  • 挑战算法中的随机数生成;
  • 验证码的随机数生成;
  • 用于暗码算法用途(例如用于生成IV、盐值、密钥等)的随机数生成。
【反例】 程序员期望生成一个唯一的不可被推测的HTTP会话ID,但该ID是通过调用rand()函数产生的数字随机数,它的ID是可推测的,并且随机性有限。
【影响】
使用rand()函数可能造成可预测的随机数。
克制在发布版本中输出对象或函数的地点

【形貌】 克制在发布版本中输出对象或函数的地点,如:将变量或函数的地点输出到客户端、日志、串口中。
当攻击者实施高级攻击时,通常必要先获取目的程序中的内存地点(如变量地点、函数地点等),再通过修改指定内存的内容,达到攻击目的。 假如程序中主动输出对象或函数的地点,则为攻击者提供了便利条件,可以根据这些地点以及偏移量盘算出其他对象或函数的地点,并实施攻击。 另外,由于内存地点泄露,也会造成地点空间随机化的掩护功能失效。
【反例】 如下代码中,使用%p格式将指针指向的地点记录到日志中。
  1. int Encode(unsigned char *in, size_t inSize, unsigned char *out, size_t maxSize)
  2. {
  3.     ...
  4.     Log("in=%p, in size=%zu, out=%p, max size=%zu\n", in, inSize, out, maxSize);
  5.     ...
  6. }
复制代码
备注:这里仅用%p打印指针作为示例,代码中将指针转换为整数再打印也存在同样的风险。
【正例】 如下代码中,删除打印地点的代码。
  1. int Encode(unsigned char *in, size_t inSize, unsigned char *out, size_t maxSize)
  2. {
  3.     ...
  4.     Log("in size=%zu, max size=%zu\n", inSize, maxSize);
  5.     ...
  6. }
复制代码
【例外】 当程序瓦解退出时,在记录瓦解的非常信息中可以输出内存地点等信息。
【影响】
内存地点信息泄露,为攻击者实施攻击提供有利信息,可能造成地点空间随机化防护失效。
克制代码中包含公网地点

【形貌】
代码或脚本中包含用户不可见,不可知的公网地点,可能会引起客户质疑。
对产品发布的软件(包含软件包/补丁包)中包含的公网地点(包罗公网IP地点、公网URL地点/域名、邮箱地点)要求如下: 1、克制包含用户界面不可见、或产品资料未形貌的未公开的公网地点。 2、已公开的公网地点克制写在代码或者脚本中,可以存储在配置文件或数据库中。
对于开源/第三方软件自带的公网地点必须至少满意上述第1条公开性要求。
【例外】


  • 对于标准协议中必须指定公网地点的场景可例外,如soap协议中函数的定名空间必须指定的一个组装的公网URL、http页面中包含w3.org网址等。
内核安全编程

内核mmap接口实现中,确保对映射起始地点和巨细进行合法性校验

【形貌】
分析:内核 mmap接口中,常常使用remap_pfn_range()函数将装备物理内存映射到用户进程空间。假如映射起始地点等参数由用户态控制并缺少合法性校验,将导致用户态可通过映射读写任意内核地点。假如攻击者经心构造传入参数,以致可在内核中执行任意代码。
【错误代码示例】
如下代码在使用remap_pfn_range()进行内存映射时,未对用户可控的映射起始地点和空间巨细进行合法性校验,可导致内核瓦解或任意代码执行。
  1. static int incorrect_mmap(struct file *file, struct vm_area_struct *vma)
  2. {
  3.         unsigned long size;
  4.         size = vma->vm_end - vma->vm_start;
  5.         vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot);
  6.         //错误:未对映射起始地址、空间大小做合法性校验
  7.         if (remap_pfn_range(vma, vma->vm_start, vma->vm_pgoff, size, vma->vm_page_prot)) {
  8.                 err_log("%s, remap_pfn_range fail", __func__);
  9.                 return EFAULT;
  10.         } else {
  11.                 vma->vm_flags &=  ~VM_IO;
  12.         }
  13.         return EOK;
  14. }
复制代码
【准确代码示例】
增加对映射起始地点等参数的合法性校验。
  1. static int correct_mmap(struct file *file, struct vm_area_struct *vma)
  2. {
  3.         unsigned long size;
  4.         size = vma->vm_end - vma->vm_start;
  5.         //修改:添加校验函数,验证映射起始地址、空间大小是否合法
  6.         if (!valid_mmap_phys_addr_range(vma->vm_pgoff, size)) {
  7.                 return EINVAL;
  8.         }
  9.         vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot);
  10.         if (remap_pfn_range(vma, vma->vm_start, vma->vm_pgoff, size, vma->vm_page_prot)) {
  11.                 err_log( "%s, remap_pfn_range fail ", __func__);
  12.                 return EFAULT;
  13.         } else {
  14.                 vma->vm_flags &=  ~VM_IO;
  15.         }
  16.         return EOK;
  17. }
复制代码
内核程序中必须使用内核专用函数读写用户态缓冲区

【形貌】
用户态与内核态之间进行数据互换时,假如在内核中不加任何校验(如校验地点范围、空指针)而直接引用用户态传入指针,当用户态传入非法指针时,可导致内核瓦解、任意地点读写等题目。因此,应当克制使用memcpy()、sprintf()等危险函数,而是使用内核提供的专用函数:copy_from_user()、copy_to_user()、put_user()和get_user()来读写用户态缓冲区,这些函数内部添加了入参校验功能。
所有禁用函数列表为:memcpy()、bcopy()、memmove()、strcpy()、strncpy()、strcat()、strncat()、sprintf()、vsprintf()、snprintf()、vsnprintf()、sscanf()、vsscanf()。
【错误代码示例】
内核态直接使用用户态传入的buf指针作为snprintf()的参数,当buf为NULL时,可导致内核瓦解。
  1. ssize_t incorrect_show(struct file *file, char__user *buf, size_t size, loff_t *data)
  2. {
  3.         // 错误:直接引用用户态传入指针,如果buf为NULL,则空指针异常导致内核崩溃
  4.         return snprintf(buf, size, "%ld\n", debug_level);
  5. }
复制代码
【准确代码示例】
使用copy_to_user()函数取代snprintf()。
  1. ssize_t correct_show(struct file *file, char __user *buf, size_t size, loff_t *data)
  2. {
  3.         int ret = 0;
  4.         char level_str[MAX_STR_LEN] = {0};
  5.         snprintf(level_str, MAX_STR_LEN, "%ld \n", debug_level);
  6.         if(strlen(level_str) >= size) {
  7.                 return EFAULT;
  8.         }
  9.        
  10.         // 修改:使用专用函数copy_to_user()将数据写入到用户态buf,并注意防止缓冲区溢出
  11.         ret = copy_to_user(buf, level_str, strlen(level_str)+1);
  12.         return ret;
  13. }
复制代码
【错误代码示例】
内核态直接使用用户态传入的指针user_buf作为数据源进行memcpy()利用,当user_buf为NULL时,可导致内核瓦解。
  1. size_t incorrect_write(struct file  *file, const char __user  *user_buf, size_t count, loff_t  *ppos)
  2. {
  3.         ...
  4.         char buf [128] = {0};
  5.         int buf_size = 0;
  6.         buf_size = min(count, (sizeof(buf)-1));
  7.         // 错误:直接引用用户态传入指针,如果user_buf为NULL,则可导致内核崩溃
  8.         (void)memcpy(buf, user_buf, buf_size);
  9.         ...
  10. }
复制代码
【准确代码示例】
使用copy_from_user()函数取代memcpy()。
  1. ssize_t correct_write(struct file *file, const char __user *user_buf, size_t count, loff_t *ppos)
  2. {
  3.         ...
  4.         char buf[128] = {0};
  5.         int buf_size = 0;
  6.         buf_size = min(count, (sizeof(buf)-1));
  7.         // 修改:使用专用函数copy_from_user()将数据写入到内核态buf,并注意防止缓冲区溢出
  8.         if (copy_from_user(buf, user_buf, buf_size)) {
  9.                 return EFAULT;
  10.         }
  11.         ...
  12. }
复制代码
必须对copy_from_user()拷贝长度进行校验,防止缓冲区溢出

分析:内核态从用户态拷贝数据时通常使用copy_from_user()函数,假如未对拷贝长度做校验或者校验不妥,会造成内核缓冲区溢出,导致内核panic或提权。
【错误代码示例】
未校验拷贝长度。
  1. static long gser_ioctl(struct file  *fp, unsigned cmd, unsigned long arg)
  2. {
  3.         char smd_write_buf[GSERIAL_BUF_LEN];
  4.         switch (cmd)
  5.         {
  6.                 case GSERIAL_SMD_WRITE:
  7.                         if (copy_from_user(&smd_write_arg, argp, sizeof(smd_write_arg))) {...}
  8.                         // 错误:拷贝长度参数smd_write_arg.size由用户输入,未校验
  9.                         copy_from_user(smd_write_buf, smd_write_arg.buf, smd_write_arg.size);
  10.                         ...
  11.         }
  12. }
复制代码
【准确代码示例】
添加长度校验。
  1. static long gser_ioctl(struct file *fp, unsigned cmd, unsigned long arg)
  2. {
  3.         char smd_write_buf[GSERIAL_BUF_LEN];
  4.         switch (cmd)
  5.         {
  6.                 case GSERIAL_SMD_WRITE:
  7.                         if (copy_from_user(&smd_write_arg, argp, sizeof(smd_write_arg))){...}
  8.                         // 修改:添加校验
  9.                         if (smd_write_arg.size  >= GSERIAL_BUF_LEN) {......}
  10.                         copy_from_user(smd_write_buf, smd_write_arg.buf, smd_write_arg.size);
  11.                         ...
  12.         }
  13. }
复制代码
必须对copy_to_user()拷贝的数据进行初始化,防止信息泄漏

【形貌】
分析:内核态使用copy_to_user()向用户态拷贝数据时,当数据未完全初始化(如结构体成员未赋值、字节对齐引起的内存空洞等),会导致栈上指针等敏感信息泄漏。攻击者可利用绕过kaslr等安全机制。
【错误代码示例】
未完全初始化数据结构成员。
  1. static long rmnet_ctrl_ioctl(struct file *fp, unsigned cmd, unsigned long arg)
  2. {
  3.         struct ep_info info;
  4.         switch (cmd) {
  5.                 case FRMNET_CTRL_EP_LOOKUP:
  6.                         info.ph_ep_info.ep_type = DATA_EP_TYPE_HSUSB;
  7.                         info.ipa_ep_pair.cons_pipe_num = port->ipa_cons_idx;
  8.                         info.ipa_ep_pair.prod_pipe_num = port->ipa_prod_idx;
  9.                         // 错误: info结构体有4个成员,未全部赋值
  10.                         ret = copy_to_user((void __user *)arg, &info, sizeof(info));
  11.                         ...
  12.         }
  13. }
复制代码
【准确代码示例】
全部进行初始化。
  1. static long rmnet_ctrl_ioctl(struct file *fp, unsigned cmd, unsigned long arg)
  2. {
  3.         struct ep_info info;
  4.         // 修改:使用memset初始化缓冲区,保证不存在因字节对齐或未赋值导致的内存空洞
  5.         (void)memset(&info, '0', sizeof(ep_info));
  6.         switch (cmd) {
  7.                 case FRMNET_CTRL_EP_LOOKUP:
  8.                         info.ph_ep_info.ep_type = DATA_EP_TYPE_HSUSB;
  9.                         info.ipa_ep_pair.cons_pipe_num = port->ipa_cons_idx;
  10.                         info.ipa_ep_pair.prod_pipe_num = port->ipa_prod_idx;
  11.                         ret = copy_to_user((void __user *)arg, &info, sizeof(info));
  12.                         ...
  13.         }
  14. }
复制代码
克制在非常处理中使用BUG_ON宏,避免造成内核panic

【形貌】
BUG_ON宏会调用内核的panic()函数,打印错误信息并主动瓦解系统,在正常逻辑处理中(如ioctl接口的cmd参数不辨认)不应当使系统瓦解,克制在此类非常处理场景中使用BUG_ON宏,推荐使用WARN_ON宏。
【错误代码示例】
正常流程中使用了BUG_ON宏
  1. / * 判断Q6侧设置定时器是否繁忙,1-忙,0-不忙 */
  2. static unsigned int is_modem_set_timer_busy(special_timer *smem_ptr)
  3. {
  4.         int i = 0;
  5.         if (smem_ptr == NULL) {
  6.                 printk(KERN_EMERG"%s:smem_ptr NULL!\n", __FUNCTION__);
  7.                 // 错误:系统BUG_ON宏打印调用栈后调用panic(),导致内核拒绝服务,不应在正常流程中使用
  8.                 BUG_ON(1);
  9.                 return 1;
  10.         }
  11.         ...
  12. }
复制代码
【准确代码示例】
去掉BUG_ON宏。
  1. / * 判断Q6侧设置定时器是否繁忙,1-忙,0-不忙  */
  2. static unsigned int is_modem_set_timer_busy(special_timer *smem_ptr)
  3. {
  4.         int i = 0;
  5.         if (smem_ptr == NULL) {
  6.                 printk(KERN_EMERG"%s:smem_ptr NULL!\n",  __FUNCTION__);
  7.                 // 修改:去掉BUG_ON调用,或使用WARN_ON
  8.                 return 1;
  9.         }
  10.         ...
  11. }
复制代码
在中断处理程序或持有自旋锁的进程上下文代码中,克制使用会引起进程休眠的函数

【形貌】
系统以进程为调理单元,在中断上下文中,只有更高优先级的中断才能将其打断,系统在中断处理的时候不能进行进程调理。假如中断处理程序处于休眠状态,就会导致内核无法叫醒,从而使得内核处于瘫痪。
自旋锁在使用时,抢占是失效的。若自旋锁在锁住以后进入睡眠,由于不能进行处理器抢占,其它进程都将因为不能得到CPU(单核CPU)而制止运行,对外表现为系统将不作任何响应,出现挂死。
因此,在中断处理程序或持有自旋锁的进程上下文代码中,应该克制使用可能会引起休眠(如vmalloc()、msleep()等)、壅闭(如copy_from_user(),copy_to_user()等)或者耗费大量时间(如printk()等)的函数。
合理使用内核栈,防止内核栈溢出

【形貌】
内核栈巨细是固定的(一般32位系统为8K,64位系统为16K,因此资源非常宝贵。不合理的使用内核栈,可能会导致栈溢出,造成系统挂死。因此必要做到以下几点:


  • 在栈上申请内存空间不要高出内核栈巨细;
  • 留意函数的嵌套使用次数;
  • 不要定义过多的变量。
【错误代码示例】
以下代码中定义的变量过大,导致栈溢出。
  1. ...
  2. struct result
  3. {
  4.         char name[4];
  5.         unsigned int a;
  6.         unsigned int b;
  7.         unsigned int c;
  8.         unsigned int d;
  9. }; // 结构体result的大小为20字节
  10. int foo()
  11. {
  12.         struct result temp[512];
  13.         // 错误: temp数组含有512个元素,总大小为10K,远超内核栈大小
  14.         (void)memset(temp, 0, sizeof(result) * 512);
  15.         ... // use temp do something
  16.         return 0;
  17. }
  18. ...
复制代码
代码中数组temp有512个元素,总共10K巨细,远超内核的8K,显着的栈溢出。
【准确代码示例】
使用kmalloc()取代之。
  1. ...
  2. struct result
  3. {
  4.         char name[4];
  5.         unsigned int a;
  6.         unsigned int b;
  7.         unsigned int c;
  8.         unsigned int d;
  9. }; // 结构体result的大小为20字节
  10. int foo()
  11. {
  12.         struct result  *temp = NULL;
  13.         temp = (result *)kmalloc(sizeof(result) * 512, GFP_KERNEL); //修改:使用kmalloc()申请内存
  14.         ... // check temp is not NULL
  15.         (void)memset(temp, 0, sizeof(result)  * 512);
  16.         ... // use temp do something
  17.         ... // free temp
  18.         return 0;
  19. }
  20. ...
复制代码
暂时关闭地点校验机制后,在利用完成后必须及时规复

【形貌】
SMEP安全机制是指克制内核执行用户空间的代码(PXN是ARM版本的SMEP)。系统调用(如open(),write()等)本来是提供给用户空间程序访问的。默认情况下,这些函数会对传入的参数地点进行校验,假如入参是非用户空间地点则报错。因此,要在内核程序中使用这些系统调用,就必须使参数地点校验功能失效。set_fs()/get_fs()就用来解决该题目。具体分析见如下代码:
  1. ...
  2. mmegment_t old_fs;
  3. printk("Hello, I'm the module that intends to write message to file.\n");
  4. if (file == NULL) {
  5.         file = filp_open(MY_FILE, O_RDWR|O_APPEND|O_CREAT, 0664);
  6. }
  7. if (IS_ERR(file)) {
  8.         printk("Error occurred while opening file %s, exiting ...\n", MY_FILE);
  9.         return 0;
  10. }
  11. sprintf(buf, "%s", "The Message.");
  12. old_fs = get_fs(); // get_fs()的作用是获取用户空间地址上限值  
  13.                    // #define get_fs() (current->addr_limit
  14. set_fs(KERNEL_DS); // set_fs的作用是将地址空间上限扩大到KERNEL_DS,这样内核代码可以调用系统函数
  15. file->f_op->write(file, (char *)buf, sizeof(buf), &file->f_pos); // 内核代码可以调用write()函数
  16. set_fs(old_fs); // 使用完后及时恢复原来用户空间地址限制值
  17. ...
复制代码
通过上述代码,可以了解到最为关键的就是利用完成后,要及时规复地点校验功能。否则SMEP/PXN安全机制就会失效,使得很多毛病的利用变得很容易。
【错误代码示例】
在程序错误处理分支,未通过set_fs()规复地点校验功能。
  1. ...
  2. oldfs = get_fs();
  3. set_fs(KERNEL_DS);
  4. /* 在时间戳目录下面创建done文件 */
  5. fd = sys_open(path, O_CREAT|O_WRONLY, FILE_LIMIT);
  6. if (fd < 0) {
  7.         BB_PRINT_ERR("sys_mkdir[%s] error, fd is[%d]\n", path, fd);
  8.         return; // 错误:在错误处理程序分支未恢复地址校验机制
  9. }
  10. sys_close(fd);
  11. set_fs(oldfs);
  12. ...
复制代码
【准确代码示例】
在错误处理程序中规复地点校验功能。
  1. ...
  2. oldfs = get_fs();
  3. set_fs(KERNEL_DS);
  4. /* 在时间戳目录下面创建done文件 */
  5. fd = sys_open(path, O_CREAT|O_WRONLY, FILE_LIMIT);
  6. if (fd < 0) {
  7.         BB_PRINT_ERR("sys_mkdir[%s] error, fd is[%d] \n", path, fd);
  8.         set_fs(oldfs); // 修改:在错误处理程序分支中恢复地址校验机制
  9.         return;
  10. }
  11. sys_close(fd);
  12. set_fs(oldfs);
  13. ...
复制代码
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

伤心客

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表