7 文件输入/输出安全
规则7.1:必须使用int类型来接收字符输入/输出函数的返回值
说明:字符输入/输出函数fgetc()、getc()和getchar()都从一个流读取一个字符,并把它以int值的形式返回。如果这个流到达了文件尾或者发生读取错误,函数返回EOF。fputc()、putc()、putchar()和ungetc()也返回一个字符或EOF。
如果这些I/O函数的返回值需要与EOF进行比较,不要将返回值转换为char类型。因为char是有符号8位的值,int是32位的值。如果getchar()返回的字符的ASCII值为0xFF,转换为char类型后将被解释为EOF。0xFF这个值被有符号扩展后是0xFFFFFFFF,刚好等于EOF的值。
错误示例:下列代码使用char类型来接收字符I/O的返回值,可能会导致返回值错误。
void Noncompliant()
{
char buf[BUFSIZ];
char c; //【错误】不要使用char类型来接收字符I/O的返回值
int i = 0;
while ((c = getchar()) != '\n' && c != EOF)
{
if (i < BUFSIZ-1)
{
buf[++i] = c;
}
}
buf[i] = ’\0’; /* terminate NTBS */
}
推荐做法:
void Compliant ()
{
char buf[BUFSIZ];
int c; //【修改】使用int类型来接收字符I/O的返回值
int i = 0;
while (((c = getchar()) != '\n') && c != EOF) /*【修改】int类型才能接收到EOF */
{
if (i < BUFSIZ-1)
{
buf[++i] = c;
}
}
buf[i] = ’\0’; /* terminate NTBS */
}
注意:对于sizeof(int) == sizeof(char)的平台,用int接收返回值也可能无法与EOF区分,这时要用feof()和ferror()检测文件尾和文件错误。
规则7.2:创建文件时必须显式指定合适的文件访问权限
说明:创建文件时,如果不显式指定合适访问权限,可能会让未经授权的用户访问该文件。访问权限依赖于文件系统,但一般文件系统都会提供控制访问权限的功能。
错误示例:下列代码没有显式配置文件的访问权限。
void Noncompliant()
{
char *file_name;
int fd;
/* initialize file_name */
fd = open(file_name, O_CREAT | O_WRONLY);
/* access permissions were missing */
if (fd == -1)
{
/* Handle error */
}
}
推荐做法:
void Compliant()
{
char *file_name;
int file_access_permissions = S_IRUSR|S_IWUSR;
/* initialize file_name and file_access_permissions */
int fd = open(
file_name,
O_CREAT | O_WRONLY,
file_access_permissions //【修改】显式配置访问权限。
);
if (fd == -1)
{
/* Handle error */
}
}
规则7.3:文件路径验证前,必须对其进行标准化
说明:当文件路径来自非信任域时,需要先将文件路径规范化再做校验。路径在验证时会有很多干扰因素,如相对路径与绝对路径,如文件的符号链接、硬链接、快捷路径、别名等。
所以在验证路径时需要对路径进行标准化,使得路径表达唯一化、无歧义。
如果没有作标准化处理,攻击者就有机会:
(1)构造一个跨越目录限制的文件路径,例如“../../../etc/passwd”或“../../../boot.ini”
(2)构造指向系统关键文件的链接文件,例如symlink("/etc/shadow","/tmp/log")
通过上述两种方式之一可以实现读取或修改系统重要数据文件,威胁系统安全。
推荐做法:
Linux下对文件进行标准化,可以防止黑客通过构造指向系统关键文件的链接文件。realpath() 函数返回绝对路径,删除了所有符号链接:
void Compliant(char *lpInputPath)
{
char realpath[MAX_PATH];
if ( realpath(inputPath, realpath) == NULL)
/* handle error */;
/*... do something ...*/
}
Windows下可以使用PathCanonicalize函数对文件路径进行标准化:
void Compliant(char *lpInputPath)
{
char realpath[MAX_PATH];
char *lpRealPath = realpath;
if ( PathCanonicalize(lpRealPath,lpInputPath) == NULL)
/* handle error */;
/*... do something ...*/
}
延伸阅读材料:CVE-2012-5335披露了开源HTTP服务器Tiny Server存在目录遍历漏洞,可以允许攻击者通过提交恶意URL(如http://192.168.1.2/../../windows/system.ini)来查看HTTP Server的任意物理目录。
建议7.1:访问文件时尽量使用文件描述符代替文件名作为输 入,以避免竞争条件问题
说明:该建议应用场景如下,当对文件的元信息进行操作时(比如修改它的所有者、对文件进行统计,或者修改它的权限位),首先要打开该文件,然后对打开的文件进行操作。只要有可能,应尽量避免使用获取文件名的操作,而是使用获取文件描述符的操作。这样做将避免文件在程序运行时被替换(一种可能的竞争条件)。
例如,当access()和open()两者都利用一个字符串参数而不是一个文件句柄来进行相关操作时,攻击者就可以通过在access()和open()之间的间隙替换掉原来的文件,如下所示:
行式打印 攻击者
access(”/tmp/attack”)
unlink(”/tmp/attack”)
symlink(”/etc/shadow”, ”/tmp/attack”)
open(”/tmp/attack”)
错误示例:下列代码使用access()函数,可能引发竞争条件问题。
void Noncompliant(char * file)
{
if(!access(file, W_OK)) //【不推荐】不要使用函数access(),易引发条件竞争
{
f = fopen(file, "w+");
/*...*/
/* close f after operate(f)*/
}
else
{
fprintf(stderr, "Unable to open file %s.\n", file);
}
}
8 STL库安全
STL(Standard Template Library标准模板库)是C++开发中的常用组件,它通过容器+迭代子+算法的灵活组合,可以满足很多应用场景,但使用不好则易留下安全隐患。
规则8.1:引用容器前后元素时要确保容器元素存在
说明:没有判断是否为空就直接通过引用STL容器首尾元素,这在容器为空时会导致程序异常。
错误示例:
bool NoCompliant(const NodeKeyList &srcList, const NodeKeyList &snkList)
{
NodeKey srcNode = srcList.front();
NodeKey snkNode = snkList.back();
/* ...do something... */
}
示例代码对函数的入参srcList没有判断长度直接通过front()和back()方法取了第一个和最后一个元素,在容器列表为空的情况下,会导致程序异常,与front()类似的还有通过begin(),数据下标获取对应元素,比如*srcList.begin(),或者srcList.begin()->GetID(),或者是srcList[0](在为vector时)。
推荐做法:
bool Compliant(const NodeKeyList &srcList, const NodeKeyList &snkList)
{
if (srcList.empty() || snkList.empty()) //【修改】确保STL容器内有元素存在
{
return false;
}
NodeKey srcNode = srcList.front();
NodeKey snkNode = snkList.back();
/* ...do something... */
}
规则8.2:迭代子使用前必须保证迭代子有效
说明:STL算法std::find()、std::find_if()和std::set::find()等有可能返回容器的end()位置,迭代子定义时可以不初始化,或者初始化指向容find()等方法返回的位置,与指针类似地,若未判断迭代子有效性,直接引用迭代子有可能导致程序崩溃。
错误示例:
void STLIterTest::IterReference_NoCompliant(int CmdCode, MAP_GENKEY_VALUE& allResult)
{
TEGenKey tmpKey;
tmpKey.attrID = DWDMTL1_ATTRPORT_CLIENTPRO;
tmpKey.objectID = it->first.objectID;
MAP_GENKEY_VALUE::iterator iter = allResult.find( tmpKey );
if ("FC100" != iter->second.sValue) //非FC100设置为无效
{
it->second.access = TEGenVar::ACCESS_INVALID;
}
}
示例代码通过map的find函数返回的迭代子iter,if语句直接通过iter->second来来引用变量,如果迭代子iter指向为allResult的end()位置,则程序会崩溃。
推荐做法:
void STLIterTest::IterReference_Compliant(int CmdCode, MAP_GENKEY_VALUE& allResult)
{
TEGenKey tmpKey;
tmpKey.attrID = DWDMTL1_ATTRPORT_CLIENTPRO;
tmpKey.objectID = it->first.objectID;
MAP_GENKEY_VALUE::iterator iter = allResult.find(tmpKey);
if (iter != allResult.end()) //【修改】确保迭代子有效后再进行操作
{
if ( "FC100" != iter->second.sValue)
{
it->second.access = TEGenVar::ACCESS_INVALID;
}
}
}
规则8.3:必须确保迭代子指向的内容有效
说明:在理解上迭代子可以视为C指针,迭代子只有在指向了容器中一个存在的对象时,访问才是安全有效的,其他情况的访问都可能存在风险。典型问题:
对连续内存容器来说(如std::vector)会分配一块固定内存来保存连续对象,在插入新元素后(成员函数包括:reserve(),resize(), push_back(),insert()等),可能会引起容器重新分配内存和数据迁移,如果在插入元素之前使用迭代子保存了迭代子位置,那么插入新元素之后,前面保存的迭代子就可能是无效的。
错误示例1:下列代码的迭代子在操作过程中失效。
void NoCompliant()
{
deque<double> d;
double data[5] = { 2.3, 3.7, 1.4, 0.8, 9.6 };
deque<double>::iterator pos = d.begin();
for (size_t i = 0; i < 5; ++i)
{
d.insert(pos++, data[i] + 41); //【错误】insert操作后,pos已失效
}
}
Insert操作后,迭代子pos已经失效,执行自增操作导致异常。
推荐做法:
void Compliant()
{
double data[5] = { 2.3, 3.7, 1.4, 0.8, 9.6 };
deque<double> d;
deque<double>::iterator pos = d.begin();
for (size_t i = 0; i < 5; ++i)
{
pos = d.insert(pos, data[i] + 41); /*【修改】通过返回值获得新的有效的迭代子 */
++pos;
}
}
std::remove和std::remove_if仅会将被删除元素后移并返回应该被删除元素位置的迭代子,并没有正在从容器中删除对象,需要另配合erase函数才能删除,所以一般建议配合一起使用。
错误示例2:下列代码中错误的仅使用remove()函数来删除容器中元素。
void NoCompliant()
{
vector<int> container;
int value = 42;
iterator end = remove( container.begin(), container.end(), value);
for (iterator i = container.begin(); i != container.end(); ++i)
{
cout << "Container element: " << *i << endl;
}
}
remove() 删除任一个成员后返回值将指向任一个成员,值将不可预知。所以被删除后需要立即调用 erase()抹去,防止不可预知的数据访问。
推荐做法:
void Compliant()
{
vector<int> container;
int value = 42;
container.erase(remove(container.begin(),container.end(),value), container.end()); /*【修改】remove删除成员后立即调用erase,确保迭代子指向的内容是有效的*/
for (iterator i = container.begin(); i != container.end(); ++i)
{
cout << "Container element: " << *i << endl;
}
}
规则8.4:正确处理容器的erase()方法与迭代子的关系
说明:调用容器的erase(iter)方法后,迭代子指向的对象被析构,迭代子已经失效,如果再对迭代子执行递增递减或者引用操作会导致程序崩溃。
错误示例:下列代码中的迭代子在执行删除操作过程中已失效。
void STLIterTest::IterVisitContainer_NoCompliant()
{
std::map<oid,NE>::iterator it = m_mapID2NE.begin();
for (; it != m_mapID2NE.end(); )
{
if (pNEInfo->GetNEState(ulNEID) == NESTATE_LOGIN)
{
m_mapID2NE.erase(iter);
iter++; //【错误】erase后,iter指向的对象可能已失效
}
else {++iter;}
}
}
推荐做法:将迭代子后置递增作为erase()的参数。
void STLIterTest::IterVisitContainer_Compliant()
{
std::map<oid,NE>::iterator it = m_mapID2NE.begin();
for (; it != m_mapID2NE.end(); )
{
if ( pNEInfo->GetNEState(ulNEID) == NESTATE_LOGIN)
{
m_mapID2NE.erase(iter++); //【修改】将迭代子后置递增作为erase参数
}
else {++iter;}
}
}
也可以使用earse方法的返回值来保存迭代子,因为返回的是被删除元素迭代子指向的下一个元素位置:
iter = erase(iter)。
注意这种用法可以用于list和vector的erase(),但不适用于map。因为std::map::erase()的返回值在不同STL实现版本是有差异的,有的有返回值,有的没有返回值,所以对map只能使用推荐做法。
9 C++类和对象安全
规则9.1:禁止切分多态的类对象
说明:当一个基类有继承类时,禁止从继承类对象到基类对象实例的拷贝,也不能在多个继承类的对象之间相互拷贝,这样会导致信息的丢失,程序运行异常,从而引发DOS(denial-of-service)。
错误示例:下列代码中切分了类对象,会导致数据丢失。
class Employee {
public:
Employee(string theName) : name(theName){};
string getName() const {return name;}
virtual void print() const {
cout << "Employee: " << getName() << endl;
}
private:
string name;
};
class Manager : public Employee {
public:
Manager(string theName, Employee theEmployee) :Employee(theName), assistant(theEmployee) {};
Employee getAssistant() const {return assistant;}
virtual void print() const {
cout << "Manager: " << getName() << endl;
cout << "Assistant: " << assistant.getName() << endl;
}
private:
Employee assistant;
};
int main () {
Employee coder("Joe Smith");
Employee typist("Bill Jones");
Manager designer("Jane Doe", typist);
coder = designer; // 【错误】切分了对象designer:Jane Doe
coder.print();
}
运行结果:Employee: Jane Doe
示例代码中基类Employee,继承类Manager(增加了属性assistant),如果将Manager类的对象数据拷贝给Employee类的对象,则将发生对象切分,Manager类的assistant属性数据将丢失。
推荐做法1(使用指针):
int main ()
{
Employee *coder = new Employee("Joe Smith");
Employee *typist = new Employee("Bill Jones");
Manager *designer = new Manager("Jane Doe", *typist);
coder = designer;
coder->print();
}
推荐做法2(引用):
int main ()
{
Employee coder("Joe Smith");
Employee typist("Bill Jones");
Manager designer("Jane Doe", typist);
Employee &toPrint = designer; // Jane remains entire
toPrint.print();
}
推荐做法3(使用智能指针):
int main ()
{
auto_ptr<Employee> coder(new Employee("Joe Smith"));
auto_ptr<Employee> typist(new Employee("Bill Jones"));
auto_ptr<Manager> designer(new Manager("Jane Doe", *typist));
coder = designer; // Smith deleted, Doe xferred
coder->print();
// everyone deleted
}
运行结果:
Manager: Jane Doe
Assistant: Bill Jones
规则9.2:禁止定义基类析构函数为非虚函数,所有可能被继承类的析构函数都必须定义为virtual
说明:基类的析构函数如果不是virtual的,那么在对一个Base类型的指针进行delete时,就不会调用到派生类Derived的析构函数。而派生类里的析构函数一般会用于析构其内部的子对象,这样就可能会造成内存泄漏。
错误示例:代码中的析构函数没有被定义成虚函数。
class Base
{
public:
~Base(){}; //【错误】禁止定义基类析构函数为非虚函数
};
class Derived : public Base
{
private:
char *pc;
public:
Derived()
{
pc=new char[100];
};
~ Derived()
{
delete [] pc;
};
};
void main()
{
Base *obj = new Derived();
delete obj;
}
以上示例代码基类Base的析构函数不是virtual的。因为不是virtual,所以在对Base类型的指针obj进行delete时,不会调用到派生类Derived的析构函数,这样就造成内存泄漏。
推荐做法:基类Base的析构函数定义为virtual,这样确保在对Base类型的指针obj进行delete时调用派生类Derived的析构函数。
class Base {
public:
virtual ~Base(){};//【修改】定义基类析构函数为虚函数
};
class Derived : public Base {
private:
char *pc;
public:
Derived()
{
pc=new char[100];
};
~ Derived()
{
delete [] pc;
};
};
void main()
{
Base *obj = new Derived();
delete obj;
}
规则9.3:避免出现delete this操作
说明:对象指针应避免使用delete this语句硬删除,除非能保证this指针删除后不再被引用,并且保证对象是通过new操作符在堆上创建的。
原因有两个:
(1)类的对象既可能是栈对象,也可能是堆对象。如果对栈对象的指针进行delete,即删除非动态分配的内存,会导致未定义行为;
(2)二是delete this容易产生悬挂指针(dangling pointer),悬挂指针是个严重的安全漏洞,可以被攻击者利用执行任意代码。
错误示例:错误的删除this指针
class SomeClass
{
public:
SomeClass();
~SomeClass();
void doSomething();
void destroy();
// ...
};
void SomeClass::destroy()
{
/* ...do something... */
delete this; //【错误】删除this指针会导致出现悬挂指针
}
void main()
{
/* ...do something... */
SomeClass sc; // 声明栈对象
/* ...do something... */
sc.destroy(); // 释放非动态分配的内存。
}
推荐做法1:不delete this,让栈对象离开作用域后自动析构。
class SomeClass
{
public:
SomeClass();
~SomeClass();
void doSomething();
void destroy();
// ...
};
void SomeClass::destroy()
{
/* ...do something... */
// delete this; // Dangerous!!
}
// ...
void main()
{
SomeClass sc; // 声明栈对象
/* ...do something... */
} // 离开作用域,自动调用sc.~SomeClass()
如果不得不使用delete this,保证类对象是堆对象,且this指针delete后置NULL,可参考如下示例代码:
class SomeClass
{
public:
SomeClass();
void doSomething();
void destroy();
// ...
protected:
~SomeClass();
};
void SomeClass::destroy()
{
/* ...do something... */
delete this;
}
// ...
{
SomeClass* sc = new SomeClass();
/* ...do something... */
sc->destroy();
sc = NULL;
}
这个示例代码中,将析构函数声明为protected,可以保证类SomeClass的对象不会在栈上生成。同时,在显示调用destory()来delete this指针后,再将指针置NULL,防止指针解引用。
延伸阅读材料:Dangling Pointer, Jonathan Afek, 2/8/07, BlackHat USA
规则9.4:禁止在类的公共接口中返回类的私有数据地址
说明:如果一个类私有成员数据的引用或者其指针,被类的公有函数作为返回值return,则此私有数据可能会遭受到非可信代码的修改,导致引入不安全因素。
例子1:
错误示例:下列代码类中的私有成员变量被公共成员函数所引用。
class Widget {
public:
Widget (): total(0) {}
// …
void add (someType someParameters)
{
/* ...do something... */
total ++;
/* ...do something... */
}
void remove (someType someParameters)
{
/* ...do something... */
total --;
/* ...do something... */
}
// …
int& getTotal () {return total;} //【错误】禁止返回类的私有数据成员地址
// …
private:
int total;
// …
};
示例代码中,total作为类的私有成员,维护着对类方法add与remove的调用计数,但是其实际值却被类的公共成员函数getTotal对外提供了可引用的接口。
推荐做法:
class Widget {
public:
Widget(): total(0) {}
// …
void add(someType someParameters)
{
/* ...do something... */
total ++;
/* ...do something... */
}
void remove(someType someParameters)
{
/* ...do something... */
total --;
/* ...do something... */
}
// …
int getTotal() {return total;}
// …
private:
int total;
// …
};
建议9.1:重载后缀操作符应返回const类型
说明:前缀操作符返回的结果是non-const引用,而后缀操作符返回的可能是临时变量或者一个地址,这两者意味着在后续的操作中其值可能会被修改,因此当重载后缀操作符时,建议重载函数返回值类型为const。
错误示例:
class X
{
public:
X& operator++(); // prefix ++a
X operator++(int); // postfix a++
};
class Y { };
Y& operator++(Y&); // prefix ++b
Y operator++(Y&,int); // postfix b++
通常对于++的定义如上所示。
#include <iostream>
#include <vector>
using namespace std;
int main()
{
vector<char> v(5, 'c');
vector<char>::iterator i = v.begin();
*++i++ = 'd'; // line 10
*i = 'e';
for (i = v.begin(); i != v.end(); ++i) cout << *i;
cout << endl;
return 0;
}
运行结果:deccc
示例代码展示了虽然在第10行(*++i++ = 'd';),连续调用了两次后缀加操作,但实际上进行了一次自加操作。第二个自加操作针对的对象是临时对象,自加后的结果随后被丢弃,未被保存下来。
推荐做法:
class X
{
public:
const X& operator++(); //【修改】重载++a时返回const类型
const X operator++(int); //【修改】重载a++时返回const类型
};
class Y { };
const Y& operator++(Y&); // prefix ++b
const Y operator++(Y&,int); // postfix b++
推荐将自加和自减后缀操作符的返回值定义为const型,这样在编译时编译器就会告警,从而避免之前错误的发生。
建议9.2:显式声明的模板类应进行类型特化
说明:编译器不会严格地验证模板的参数,容易被破解者利用,并造成攻击。
错误示例:模板类使用错误。
template <typename T>
class A
{
public:
void f1() {/* ... */}
void f2()
{
T t;
t.x = 50;
}
};
int main()
{
A<int> a; // 【错误】A<int>::f2有问题,int并不是class且没有成员变量x
a.f1();
}
示例代码A<INT>::f2明显是有问题的,因为类型int并不是class,并且也没有成员变量x。很明显,模板A的设计者并不是将该模板应用于类型int。然而编译器并不会捕捉到这个错误,因此代码会被成功编译,却引入了缺陷。
推荐做法:
template <typename T>
class A
{
public:
void f1() {/* ... */}
void f2()
{
T t;
t.x = 50;
}
};
template class A<int>; //【修改】显示声明模板类特化
int main()
{
A<int> a;
a.f1();
}
添加如上代码后,编译器会捕获到样例代码中的错误,因为模板的声明会强制编译器初始化类的所有成员,包括A<int>::f2(),此时就会捕获到编译错误。