scanf()踩内存定位记录
一、前情提要
前段时间,有个临时需求,需要我提供demo给第三方用户进行测试,但是我不知道第三方用户提供的密钥明文,并且需要我去除掉我们的内部库。我觉得最简单的方法就是使用开源的openssl库,和使用scanf()让第三方用户手动输入密钥明文。我觉得scanf()对于一个刚学C语言的新手来讲应该也挺简单的,但是我却出现了第二次手动输入指定长度的字符串后,第一个字符串的第一个字符会被修篡改。本来应该立马能定位到的,但是我却折腾了挺久,所以还是决定记录一下。
二、问题探究
1、源码复现
我们构造一个类似的源码,尝试来复现一下这个问题。我准备了以下的main.c:
#include <stdio.h>
#include <string.h>
// 按16进制打印字符串
void print_hex(char* buf, int len) {
int idx = 0;
while (idx < len) {
printf("%02x ", buf[idx++]);
}
printf("\n");
}
// 模拟字符串转换
void strTrans(char* dst, const char* src, const int len) {
memcpy(dst, src, 64);
}
int main(int argc, char** argv) {
// 1111111111111111111111111111111111111111111111111111111111111111
char strA_src[64] = {0};
char strA_dst[64] = {0};
// 2222222222222222222222222222222222222222222222222222222222222222
char strB_src[64] = {0};
char strB_dst[64] = {0};
printf("Input strA:");
scanf("%s", strA_src);
strTrans(strA_dst, strA_src, 64);
printf("Input strB:");
scanf("%s", strB_src);
strTrans(strB_dst, strB_src, 64);
printf("after trans strA:\n");
print_hex(strA_dst, 64);
printf("after trans strB:\n");
print_hex(strB_dst, 64);
return 0;
}
尝试编译运行一下,编译命令为:gcc -g -o exec main.c。运行后我们按照注释进行字符串的手动输入,strA为64个“1”,strB为64个“2”。运行结果如下图所示:

可以看到,我标红框的位置,字符串“strA_dst”的第一个字符已经异常,理论上“strA_dst[0]”的值应该为“0x31”才对。下面我尝试使用gdb对此问题进行定位。
2、问题定位
我在strTrans(strA_dst, strA_src, 64);和scanf("%s", strB_src);这两行进行设置断点。运行后在第一个断点处,我们步进一步后分别打印一下“strA_src”和“strA_dst”。结果如图:

可以看到此时“strA_src”和“strA_dst”都是我们期望的,是正确的。
然后我们再运行到第二个断点(scanf("%s", strB_src);)处,先打印一次看看“strA_dst”是否正确。

这时候还是正确的值,然后我们步进一步后,输入“strB_src”,然后再看一下“strA_dst”的值。

此时“strA_dst”已经发生变化了,因此可以确定是scanf()导致字符串内存被篡改,我们俗称“踩内存”。既然定位到了问题所在,那么我们先回顾一下scanf()的用法。
3、scanf原理探究
函数scanf()是从标准输入流stdin(标准输入设备,一般指向键盘)中读内容的通用子程序,可以说明的格式读入多个字符,并保存在对应地址的变量中。函数的第一个参数是格式字符串,它指定了输入的格式,并按照格式说明符解析输入对应位置的信息并存储于可变参数列表中对应的指针所指位置。每一个指针要求非空,并且与字符串中的格式符一一顺次对应。
找了一圈都是对scanf()的使用说明,没能找到解决问题的关键。这时候同事给了关键的信息,scanf()会自动在输入字符串后补充'\0'!如果是这样的话,那确实存在着溢出的问题,因为我给的数组长度一共是64字节,实际输入的字符数也是64字节,如果再自动追加'\0'字符,那么必定产生了数组越界的情况。此时我们来写一个小demo验证一下。
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv) {
char ch[10];
memset(ch, 0x31, sizeof(ch));
printf("init array by 0x1:\n");
for (int chIdx = 0; chIdx < 10; chIdx++) {
printf("0x%02x, ", ch[chIdx]);
}
printf("\n\nstarting scanf test!\n\n");
scanf("%s", ch);
printf("after scanf ch is:\n");
for (int chIdx = 0; chIdx < 10; chIdx++) {
printf("0x%02x, ", ch[chIdx]);
}
printf("\n");
return 0;
}
代码很简单,先定义一个长度为10字节的char型数组,并用0x31(字符‘1’的16进制ASCII码)进行初始化数组的每一个值。然后我们调用scanf()函数手动输入长度小于10的一个字符串,看看字符串的最后一位是否追加了'\0'。编译命令也很简单,只需简单的gcc编译就行。运行结果如下图所示:

在输入了4个字符‘2’之后,打印了数组里的每一个值,可以看到第5位的0x31,已经变成了0x00。证实了scanf()函数对字符串输入会追加'\0'的结论。
那么此次这个问题到这里就基本定位完了,下面我们尝试解决一下。
三、解决问题
最简单的一个思路,既然是越界了,那么我们遵守scanf的用法,将两个待输入的字符数组,扩大一位不就好了吗?
// 1111111111111111111111111111111111111111111111111111111111111111
char strA_src[65] = {0};
char strA_dst[64] = {0};
// 2222222222222222222222222222222222222222222222222222222222222222
char strB_src[65] = {0};
char strB_dst[64] = {0};
按照这种方式,是可以解决上面遇到的问题,但是这也是一个非常粗糙的规避手段,那我们有没有什么完美的解决方案呢?
- 使用
std::string:
鉴于C++的强大,我们可以直接使用std::string,利用它的自适应性,可以完美解决这个问题。 getchar()
利用getchar()函数进行循环输入,并且使用堆内存的开辟,这样也是一个非常好的解决方案。
以上的两种方法,第一种太简单就不做演示了,那就写个简单的demo演示一下第二种吧。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 初始化内存大小
#define INIT_SIZE 20
// 每次扩增的大小
#define EXPEND_SIZE 5
void print_hex(char* buf, int len) {
int idx = 0;
while (idx < len) {
if (0 == idx % 16 && 0 != idx) {
printf("\n");
}
printf("0x%02x ", buf[idx++]);
}
printf("\n");
}
int main (int argc, char **argv) {
char *pStr = NULL;
pStr = (char *)malloc(INIT_SIZE * sizeof(char));
if (!pStr) {
printf("[err] malloc failed!\n");
return -1;
}
// 记录当前字符串内存空间大小
int strSz = INIT_SIZE * sizeof(char);
memset(pStr, 0, INIT_SIZE * sizeof(char));
// 记录当前字符串有效长度
int strLen = 0;
// 存储字符的临时内存
char ch = 0x00;
// 用回车键作为输入的结束符,且不记录到字符串内
while ('\n' != (ch = getchar())) {
// 当字符串有效长度大于内存空间时,需重新开辟内存
if (strLen + 1 > strSz) {
char *pNewStr = (char *)realloc(pStr, strSz + EXPEND_SIZE);
if (!pNewStr) {
printf("[err] realloc failed!\n");
return -1;
}
pStr = pNewStr;
strSz += EXPEND_SIZE;
}
*(pStr + strLen) = ch;
strLen++;
}
printf("str: \n");
print_hex(pStr, strLen);
printf("%s\n", pStr);
return 0;
}
在这里,我用了换行符来作为输入结束的条件,且换行符并不会作为有效字符被记录。下面我贴上实际的运行过程:

可以看到,程序能自适应的获取到当前手动输入的字符串,并扩展其存储空间。
四、总结
这个问题拖了很久很久才陆陆续续完成了这篇总结内容。问题非常简单,但是却又极容易出现纰漏,还是需要把基础知识夯实再夯实呀!问题的解决方案有很多,也不仅仅局限于当前这两种方式,包括使用std::vector存储字符数组也是一种方法,当然了,这也是基于更灵活的C++的实现方式。我们在遇到问题的时候不能仅仅局限于当前,不能只想着如何规避问题,最为关键的应该是如何去解决问题,这才是一个合格的程序员所应该考虑的事情。可能我上面的解决方案还有不恰当之处,也恳请各位同行和大神指导斧正!
最后,我也会把此篇总结上传到我的个人网站中,链接如下:
http://www.ccccxy.top/coding/archives/2021/01/06/scanf-overflow_98/

浙公网安备 33010602011771号