禁止使用std::string存储敏感信息
【描述】
std::string类是C++内部定义的字符串管理类,如果口令等敏感信息通过std::string进行操作,在程序运行过程中,敏感信息可能会散落到内存的各个地方,并且无法清除。
【反例】
如下代码中,Foo函数中获取密码,保存到std::string变量password中,随后传递给VerifyPassword函数,在这个过程中,password实际上在内存中出现了两份。
bool VerifyPassword(std::string password)
{
...
}
void Foo()
{
std::string password = GetPassword();
VerifyPassword(password);
}
【影响】
未及时清理敏感信息,可能导致信息泄露。
外部数据用于容器索引或迭代器时必须确保在有效范围内
【描述】
外部数据是不可信数据,当将外部数据用于容器或数组的索引时,应确保其值在容器或数组可被访问元素的有效范围内;当将外部数据用于迭代器偏移时,应确保偏移后的迭代器值在与迭代器关联容器(从容器对象c的begin()方法创建)的[begin(), end())之间(即大于等于c.begin(),小于等于c.end())。
对于具有at()方法的容器(如std::vector, std::set, std::map),对应索引越界或键值内容不存在时,方法将抛出异常;而其对应的operator[]出现索引越界时,将导致未定义行为;或者因键值内容不存在而构造对应键值的默认值不成功时,也将导致未定义行为。
【反例】
int main()
{
// 得到一个来自外部输入的整数 (index)
int index;
if (!(std::cin >> index)) {
... // 错误处理
return -1;
}
std::vector<char> c{'A', 'B', 'C', 'D'};
// 不符合:没有正确校验index的范围,溢出读取:需要确保index在容器元素的位置范围
std::cout << c[index] << std::endl;
// 不符合:需要确保index在容器/数组元素的位置范围
for (auto pos = std::cbegin(c) + index; pos < std::cend(c); ++pos) {
std::cout << *pos << std::endl;
}
return 0;
}
void Foo(size_t n)
{
std::vector<int> v{0, 1, 2, 3};
// n为外部的API传入的索引,可能导致越界访问
for_each_n(v.cbegin(), n, [](int x) { std::cout << x; });
}
【正例】
int main()
{
// 得到一个来自外部输入的整数 (index)
int index;
if (!(std::cin >> index)) {
... // 错误处理
return -1;
}
// 这里仅以std::vector来举例,std::cbegin(c)等代码也适用于std::string字符串、
// 和C数组(但不适应于char*变量以及char*表示的静态字符串)
std::vector<char> c{'A', 'B', 'C', 'D'};
try {
std::cout << c.at(index) << std::endl; // 符合:索引越界时,at函数将抛出异常
} catch (const std::out_of_range& e) {
... // 越界异常处理
}
// 后续代码必须使用检验合法的 index 进行容器元素索引或迭代器偏移
// 正确校验index的范围:已确保index在容器元素的位置范围
if (index < 0 || index >= c.size()) {
... // 错误处理
return -1;
}
std::cout << c[index] << std::endl; // 符合:已检验index的范围
// 符合:已检验index
for (auto pos = std::cbegin(c) + index; pos < std::cend(c); ++pos) {
std::cout << *pos << std::endl;
}
return 0;
}
void Foo(size_t n)
{
std::vector<int> v{0, 1, 2, 3};
// 必须确保for_each_n的迭代范围[first, first + count)有效
if (n > v.size()) {
... // 错误处理
return;
}
for_each_n(v.cbegin(), n, [](int x) { std::cout << x; });
}
调用格式化输入/输出函数时,使用有效的格式字符串
【描述】
使用C风格的格式化输入/输出函数时,需要确保格式串是合法有效的,并且格式串与相应的实参类型是严格匹配的,否则会使程序产生非预期行为。
除C风格的格式化输入/输出函数以外,C++中类似的函数也需要确保使用有效的格式串,如C++20的std::format()函数。
对于自定义C风格的格式化函数,可以使用编译器支持的属性自动检查使用自定义格式化函数的正确性。
例如:GCC支持自动检测类似printf, scanf, strftime, strfmon的自定义格式化函数,参考GCC手册的Common Function Attributes:
extern int CustomPrintf(void* obj, const char* format, ...)
__attribute__ ((format (printf, 2, 3)));
【反例】
如下代码示例中,格式化输入一个整数到macAddr变量中,但是macAddr为unsigned char类型,而%x对应的是int类型参数,函数执行完成后会发生写越界。
unsigned char macAddr[6];
...
// macStr中的数据格式为 e2:42:a4:52:1e:33
int ret = sscanf(macStr, "%x:%x:%x:%x:%x:%x\n",
&macAddr[0], &macAddr[1],
&macAddr[2], &macAddr[3],
&macAddr[4], &macAddr[5]);
...
【正例】
如下代码中,使用%hhx确保格式串与相应的实参类型严格匹配。
unsigned char macAddr[6];
...
// macStr中的数据格式为 e2:42:a4:52:1e:33
int ret = sscanf(macStr, "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx\n",
&macAddr[0], &macAddr[1],
&macAddr[2], &macAddr[3],
&macAddr[4], &macAddr[5]);
...
注:在C++中不推荐使用sscanf, sprintf等C库函数,可以替换为:std::istringstream, std::ostringstream, 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()
调用格式化函数时,应使用常量字符串作为格式串,禁止格式串外部可控:
Box<int> v{MAX_COUNT};
std::cout << std::format("{:#x}", v);
【反例】
如下代码示例中,使用Log()函数直接打印外部数据,可能出现格式化字符串漏洞。
void Foo()
{
std::string msg = GetMsg();
...
syslog(priority, msg.c_str()); // 不符合:存在格式化字符串漏洞
}
【正例】
下面是推荐做法,使用%s转换符打印外部数据,避免格式化字符串漏洞。
void Foo()
{
std::string msg = GetMsg();
...
syslog(priority, "%s", msg.c_str()); // 符合:这里没有格式化字符串漏洞
}
【影响】
如果格式串被外部可控,攻击者可以使进程崩溃、查看栈内容、查看内存内容或者在任意内存位置写入数据,进而以被攻击进程的权限执行任意代码。
禁止外部可控数据作为进程启动函数的参数或者作为dlopen/LoadLibrary等模块加载函数的参数
【描述】
本条款中进程启动函数包括system、popen、execl、execlp、execle、execv、execvp等。
system()、popen()等函数会创建一个新的进程,如果外部可控数据作为这些函数的参数,会导致注入漏洞。
使用execl()等函数执行新进程时,如果使用shell启动新进程,则同样存在命令注入风险。
使用execlp()、execvp()、execvpe()函数依赖于系统的环境变量PATH来搜索程序路径,使用它们时应充分考虑外部环境变量的风险,或避免使用这些函数。
因此,总是优先考虑使用C标准函数实现需要的功能。如果确实需要使用这些函数,应使用白名单机制确保这些函数的参数不受任何外来数据的影响。
dlopen、LoadLibrary函数会加载外部模块,如果外部可控数据作为这些函数的参数,有可能会加载攻击者事先预制的模块。如果要使用这些函数,可以采用如下措施之一:
- 使用白名单机制,确保这些函数的参数不受任何外来数据的影响。
- 使用数字签名机制保护要加载的模块,充分保证其完整性。
- 在设备本地加载的动态库通过权限与访问控制措施保证了本身安全性后,通过特定目录自动被程序加载。
- 在设备本地的配置文件通过权限与访问控制措施保证了本身安全性后,自动加载配置文件中指定的动态库。
【反例】
如下代码从外部获取数据后直接作为LoadLibrary函数的参数,有可能导致程序被植入木马。
char* msg = GetMsgFromRemote();
LoadLibrary(msg);
如下代码示例中,使用 system() 函数执行 cmd 命令串来自外部,攻击者可以执行任意命令:
std::string cmd = GetCmdFromRemote();
system(cmd.c_str());
如下代码示例中,使用 system() 函数执行 cmd 命令串的一部分来自外部,攻击者可能输入 some dir;reboot
字符串,创造成系统重启:
std::string name = GetDirNameFromRemote();
std::string cmd{"ls " + name};
system(cmd.c_str());
使用exec系列函数来避免命令注入时,注意exec系列函数中的path、file参数禁止使用命令解析器(如/bin/sh)。
int execl(const char* path, const char* arg, ...);
int execlp(const char* file, const char* arg, ...);
int execle(const char* path, const char* arg, ...);
int execv(const char* path, char* const argv[]);
int execvp(const char* file, char* const argv[]);
int execvpe(const char* file, char* const argv[], char* const envp[]);
例如,禁止如下使用方式:
std::string cmd = GetDirNameFromRemote();
execl("/bin/sh", "sh", "-c", cmd.c_str(), nullptr);
可以使用库函数,或者可以通过编写少量的代码来避免使用system函数调用命令,如mkdir()
函数可以实现mkdir
命令的功能。
如下代码中,应该避免使用cat
命令实现文件内容复制的功能。
int WriteDataToFile(const char* dstFile, const char* srcFile)
{
... // 入参的合法性校验
std::ostringstream oss;
oss << "cat " << srcFile << " > " << dstFile;
std::string cmd{oss.str()};
system(cmd.c_str());
...
}
【正例】
如下代码中,通过少量的代码来实现。如下代码实现了文件复制的功能,避免了对cat
或cp
命令的调用。需要注意的是,为简化描述,下面代码未考虑信号中断的影响。
bool WriteDataToFile(const std::string& dstFilePath, const std::string& srcFilePath)
{
const int bufferSize = 1024;
std::vector<char> buffer (bufferSize + 1, 0);
std::ifstream srcFile(srcFilePath, std::ios::binary);
std::ofstream dstFile(dstFilePath, std::ios::binary);
if (!dstFile || !dstFile) {
... // 错误处理
return false;
}
while (true) {
// 从srcFile读取内容分块
srcFile.read(buffer.data(), bufferSize);
std::streamsize size = srcFile ? bufferSize : srcFile.gcount();
// 写入分块内容到dstFile
if (size > 0 && !dstFile.write(buffer.data(), size)) {
... // 错误处理
break;
}
if (!srcFile) {
... // 检查错误:当不是eof()时记录错误
break;
}
}
// srcFile 和 dstFile 在退出作用域时会自动被关闭
return true;
}
可以通过库函数简单实现的功能(如上例),需要避免调用命令处理器来执行外部命令。
如果确实需要调用单个命令,应使用exec*函数来实现参数化调用,并对调用的命令实施白名单管理。同时应避免使用execlp、execvp、execvpe函数,因为这几个函数依赖外部的PATH环境变量。
此时,外部输入的fileName仅作为some_tool命令的参数,没有命令注入的风险。
pid_t pid;
char* const envp[] = {nullptr};
...
std::string fileName = GetDirNameFromRemote();
...
pid = fork();
if (pid < 0) {
...
} else if (pid == 0) {
// 使用some_tool对指定文件进行加工
execle("/bin/some_tool", "some_tool", fileName.c_str(), nullptr, envp);
_Exit(-1);
}
...
int status;
waitpid(pid, &status, 0);
std::ofstream ofs(fileName, std::ios::in);
...
在必须使用system等命令解析器执行命令时,应对输入的命令字符串基于合理的白名单检查,避免命令注入。
std::string cmd = GetCmdFromRemote();
// 使用白名单检查命令是否合法,仅允许"some_tool_a", "some_tool_b"命令,外部无法随意控制
if (!IsValidCmd(cmd.c_str())) {
... // 错误处理
}
system(cmd.c_str());
...
【影响】
- 如果传递给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,与预期不符。
#define DATA_LEN 256
void ArrayInit(int inArray[])
{
// 不符合:这里使用sizeof(inArray)计算数组大小
for (size_t i = 0; i < sizeof(inArray) / sizeof(inArray[0]); i++) {
...
}
}
void FunctionData(void)
{
int data[DATA_LEN];
...
ArrayInit(data); // 调用ArrayInit函数初始化数组data数据
...
}
【正例】
如下代码示例中,修改函数定义,添加数组长度参数,并在调用处正确传入数组长度。
#define DATA_LEN 256
// 函数说明:入参len是入参inArray数组的长度
void ArrayInit(int inArray[], size_t len)
{
for (size_t i = 0; i < len; i++) {
...
}
}
void FunctionData(void)
{
int data[DATA_LEN];
ArrayInit(data, sizeof(data) / sizeof(data[0]));
...
}
【反例】
如下代码示例中,sizeof(inArray)
不等于ARRAY_MAX_LEN * sizeof(int)
,因为将sizeof操作符应用于声明为具有数组类型的参数时,即使参数声明指定了长度,也会被调整为指针,sizeof(inArray)
等同于 sizeof(int *)
:
#define ARRAY_MAX_LEN 256
void ArrayInit(int inArray[ARRAY_MAX_LEN])
{
// 不符合:sizeof(inArray),得到的长度是指针的大小,不是数组的长度,和预期不符。
for (size_t i = 0; i < sizeof(inArray) / sizeof(inArray[0]); i++) {
...
}
}
int main(void)
{
int masterArray[ARRAY_MAX_LEN];
...
ArrayInit(masterArray);
return 0;
}
【正例】
如下代码示例中,使用入参len表示指定数组的长度:
#define ARRAY_MAX_LEN 256
// 函数说明:入参len是入参数组的长度
void ArrayInit(int inArray[], size_t len)
{
for (size_t i = 0; i < len; i++) {
...
}
}
int main(void)
{
int masterArray[ARRAY_MAX_LEN];
ArrayInit(masterArray, ARRAY_MAX_LEN);
...
return 0;
}
禁止通过对指针变量进行sizeof操作来获取数组大小
描述】
将指针当做数组进行sizeof操作时,会导致实际的执行结果与预期不符。例如:变量定义 char *p = array
,其中array的定义为char array[LEN]
,表达式sizeof(p)
得到的结果与 sizeof(char *)
相同,并非array的长度。
【反例】
如下代码示例中,buffer和path分别是指针和数组,程序员想对这2个内存进行清0操作,但由于程序员的疏忽,将内存大小误写成了sizeof(buffer)
,与预期不符。
char path[MAX_PATH];
char *buffer = (char *)malloc(SIZE);
...
...
memset(path, 0, sizeof(path));
// sizeof与预期不符,其结果为指针本身的大小而不是缓冲区大小
memset(buffer, 0, sizeof(buffer));
【正例】
如下代码示例中,将sizeof(buffer)
修改为申请的缓冲区大小:
char path[MAX_PATH];
char *buffer = (char *)malloc(SIZE);
...
...
memset(path, 0, sizeof(path));
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注入风险:
char name[NAME_MAX];
char sqlStatements[SQL_CMD_MAX];
int ret = GetUserInput(name, NAME_MAX);
...
ret = sprintf(sqlStatements,
"SELECT childinfo FROM children WHERE name= ‘%s’",
name);
...
ret = mysql_query(&myConnection, sqlStatements);
...
【正例】
使用预处理语句进行参数化查询可以防御SQL注入攻击:
char name[NAME_MAX];
...
MYSQL_STMT *stmt = mysql_stmt_init(myConnection);
char *query = "SELECT childinfo FROM children WHERE name= ?";
if (mysql_stmt_prepare(stmt, query, strlen(query))) {
...
}
int ret = GetUserInput(name, NAME_MAX);
...
MYSQL_BIND params[1];
(void)memset(params, 0, sizeof(params));
...
params[0].bufferType = MYSQL_TYPE_STRING;
params[0].buffer = (char *)name;
params[0].bufferLength = strlen(name);
params[0].isNull = 0;
bool isCompleted = mysql_stmt_bind_param(stmt, params);
...
ret = mysql_stmt_execute(stmt);
...
【影响】
如果拼接SQL语句的字符串是外部可控的,则攻击者可以通过注入特定的字符串欺骗程序执行恶意的SQL命令,造成信息泄露、权限绕过、数据被篡改等问题。
内存中的敏感信息使用完毕后立即清0
【描述】
内存中的口令、密钥等敏感信息使用完毕后立即清0,避免被攻击者获取或者无意间泄露给低权限用户。这里所说的内存包括但不限于:
- 动态分配的内存
- 静态分配的内存
- 自动分配(堆栈)内存
- 内存缓存
- 磁盘缓存
【反例】
通常内存在释放前不需要清除内存数据,因为这样在运行时会增加额外开销,所以在这段内存被释放之后,之前的数据还是会保留在其中。如果这段内存中的数据包含敏感信息,则可能会意外泄露敏感信息。为了防止敏感信息泄露,必须先清除内存中的敏感信息,然后再释放。
在如下代码示例中,存储在所引用的动态内存中的敏感信息secret被复制到新动态分配的缓冲区newSecret,最终通过free()释放。因为释放前未清除这块内存数据,这块内存可能被重新分配到程序的另一部分,之前存储在newSecret中的敏感信息可能会无意中被泄露。
char *secret = NULL;
/*
* 假设 secret 指向敏感信息,敏感信息的内容是长度小于SIZE_MAX个字符,
* 并且以null终止的字节字符串
*/
size_t size = strlen(secret);
char *newSecret = NULL;
newSecret = (char *)malloc(size + 1);
if (newSecret == NULL) {
... // 错误处理
} else {
errno_t ret = strcpy(newSecret, secret);
... // 处理 ret
... // 处理 newSecret...
free(newSecret);
newSecret = NULL;
}
...
【正例】
如下代码示例中,为了防止信息泄露,应先清除包含敏感信息的动态内存(用’\0’字符填充空间),然后再释放它。
char *secret = NULL;
/*
* 假设 secret 指向敏感信息,敏感信息的内容是长度小于SIZE_MAX个字符,
* 并且以null终止的字节字符串
*/
size_t size = strlen(secret);
char *newSecret = NULL;
newSecret = (char *)malloc(size + 1);
if (newSecret == NULL) {
... // 错误处理
} else {
errno_t ret = strcpy(newSecret, secret);
... // 处理 ret
... // 处理 newSecret...
(void)memset(newSecret, 0, size + 1);
free(newSecret);
newSecret = NULL;
}
...
【正例】
下面是另外一个涉及敏感信息清理的场景,在代码获取到密码后,将密码保存到password中,进行密码验证,使用完毕后,通过memset()
函数对password清0。
int Foo(void)
{
char password[MAX_PWD_LEN];
if (!GetPassword(password, sizeof(password))) {
...
}
if (!VerifyPassword(password)) {
...
}
...
(void)memset(password, 0, sizeof(password));
...
}
要特别注意:对敏感信息清理的时候要同时防止因编译器优化而使清理代码无效。
例如,下列代码使用了可能被编译器优化掉的语句。
int SecureLogin(void)
{
char pwd[PWD_SIZE];
if (RetrievePassword(pwd, sizeof(pwd))) {
... // 口令检查及其他处理
}
memset(pwd, 0, sizeof(pwd)); // 编译器优化有可能会使该语句失效
...
}
某些编译器在优化时候不会执行它认为不会改变程序执行结果的代码,因此memset()操作会被优化掉。
如果编译器支持#pragma指令,那么可以使用该指令指示编译器不作优化。
void SecureLogin(void)
{
char pwd[PWD_SIZE];
if (RetrievePassword(pwd, sizeof(pwd))) {
... // 口令检查及其他处理
}
#pragma optimize("", off)
// 清除内存
...
#pragma optimize("", on)
...
}
【影响】
未及时清理敏感信息,可能导致信息泄露。
创建文件时必须显式指定合适的文件访问权限
【描述】
创建文件时,如果不显式指定合适访问权限,可能会让未经授权的用户访问该文件,造成信息泄露,文件数据被篡改,文件中被注入恶意代码等风险。
虽然文件的访问权限也依赖于文件系统,但是当前许多文件创建函数(例如POSIX open函数)都具有设置(或影响)文件访问权限的功能,所以当使用这些函数创建文件时,必须显式指定合适的文件访问权限,以防止意外访问。
【反例】
使用POSIX open()函数创建文件但未显示指定该文件的访问权限,可能会导致文件创建时具有过高的访问权限。这可能会导致漏洞(例如CVE-2006-1174)。
void Foo(void)
{
int fd = -1;
char *filename = NULL;
... // 初始化 filename
fd = open(filename, O_CREAT | O_WRONLY); // 没有显式指定访问权限
if (fd == -1) {
... // 错误处理
}
...
}
【正例】
应该在open的第三个参数中显式指定新创建文件的访问权限。可以根据文件实际的应用情况设置何种访问权限。
void Foo(void)
{
int fd = -1;
char *filename = NULL;
... // 初始化 filename 和指定其访问权限
// 此处根据文件实际需要,显式指定其访问权限
int fd = open(filename, O_CREAT | O_WRONLY, S_IRUSR | S_IWUSR);
if (fd == -1) {
... // 错误处理
}
...
}
【影响】
创建访问权限弱的文件,可能会导致对这些文件的非法访问。
使用文件路径前必须进行规范化并校验
【描述】
当文件路径来自外部数据时,必须对其做合法性校验,如果不校验,可能造成系统文件的被任意访问。但是禁止直接对其进行校验,正确做法是在校验之前必须对其进行路径规范化处理。这是因为同一个文件可以通过多种形式的路径来描述和引用,例如既可以是绝对路径,也可以是相对路径;而且路径名、目录名和文件名可能包含使校验变得困难和不准确的字符(如:“.”、“..”)。此外,文件还可以是符号链接,这进一步模糊了文件的实际位置或标识,增加了校验的难度和校验准确性。所以必须先将文件路径规范化,从而更容易校验其路径、目录或文件名,增加校验准确性。
因为规范化机制在不同的操作系统和文件系统之间可能有所不同,所以最好使用符合当前系统特性的规范化机制。
一个简单的案例说明如下:
当文件路径来自外部数据时,需要先将文件路径规范化,如果没有作规范化处理,攻击者就有机会通过恶意构造文件路径进行文件的越权访问。
例如,攻击者可以构造“../../../etc/passwd”的方式进行任意文件访问。
【反例】
在此错误的示例中,inputFilename包含一个源于受污染源的文件名,并且该文件名已打开以进行写入。在使用此文件名操作之前,应该对其进行验证,以确保它引用的是预期的有效文件。
不幸的是,inputFilename引用的文件名可能包含特殊字符,例如目录字符,这使验证变得困难,甚至不可能。而且,inputFilename中可能包含可以指向任意文件路径的符号链接,即使该文件名通过了验证,也会导致该文件名是无效的。
这种场景下,对文件名的直接验证即使被执行也是得不到预期的结果,对fopen()的调用可能会导致访问一个意外的文件。
...
if (!verify_file(inputFilename) { // 没有对inputFilename做规范化,直接做校验
... // 错误处理
}
if (fopen(inputFilename, "w") == NULL) {
... // 错误处理
}
...
【正例】
规范化文件名是具有一定难度的,因为这需要了解底层文件系统。
POSIX realpath()函数可以帮助将路径名转换为规范形式。参考信息技术标准-POSIX®,基本规范第7期[IEEE std 1003.1:2013]:
- 该realpath()函数应从所指向的路径名派生一个filename的绝对路径名,两者指向同一文件,绝对路径其文件名不涉及“ .”,“ ..”或符号链接。
在规范化路径之后,还必须执行进一步的验证,例如确保两个连续的斜杠或特殊文件不会出现在文件名中。有关如何执行路径名解析的更多详细信息,请参见[IEEE Std 1003.1: 2013]第4.12节“路径名解析”。
使用realpath()函数有许多需要注意的地方。
在了解了以上原理之后,对上面的错误代码示例,我们采用如下解决方案:
char *realpathRes = NULL;
...
// 在校验之前,先对inputFilename做规范化处理
realpathRes = realpath(inputFilename, NULL);
if (realpathRes == NULL) {
... // 规范化的错误处理
}
// 规范化以后对路径进行校验
if (!verify_file(realpathRes) {
... // 校验的错误处理
}
// 使用
if (fopen(realpathRes, "w") == NULL) {
... // 实际操作的错误处理
}
...
free(realpathRes);
realpathRes = NULL;
...
【正例】
根据我们的实际场景,我们还可以采用的第二套解决方案,说明如下:
如果PATH_MAX
被定义为 limits.h 中的一个常量,那么使用非空的resolved_path
调用realpath()也是安全的。
在本例中realpath()函数期望resolved_path
引用一个字符数组,该字符数组足够大,可以容纳规范化的路径。
如果定义了PATH_MAX,则分配一个大小为PATH_MAX
的缓冲区来保存realpath()的结果。正确代码示例如下:
char *realpathRes = NULL;
char *canonicalFilename = NULL;
size_t pathSize = 0;
...
pathSize = (size_t)PATH_MAX;
if (VerifyPathSize(pathSize)) {
canonicalFilename = (char *)malloc(pathSize);
if (canonicalFilename == NULL) {
... // 错误处理
}
realpathRes = realpath(inputFilename, canonicalFilename);
}
if (realpathRes == NULL) {
... // 错误处理
}
if (VerifyFile(realpathRes)) {
... // 错误处理
}
if (fopen(realpathRes, "w") == NULL ) {
... // 错误处理
}
...
free(canonicalFilename);
canonicalFilename = NULL;
...
【反例】
下面的代码场景是从外部获取到文件名称,拼接成文件路径后,直接对文件内容进行读取,导致攻击者可以读取到任意文件的内容:
char *filename = GetMsgFromRemote();
...
int ret = sprintf(untrustPath, "/tmp/%s", filename);
...
char *text = ReadFileContent(untrustPath);
【正例】
正确的做法是,对路径进行规范化后,再判断路径是否是本程序所认为的合法的路径:
char *filename = GetMsgFromRemote();
...
sprintf(untrustPath, "/tmp/%s", filename);
char path[PATH_MAX];
if (realpath(untrustPath, path) == NULL) {
... // 处理错误
}
if (!IsValidPath(path)) { // 检查文件的位置是否正确
... // 处理错误
}
char *text = ReadFileContent(path);
【例外】
运行于控制台的命令行程序,通过控制台手工输入文件路径,可以作为本条款例外。
int main(int argc, char **argv)
{
int fd = -1;
if (argc == 2) {
fd = open(argv[1], O_RDONLY);
...
}
...
return 0;
}
【影响】
未对不可信的文件路径进行规范化和校验,可能造成对任意文件的访问。
不要在共享目录中创建临时文件
【描述】
共享目录是指其它非特权用户可以访问的目录。程序的临时文件应当是程序自身独享的,任何将自身临时文件置于共享目录的做法,将导致其他共享用户获得该程序的额外信息,产生信息泄露。因此,不要在任何共享目录创建仅由程序自身使用的临时文件。
临时文件通常用于辅助保存不能驻留在内存中的数据或存储临时的数据,也可用作进程间通信的一种手段(通过文件系统传输数据)。例如,一个进程在共享目录中创建一个临时文件,该文件名可能使用了众所周知的名称或者一个临时的名称,然后就可以通过该文件在进程间共享信息。这种通过在共享目录中创建临时文件的方法实现进程间共享的做法很危险,因为共享目录中的这些文件很容易被攻击者劫持或操纵。这里有几种缓解策略:
- 使用其他低级IPC(进程间通信)机制,例如套接字或共享内存。
- 使用更高级别的IPC机制,例如远程过程调用。
- 使用仅能由程序本身访问的安全目录(多线程/进程下注意防止条件竞争)。
同时,下面列出了几项临时文件创建使用的方法,产品根据具体场景执行以下一项或者几项,同时产品也可以自定义合适的方法。
- 文件必须具有合适的权限,只有符合权限的用户才能访问
- 创建的文件名是唯一的、或不可预测的
- 仅当文件不存在时才创建打开(原子创建打开)
- 使用独占访问打开,避免竞争条件
- 在程序退出之前移除
同时也需要注意到,当某个目录被开放读/写权限给多个用户或者一组用户时,该共享目录潜在的安全风险远远大于访问该目录中临时文件这个功能的本身。
在共享目录中创建临时文件很容易受到威胁。例如,用于本地挂载的文件系统的代码在与远程挂载的文件系统一起共享使用时可能会受到攻击。安全的解决方案是不要在共享目录中创建临时文件。
【反例】
如下代码示例,程序在系统的共享目录/tmp下创建临时文件来保存临时数据,且文件名是硬编码的。
由于文件名是硬编码的,因此是可预测的,攻击者只需用符号链接替换文件,然后链接所引用的目标文件就会被打开并写入新内容。
void ProcData(const char *filename)
{
FILE *fp = fopen(filename, "wb+");
if (fp == NULL) {
... // 错误处理
}
... // 写文件
fclose(fp);
}
int main(void)
{
// 不符合:1.在系统共享目录中创建临时文件;2.临时文件名硬编码
char *pFile = "/tmp/data";
...
ProcData(pFile);
...
return 0;
}
【正确案例】
不应在该目录下创建仅由程序自身使用的临时文件。
【影响】
不安全的创建临时文件,可能导致文件非法访问,并造成本地系统上的权限提升。
不要在信号处理函数中访问共享对象
【描述】
如果在信号处理程序中访问和修改共享对象,可能会造成竞争条件,使数据处于不确定的状态。
这条规则有两个不适用的场景(参考C11标准5.1.2.3第5段):
- 读写不需要加锁的原子对象;
- 读写volatile sig_atomic_t类型的对象,因为具有volatile sig_atomic_t类型的对象即使在出现异步中断的时候也可以作为一个原子实体访问,是异步安全的。
【反例】
在这个信号处理过程中,程序打算将g_msg
作为共享对象,当产生SIGINT信号时更新共享对象的内容,但是该g_msg
变量类型不是volatile sig_atomic_t
,所以不是异步安全的。
#define MAX_MSG_SIZE 32
static char g_msgBuf[MAX_MSG_SIZE] = {0};
static char *g_msg = g_msgBuf;
void SignalHandler(int signum)
{
// 下面代码操作g_msg不合规,因为不是异步安全的
(void)memset(g_msg,0, MAX_MSG_SIZE);
errno_t ret = strcpy(g_msg, "signal SIGINT received.");
... // 处理 ret
}
int main(void)
{
errno_t ret = strcpy(g_msg, "No msg yet."); // 初始化消息内容
... // 处理 ret
signal(SIGINT, SignalHandler); // 设置SIGINT信号对应的处理函数
... // 程序主循环代码
return 0;
}
【正例】
如下代码示例中,在信号处理函数中仅将volatile sig_atomic_t
类型作为共享对象使用。
#define MAX_MSG_SIZE 32
volatile sig_atomic_t g_sigFlag = 0;
void SignalHandler(int signum)
{
g_sigFlag = 1; // 符合
}
int main(void)
{
signal(SIGINT, SignalHandler);
char msgBuf[MAX_MSG_SIZE];
errno_t ret = strcpy(msgBuf, "No msg yet."); // 初始化消息内容
... // 处理 ret
... // 程序主循环代码
if (g_sigFlag == 1) { // 在退出主循环之后,根据g_sigFlag状态再刷新消息内容
ret = strcpy(msgBuf, "signal SIGINT received.");
... // 处理 ret
}
return 0;
}
【影响】
在信号处理程序中访问或修改共享对象,可能造成以不一致的状态访问数据。
禁用rand函数产生用于安全用途的伪随机数
【描述】
C语言标准库rand()函数生成的是伪随机数,所以不能保证其产生的随机数序列质量。根据C11标准,rand()函数产生的随机数范围是[0, RAND_MAX(0x7FFF)]
,因为范围相对较短,所以这些数字可以被预测。
所以禁止使用rand()函数产生的随机数用于安全用途,必须使用安全的随机数产生方式。
典型的安全用途场景包括(但不限于)以下几种:
- 会话标识SessionID的生成;
- 挑战算法中的随机数生成;
- 验证码的随机数生成;
- 用于密码算法用途(例如用于生成IV、盐值、密钥等)的随机数生成。
【反例】
程序员期望生成一个唯一的不可被猜测的HTTP会话ID,但该ID是通过调用rand()函数产生的数字随机数,它的ID是可猜测的,并且随机性有限。
【影响】
使用rand()函数可能造成可预测的随机数。
禁止在发布版本中输出对象或函数的地址
【描述】
禁止在发布版本中输出对象或函数的地址,如:将变量或函数的地址输出到客户端、日志、串口中。
当攻击者实施高级攻击时,通常需要先获取目标程序中的内存地址(如变量地址、函数地址等),再通过修改指定内存的内容,达到攻击目的。
如果程序中主动输出对象或函数的地址,则为攻击者提供了便利条件,可以根据这些地址以及偏移量计算出其他对象或函数的地址,并实施攻击。
另外,由于内存地址泄露,也会造成地址空间随机化的保护功能失效。
【反例】
如下代码中,使用%p格式将指针指向的地址记录到日志中。
int Encode(unsigned char *in, size_t inSize, unsigned char *out, size_t maxSize)
{
...
Log("in=%p, in size=%zu, out=%p, max size=%zu\n", in, inSize, out, maxSize);
...
}
备注:这里仅用%p打印指针作为示例,代码中将指针转换为整数再打印也存在同样的风险。
【正例】
如下代码中,删除打印地址的代码。
int Encode(unsigned char *in, size_t inSize, unsigned char *out, size_t maxSize)
{
...
Log("in size=%zu, max size=%zu\n", inSize, maxSize);
...
}
【例外】
当程序崩溃退出时,在记录崩溃的异常信息中可以输出内存地址等信息。
【影响】
内存地址信息泄露,为攻击者实施攻击提供有利信息,可能造成地址空间随机化防护失效。
禁止代码中包含公网地址
【描述】
代码或脚本中包含用户不可见,不可知的公网地址,可能会引起客户质疑。
对产品发布的软件(包含软件包/补丁包)中包含的公网地址(包括公网IP地址、公网URL地址/域名、邮箱地址)要求如下:
1、禁止包含用户界面不可见、或产品资料未描述的未公开的公网地址。
2、已公开的公网地址禁止写在代码或者脚本中,可以存储在配置文件或数据库中。
对于开源/第三方软件自带的公网地址必须至少满足上述第1条公开性要求。
【例外】
- 对于标准协议中必须指定公网地址的场景可例外,如soap协议中函数的命名空间必须指定的一个组装的公网URL、http页面中包含w3.org网址等。
内核安全编程
内核mmap接口实现中,确保对映射起始地址和大小进行合法性校验
【描述】
说明:内核 mmap接口中,经常使用remap_pfn_range()函数将设备物理内存映射到用户进程空间。如果映射起始地址等参数由用户态控制并缺少合法性校验,将导致用户态可通过映射读写任意内核地址。如果攻击者精心构造传入参数,甚至可在内核中执行任意代码。
【错误代码示例】
如下代码在使用remap_pfn_range()进行内存映射时,未对用户可控的映射起始地址和空间大小进行合法性校验,可导致内核崩溃或任意代码执行。
static int incorrect_mmap(struct file *file, struct vm_area_struct *vma)
{
unsigned long size;
size = vma->vm_end - vma->vm_start;
vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot);
//错误:未对映射起始地址、空间大小做合法性校验
if (remap_pfn_range(vma, vma->vm_start, vma->vm_pgoff, size, vma->vm_page_prot)) {
err_log("%s, remap_pfn_range fail", __func__);
return EFAULT;
} else {
vma->vm_flags &= ~VM_IO;
}
return EOK;
}
【正确代码示例】
增加对映射起始地址等参数的合法性校验。
static int correct_mmap(struct file *file, struct vm_area_struct *vma)
{
unsigned long size;
size = vma->vm_end - vma->vm_start;
//修改:添加校验函数,验证映射起始地址、空间大小是否合法
if (!valid_mmap_phys_addr_range(vma->vm_pgoff, size)) {
return EINVAL;
}
vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot);
if (remap_pfn_range(vma, vma->vm_start, vma->vm_pgoff, size, vma->vm_page_prot)) {
err_log( "%s, remap_pfn_range fail ", __func__);
return EFAULT;
} else {
vma->vm_flags &= ~VM_IO;
}
return EOK;
}
内核程序中必须使用内核专用函数读写用户态缓冲区
【描述】
用户态与内核态之间进行数据交换时,如果在内核中不加任何校验(如校验地址范围、空指针)而直接引用用户态传入指针,当用户态传入非法指针时,可导致内核崩溃、任意地址读写等问题。因此,应当禁止使用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时,可导致内核崩溃。
ssize_t incorrect_show(struct file *file, char__user *buf, size_t size, loff_t *data)
{
// 错误:直接引用用户态传入指针,如果buf为NULL,则空指针异常导致内核崩溃
return snprintf(buf, size, "%ld\n", debug_level);
}
【正确代码示例】
使用copy_to_user()函数代替snprintf()。
ssize_t correct_show(struct file *file, char __user *buf, size_t size, loff_t *data)
{
int ret = 0;
char level_str[MAX_STR_LEN] = {0};
snprintf(level_str, MAX_STR_LEN, "%ld \n", debug_level);
if(strlen(level_str) >= size) {
return EFAULT;
}
// 修改:使用专用函数copy_to_user()将数据写入到用户态buf,并注意防止缓冲区溢出
ret = copy_to_user(buf, level_str, strlen(level_str)+1);
return ret;
}
【错误代码示例】
内核态直接使用用户态传入的指针user_buf作为数据源进行memcpy()操作,当user_buf为NULL时,可导致内核崩溃。
size_t incorrect_write(struct file *file, const char __user *user_buf, size_t count, loff_t *ppos)
{
...
char buf [128] = {0};
int buf_size = 0;
buf_size = min(count, (sizeof(buf)-1));
// 错误:直接引用用户态传入指针,如果user_buf为NULL,则可导致内核崩溃
(void)memcpy(buf, user_buf, buf_size);
...
}
【正确代码示例】
使用copy_from_user()函数代替memcpy()。
ssize_t correct_write(struct file *file, const char __user *user_buf, size_t count, loff_t *ppos)
{
...
char buf[128] = {0};
int buf_size = 0;
buf_size = min(count, (sizeof(buf)-1));
// 修改:使用专用函数copy_from_user()将数据写入到内核态buf,并注意防止缓冲区溢出
if (copy_from_user(buf, user_buf, buf_size)) {
return EFAULT;
}
...
}
必须对copy_from_user()拷贝长度进行校验,防止缓冲区溢出
说明:内核态从用户态拷贝数据时通常使用copy_from_user()函数,如果未对拷贝长度做校验或者校验不当,会造成内核缓冲区溢出,导致内核panic或提权。
【错误代码示例】
未校验拷贝长度。
static long gser_ioctl(struct file *fp, unsigned cmd, unsigned long arg)
{
char smd_write_buf[GSERIAL_BUF_LEN];
switch (cmd)
{
case GSERIAL_SMD_WRITE:
if (copy_from_user(&smd_write_arg, argp, sizeof(smd_write_arg))) {...}
// 错误:拷贝长度参数smd_write_arg.size由用户输入,未校验
copy_from_user(smd_write_buf, smd_write_arg.buf, smd_write_arg.size);
...
}
}
【正确代码示例】
添加长度校验。
static long gser_ioctl(struct file *fp, unsigned cmd, unsigned long arg)
{
char smd_write_buf[GSERIAL_BUF_LEN];
switch (cmd)
{
case GSERIAL_SMD_WRITE:
if (copy_from_user(&smd_write_arg, argp, sizeof(smd_write_arg))){...}
// 修改:添加校验
if (smd_write_arg.size >= GSERIAL_BUF_LEN) {......}
copy_from_user(smd_write_buf, smd_write_arg.buf, smd_write_arg.size);
...
}
}
必须对copy_to_user()拷贝的数据进行初始化,防止信息泄漏
【描述】
说明:内核态使用copy_to_user()向用户态拷贝数据时,当数据未完全初始化(如结构体成员未赋值、字节对齐引起的内存空洞等),会导致栈上指针等敏感信息泄漏。攻击者可利用绕过kaslr等安全机制。
【错误代码示例】
未完全初始化数据结构成员。
static long rmnet_ctrl_ioctl(struct file *fp, unsigned cmd, unsigned long arg)
{
struct ep_info info;
switch (cmd) {
case FRMNET_CTRL_EP_LOOKUP:
info.ph_ep_info.ep_type = DATA_EP_TYPE_HSUSB;
info.ipa_ep_pair.cons_pipe_num = port->ipa_cons_idx;
info.ipa_ep_pair.prod_pipe_num = port->ipa_prod_idx;
// 错误: info结构体有4个成员,未全部赋值
ret = copy_to_user((void __user *)arg, &info, sizeof(info));
...
}
}
【正确代码示例】
全部进行初始化。
static long rmnet_ctrl_ioctl(struct file *fp, unsigned cmd, unsigned long arg)
{
struct ep_info info;
// 修改:使用memset初始化缓冲区,保证不存在因字节对齐或未赋值导致的内存空洞
(void)memset(&info, '0', sizeof(ep_info));
switch (cmd) {
case FRMNET_CTRL_EP_LOOKUP:
info.ph_ep_info.ep_type = DATA_EP_TYPE_HSUSB;
info.ipa_ep_pair.cons_pipe_num = port->ipa_cons_idx;
info.ipa_ep_pair.prod_pipe_num = port->ipa_prod_idx;
ret = copy_to_user((void __user *)arg, &info, sizeof(info));
...
}
}
禁止在异常处理中使用BUG_ON宏,避免造成内核panic
【描述】
BUG_ON宏会调用内核的panic()函数,打印错误信息并主动崩溃系统,在正常逻辑处理中(如ioctl接口的cmd参数不识别)不应当使系统崩溃,禁止在此类异常处理场景中使用BUG_ON宏,推荐使用WARN_ON宏。
【错误代码示例】
正常流程中使用了BUG_ON宏
/ * 判断Q6侧设置定时器是否繁忙,1-忙,0-不忙 */
static unsigned int is_modem_set_timer_busy(special_timer *smem_ptr)
{
int i = 0;
if (smem_ptr == NULL) {
printk(KERN_EMERG"%s:smem_ptr NULL!\n", __FUNCTION__);
// 错误:系统BUG_ON宏打印调用栈后调用panic(),导致内核拒绝服务,不应在正常流程中使用
BUG_ON(1);
return 1;
}
...
}
【正确代码示例】
去掉BUG_ON宏。
/ * 判断Q6侧设置定时器是否繁忙,1-忙,0-不忙 */
static unsigned int is_modem_set_timer_busy(special_timer *smem_ptr)
{
int i = 0;
if (smem_ptr == NULL) {
printk(KERN_EMERG"%s:smem_ptr NULL!\n", __FUNCTION__);
// 修改:去掉BUG_ON调用,或使用WARN_ON
return 1;
}
...
}
在中断处理程序或持有自旋锁的进程上下文代码中,禁止使用会引起进程休眠的函数
【描述】
系统以进程为调度单位,在中断上下文中,只有更高优先级的中断才能将其打断,系统在中断处理的时候不能进行进程调度。如果中断处理程序处于休眠状态,就会导致内核无法唤醒,从而使得内核处于瘫痪。
自旋锁在使用时,抢占是失效的。若自旋锁在锁住以后进入睡眠,由于不能进行处理器抢占,其它进程都将因为不能获得CPU(单核CPU)而停止运行,对外表现为系统将不作任何响应,出现挂死。
因此,在中断处理程序或持有自旋锁的进程上下文代码中,应该禁止使用可能会引起休眠(如vmalloc()、msleep()等)、阻塞(如copy_from_user(),copy_to_user()等)或者耗费大量时间(如printk()等)的函数。
合理使用内核栈,防止内核栈溢出
【描述】
内核栈大小是固定的(一般32位系统为8K,64位系统为16K,因此资源非常宝贵。不合理的使用内核栈,可能会导致栈溢出,造成系统挂死。因此需要做到以下几点:
- 在栈上申请内存空间不要超过内核栈大小;
- 注意函数的嵌套使用次数;
- 不要定义过多的变量。
【错误代码示例】
以下代码中定义的变量过大,导致栈溢出。
...
struct result
{
char name[4];
unsigned int a;
unsigned int b;
unsigned int c;
unsigned int d;
}; // 结构体result的大小为20字节
int foo()
{
struct result temp[512];
// 错误: temp数组含有512个元素,总大小为10K,远超内核栈大小
(void)memset(temp, 0, sizeof(result) * 512);
... // use temp do something
return 0;
}
...
代码中数组temp有512个元素,总共10K大小,远超内核的8K,明显的栈溢出。
【正确代码示例】
使用kmalloc()代替之。
...
struct result
{
char name[4];
unsigned int a;
unsigned int b;
unsigned int c;
unsigned int d;
}; // 结构体result的大小为20字节
int foo()
{
struct result *temp = NULL;
temp = (result *)kmalloc(sizeof(result) * 512, GFP_KERNEL); //修改:使用kmalloc()申请内存
... // check temp is not NULL
(void)memset(temp, 0, sizeof(result) * 512);
... // use temp do something
... // free temp
return 0;
}
...
临时关闭地址校验机制后,在操作完成后必须及时恢复
【描述】
SMEP安全机制是指禁止内核执行用户空间的代码(PXN是ARM版本的SMEP)。系统调用(如open(),write()等)本来是提供给用户空间程序访问的。默认情况下,这些函数会对传入的参数地址进行校验,如果入参是非用户空间地址则报错。因此,要在内核程序中使用这些系统调用,就必须使参数地址校验功能失效。set_fs()/get_fs()就用来解决该问题。详细说明见如下代码:
...
mmegment_t old_fs;
printk("Hello, I'm the module that intends to write message to file.\n");
if (file == NULL) {
file = filp_open(MY_FILE, O_RDWR | O_APPEND | O_CREAT, 0664);
}
if (IS_ERR(file)) {
printk("Error occurred while opening file %s, exiting ...\n", MY_FILE);
return 0;
}
sprintf(buf, "%s", "The Message.");
old_fs = get_fs(); // get_fs()的作用是获取用户空间地址上限值
// #define get_fs() (current->addr_limit
set_fs(KERNEL_DS); // set_fs的作用是将地址空间上限扩大到KERNEL_DS,这样内核代码可以调用系统函数
file->f_op->write(file, (char *)buf, sizeof(buf), &file->f_pos); // 内核代码可以调用write()函数
set_fs(old_fs); // 使用完后及时恢复原来用户空间地址限制值
...
通过上述代码,可以了解到最为关键的就是操作完成后,要及时恢复地址校验功能。否则SMEP/PXN安全机制就会失效,使得许多漏洞的利用变得很容易。
【错误代码示例】
在程序错误处理分支,未通过set_fs()恢复地址校验功能。
...
oldfs = get_fs();
set_fs(KERNEL_DS);
/* 在时间戳目录下面创建done文件 */
fd = sys_open(path, O_CREAT | O_WRONLY, FILE_LIMIT);
if (fd < 0) {
BB_PRINT_ERR("sys_mkdir[%s] error, fd is[%d]\n", path, fd);
return; // 错误:在错误处理程序分支未恢复地址校验机制
}
sys_close(fd);
set_fs(oldfs);
...
【正确代码示例】
在错误处理程序中恢复地址校验功能。
...
oldfs = get_fs();
set_fs(KERNEL_DS);
/* 在时间戳目录下面创建done文件 */
fd = sys_open(path, O_CREAT | O_WRONLY, FILE_LIMIT);
if (fd < 0) {
BB_PRINT_ERR("sys_mkdir[%s] error, fd is[%d] \n", path, fd);
set_fs(oldfs); // 修改:在错误处理程序分支中恢复地址校验机制
return;
}
sys_close(fd);
set_fs(oldfs);
...