scanf的缺点

89

我想了解 scanf() 的缺点。

在许多网站上,我都看到过使用 scanf 可能会导致缓冲区溢出。这是什么原因?scanf 还有其他缺点吗?


4
参见A Beginners' Guide Away From scanf() - Jonathan Leffler
9个回答

85
到目前为止,大部分答案似乎集中在字符串缓冲区溢出问题上。实际上,可以使用与scanf函数配合使用的格式说明符支持显式字段宽度设置,这限制了输入的最大大小并防止了缓冲区溢出。这使得普遍指责scanf存在字符串缓冲区溢出危险的说法几乎毫无根据。声称scanf在这方面与gets类似是完全不正确的。scanfgets之间存在重大的定性差异:scanf提供了用户防止字符串缓冲区溢出的功能,而gets则没有。
人们可以争论这些scanf特性难以使用,因为字段宽度必须嵌入到格式字符串中(无法通过变参来传递,如printf中所做的那样)。这实际上是真的。在这方面scanf的设计确实相当糟糕。但是任何关于scanf在字符串缓冲区溢出安全方面已经彻底失效的说法都是完全错误的,通常是由懒惰的程序员提出的。 scanf的真正问题完全不同,即使它也涉及到溢出。当scanf函数用于将数字的十进制表示转换为算术类型的值时,它不提供从算术溢出中保护的保障。如果发生溢出,scanf会产生未定义行为。因此,在C标准库中执行转换的唯一正确方式是使用strto...系列函数。
因此,总结上述内容,scanf的问题在于使用字符串缓冲区时难以(尽管可能)正确且安全地使用。而对于算术输入来说,它无法安全使用。后者才是真正的问题。前者只是一个不便之处。

注:上述内容是关于scanf函数族的,包括fscanfsscanf。对于scanf函数来说,显而易见的问题是,使用一种严格格式化的函数来读取可能是互动式输入的想法本身就值得商榷。


6
我要指出的是,不能安全地读取算术输入,而不是根本不能读取。实际上,对于脏数据,您不能正确地进行计算,并且缺乏鲁棒性。对我来说,在我的程序崩溃或被攻击之间,以及当用户故意捣乱时只是得到一些错误的值之间有着巨大的区别。如果他们输入了1431337.4044194872987,最终得到4.0,我又怎么在意呢?无论如何,他们都输入了4.0。(有时可能很重要,但是会发生多少次呢?) - user645280
1
第三段:如果在字符串中遇到一个大于2^32的值,scanf将愉快地读取它到一个32位整数中,并导致未定义的行为? - 2501
@2501:是的,确切地说,至少根据语言标准是这样的。 - AnT stands with Russia
2
声称scanf在某些方面类似于gets是完全不正确的。我知道,scanf至少允许您指定最大字段大小,但是使用%s的意识形态肯定与gets具有相同的问题,并且与C中许多其他危险但有用的工具一样,它们都很容易被滥用。即使是strtoul也有其危险性,因此,与其建议人们停止使用C的部分,不如建议人们停止使用C的所有部分,这样更好吧? - autistic

72
scanf的问题(至少)包括:
- 使用`%s`从用户获取字符串,如果该字符串比缓冲区还要长,则会导致溢出。 - 扫描失败可能会使文件指针处于不确定状态。
我非常喜欢使用`fgets`读取整行数据,以便限制读取的数据量。如果你有一个1K的缓冲区,并用`fgets`将一行读入其中,你可以通过没有终止符来判断这行是否过长(当然,最后一行没有换行符也是例外)。
然后,你可以向用户抱怨,或在必要时为其余部分分配更多的空间(如果需要连续分配,直到有足够的空间)。无论哪种情况,都没有缓冲区溢出的风险。
一旦你读入了这一行,你就“知道”下一个位置在哪里,所以那里不会出现问题。然后,你可以尽情地对你的字符串使用`sscanf`,而无需保存和恢复文件指针以进行重新读取。
以下是我经常使用的代码片段,以确保在请求用户信息时不会发生缓冲区溢出。
如果需要,它可以轻松调整为使用除标准输入外的其他文件,你还可以让它分配自己的缓冲区(并不断增加其大小,直到足够大),然后将其返回给调用者(当然,调用者随后需要负责释放它)。
#include <stdio.h>
#include <string.h>

#define OK         0
#define NO_INPUT   1
#define TOO_LONG   2
#define SMALL_BUFF 3
static int getLine (char *prmpt, char *buff, size_t sz) {
    int ch, extra;

    // Size zero or one cannot store enough, so don't even
    // try - we need space for at least newline and terminator.

    if (sz < 2)
        return SMALL_BUFF;

    // Output prompt.

    if (prmpt != NULL) {
        printf ("%s", prmpt);
        fflush (stdout);
    }

    // Get line with buffer overrun protection.

    if (fgets (buff, sz, stdin) == NULL)
        return NO_INPUT;

    // Catch possibility of `\0` in the input stream.

    size_t len = strlen(buff);
    if (len < 1)
        return NO_INPUT;

    // If it was too long, there'll be no newline. In that case, we flush
    // to end of line so that excess doesn't affect the next call.

    if (buff[len - 1] != '\n') {
        extra = 0;
        while (((ch = getchar()) != '\n') && (ch != EOF))
            extra = 1;
        return (extra == 1) ? TOO_LONG : OK;
    }

    // Otherwise remove newline and give string back to caller.
    buff[len - 1] = '\0';
    return OK;
}

还有一个针对它的测试驱动程序:

// Test program for getLine().

int main (void) {
    int rc;
    char buff[10];

    rc = getLine ("Enter string> ", buff, sizeof(buff));
    if (rc == NO_INPUT) {
        // Extra NL since my system doesn't output that on EOF.
        printf ("\nNo input\n");
        return 1;
    }

    if (rc == TOO_LONG) {
        printf ("Input too long [%s]\n", buff);
        return 1;
    }

    printf ("OK [%s]\n", buff);

    return 0;
}

最后,进行一次测试以展示其实际效果:
$ printf "\0" | ./tstprg     # Singular NUL in input stream.
Enter string>
No input

$ ./tstprg < /dev/null       # EOF in input stream.
Enter string>
No input

$ ./tstprg                   # A one-character string.
Enter string> a
OK [a]

$ ./tstprg                   # Longer string but still able to fit.
Enter string> hello
OK [hello]

$ ./tstprg                   # Too long for buffer.
Enter string> hello there
Input too long [hello the]

$ ./tstprg                   # Test limit of buffer.
Enter string> 123456789
OK [123456789]

$ ./tstprg                   # Test just over limit.
Enter string> 1234567890
Input too long [123456789]

如果(fgets(buff,sz,stdin)== NULL)返回NO_INPUT; 为什么使用“NO_INPUT”作为返回值? “fgets”仅在出现错误时返回“NULL”。 - Fabio Carello
@Fabio,不完全正确。如果在输入任何内容之前关闭流,则它还会返回null。这就是此处捕获的情况。不要犯“NO_INPUT”意味着空输入(在输入任何其他内容之前按ENTER)的错误 - 后者将为您提供一个没有“NO_INPUT”错误代码的空字符串。 - paxdiablo
2
最新的POSIX标准允许使用char *buf; scanf("%ms", &buf);,它将使用malloc为您分配足够的空间(因此必须稍后释放),这有助于防止缓冲区溢出。 - dreamlax
1
如果我们将sz参数设置为1,调用getLine会发生什么?问题出现在if (buff[strlen(buff)-1] != '\n')。也许if (!sz) { return TOO_LONG; } if (buff[sz = strcspn(buff, "\n")] == '\n' || getchar() == '\n') { buff[sz] = '\0'; return OK; } unsigned char c; while (fread(&c, 1, 1, stdin) == 1 && c != '\n'); return TOO_LONG;确实不会在传递sz <= 1时溢出,并且具有零开销的去除'\n'的附加好处,尽管应该注意到您的代码可以通过策略性地使用scanf来增强... - autistic
1
这是一个很好的发现,@chux,我已经添加了额外的检查来将其视为“无输入”。使用printf "\0" | exeName进行测试以验证原始问题和修复。我想我从未检查过像那样疯狂的输入场景(但我应该做到)。感谢您的提醒。 - paxdiablo
显示剩余2条评论

22

来自comp.lang.c的常见问题解答:为什么每个人都说不要使用scanf?我应该使用什么代替它?

scanf存在很多问题-参见问题12.17, 12.18a,和12.19。而且,它的%s格式与gets()一样存在问题(参见问题12.23)-很难保证接收缓冲区不会溢出。[脚注]

更一般地说,scanf是为相对结构化的格式输入设计的(事实上,它的名称来源于“扫描格式”)。如果你注意,它会告诉你是否成功或失败,但它只能大致告诉你它在哪里失败了,而无法告诉你如何或为什么失败。你几乎没有机会进行任何错误恢复。

然而,交互式用户输入是最不结构化的输入。一个设计良好的用户界面将允许用户输入几乎任何内容-不仅仅是字母或标点符号,当期望数字时还可能包括更多或更少的字符,或者根本没有字符(即,只有回车键),或者过早的EOF,或其他任何东西。使用scanf处理所有这些潜在问题几乎是不可能的;使用fgets或类似方法读取整行,然后解释它们,使用sscanf或其他技术。(例如,函数strtolstrtokatoi通常很有用;还请参见问题12.1613.6。)如果你确实使用任何scanf变体,请务必检查返回值以确保找到了预期的项目数。此外,如果你使用%s,请确保防止缓冲区溢出。

顺便说一句,对scanf的批评不一定是对fscanfsscanf的指责。scanfstdin读取,这通常是一个交互式键盘,因此约束最少,导致问题最多。另一方面,当数据文件具有已知格式时,使用fscanf读取它可能是合适的。使用sscanf解析字符串是完全适当的(只要检查返回值),因为很容易重新获得控制,重新启动扫描,丢弃输入(如果没有匹配),等等。

附加链接:

参考文献:K&R2 Sec. 7.4 p. 159


6

要让scanf做你想要的事情非常困难。当然,你可以这样做,但是像scanf(“%s”,buf);这样的东西和gets(buf);一样危险,正如每个人所说的那样。

例如,paxdiablo在他的读取函数中所做的事情可以使用类似以下代码来完成:

scanf("%10[^\n]%*[^\n]", buf));
getchar();

上述代码将读取一行文本,将前10个非换行符字符存储到buf中,然后舍弃至包括换行符在内的所有内容。因此,paxdiablo的函数可以使用以下方式使用scanf实现:
#include <stdio.h>

enum read_status {
    OK,
    NO_INPUT,
    TOO_LONG
};

static int get_line(const char *prompt, char *buf, size_t sz)
{
    char fmt[40];
    int i;
    int nscanned;

    printf("%s", prompt);
    fflush(stdout);

    sprintf(fmt, "%%%zu[^\n]%%*[^\n]%%n", sz-1);
    /* read at most sz-1 characters on, discarding the rest */
    i = scanf(fmt, buf, &nscanned);
    if (i > 0) {
        getchar();
        if (nscanned >= sz) {
            return TOO_LONG;
        } else {
            return OK;
        }
    } else {
        return NO_INPUT;
    }
}

int main(void)
{
    char buf[10+1];
    int rc;

    while ((rc = get_line("Enter string> ", buf, sizeof buf)) != NO_INPUT) {
        if (rc == TOO_LONG) {
            printf("Input too long: ");
        }
        printf("->%s<-\n", buf);
    }
    return 0;
}

scanf存在另一个问题,就是在溢出的情况下会出现异常行为。例如,在读取int时:

int i;
scanf("%d", &i);

在发生溢出的情况下,以上内容不能安全使用。即使是第一种情况,使用fgets读取字符串比使用scanf更简单。


5

是的,您说得对。在scanf家族(scanfsscanffscanf等)中存在一个严重的安全漏洞,特别是在读取字符串时,因为它们没有考虑缓冲区的长度(它们要读入的缓冲区)。

例如:

char buf[3];
sscanf("abcdef","%s",buf);

显然缓冲区buf最多只能容纳3个字符。但是sscanf将尝试将"abcdef"放入其中,导致缓冲区溢出。


4
你可以使用"%10s"作为格式说明符,它会最多读取10个字符到缓冲区中。 - dreamlax
5
可以安全地使用API,就像可以安全地使用炸药来清理花园一样。但我不建议这样做,特别是因为有更安全的选择。 - Larry Osterman
4
我父亲过去在农场砍树时会使用硝化甘油胶囊,只需要了解工具并知道危险即可。 - paxdiablo
那个缓冲区只能容纳两个字符,因为你需要保留一个空终止符。 - Arthur Kalliokoski
2
@codaddict:某人在使用scanf时没有使用字段宽度,这是该人的问题,而不是scanf的问题。这与问题本身完全无关。毕竟这是C语言,而不是Java。 - AnT stands with Russia
2
问题在于scanf()中的字段宽度必须在转换说明符中硬编码;而在printf()中,您可以在转换说明符中使用*并将长度作为参数传递。但由于*scanf()中意味着不同的东西,所以这种方法行不通,因此您基本上必须像Alok在他的示例中那样为每个读取生成一个新格式。这只会增加更多的工作和混乱;最好使用fgets()并完成它。 - John Bode

5
scanf的优点是,一旦你学会了如何使用它——正如在C语言中应该做的那样——它有非常实用的用途。 你可以通过阅读和理解手册来学习如何使用scanf及其相关工具。如果你无法顺利理解该手册,那可能意味着你对C语言并不十分熟悉。
scanf和其它类似函数的设计存在不幸的选择,这使得在没有阅读文档的情况下正确使用它变得困难(有时甚至是不可能的),正如其他答案所示。不幸的是,这种情况在C中普遍存在,因此如果我建议不要使用scanf,那么我可能会建议不要使用C。 最大的缺点之一似乎纯粹是它在未接受培训的人中所获得的声誉;与C的许多有用特性一样,在使用它之前我们应该了解清楚。关键是要认识到,与C的其余部分一样,它看起来简洁而习惯,但这可能会产生微妙的误导。这在C中是无处不在的;初学者很容易编写他们认为有意义并且可能在最初能够工作的代码,但这些代码实际上是没有意义的,并且可能会发生灾难性的错误。
例如,未接受培训的人通常期望%s指令会导致读取一行,虽然这似乎很直观,但这并不一定是正确的。更恰当的描述是读取一个单词。强烈建议每个函数都阅读其手册。
没有提到安全性和缓冲区溢出的风险,哪有任何回答这个问题的意义呢?正如我们已经讨论的那样,C语言并不安全,它会允许我们取巧,可能会在牺牲正确性的情况下应用优化,或者更有可能是因为我们是懒惰的程序员。因此,当我们知道系统永远不会接收大于固定字节数量的字符串时,我们可以声明一个相应大小的数组,并跳过边界检查。我并不认为这是一种缺陷;这是一种选择。再次强烈建议阅读手册,这样我们就能发现这个选项。

懒惰的程序员不是唯一被scanf伤害的人。常见的情况是看到人们试图使用%d来读取floatdouble的值,他们通常错误地认为实现会在幕后执行某种转换,这是有道理的,因为类似的转换在语言的其他部分中也会发生,但在这里不是这样。正如我之前所说的,scanf和它的伙伴们(以及C的其余部分)都具有欺骗性;它们看起来简洁而且符合惯例,但实际上并不是这样的。

新手程序员不需要考虑操作的成功。假设用户输入了完全非数字的内容,而我们已经告诉scanf使用%d读取和转换一系列十进制数字。我们唯一能拦截这种错误数据的方法是检查返回值,但我们有多少次会费心检查返回值呢?
fgets类似,当scanf等函数无法读取所需内容时,流将处于异常状态。
  • 对于fgets,如果没有足够的空间来存储完整行,则未读取的剩余部分可能会被错误地视为新行。
  • 对于scanf及其相关函数,如上所述,转换失败后,错误数据将保留在流中,可能会被错误地视为不同字段的一部分。
使用scanf和相关函数并不比使用fgets更容易。如果我们在使用fgets时通过查找'\n'或者在使用scanf及其相关函数时检查返回值发现读取了不完整的行或无法读取字段,则面临同样的现实:我们很可能会丢弃输入(通常是直到下一个换行符为止)!太恶心了!
不幸的是,scanf既使得这种方式的丢弃输入变得困难(不直观),又使它变得容易(最少的按键)。面对这种丢弃用户输入的现实,有些人尝试过scanf("%*[^\n]%*c");,却没有意识到%*[^\n]委托遇到只有换行符时会失败,因此换行符仍然会留在流中。
通过分离两个格式委托,我们进行了一些微小的改进,并取得了一些成功:scanf("%*[^\n]"); getchar();。尝试使用其他工具进行如此少的按键操作 ;)

4

scanf等函数存在一个大问题-缺乏任何类型安全。也就是说,你可以编写以下代码:


int i;
scanf("%10s", &i);

你好,即使像这样也“还算可以”:
scanf("%10s", i);

printf 类似的函数相比,scanf 更糟糕,因为它期望一个指针,所以崩溃的可能性更大。

当然,有一些格式说明符检查器存在,但是它们并不完美,并且它们不属于语言或标准库的一部分。


这更多是一个历史性问题,因为大多数现代编译器将检查参数类型是否与格式字符串中指定的匹配,并在不匹配时产生警告。但是,我相信仍然有很多编译器没有这样做。 - Graeme

4

我对*scanf()系列有以下问题:

  • %s和%[转换说明符可能导致缓冲区溢出。是的,你可以指定最大字段宽度,但与printf()不同的是,在scanf()调用中,你不能将其作为参数传递;必须在转换说明符中硬编码。
  • %d、%i等可能导致算术溢出。
  • 检测和拒绝格式错误输入的能力有限。例如,“12w4”不是一个有效的整数,但scanf("%d", &value);会成功地转换并分配12给value,留下“w4”卡在输入流中,以污染未来的读取。理想情况下,整个输入字符串应该被拒绝,但scanf()没有提供简单的机制来实现这一点。

如果你知道你的输入总是格式良好的固定长度字符串和不会引起溢出的数字值,那么scanf()是一个很好的工具。如果你处理交互式输入或不能保证格式良好的输入,则使用其他方法。


1
读取固定长度字符串和数字值的其他合理替代方案有哪些? - Rajkumar S

4
许多答案都讨论了使用scanf("%s", buf)可能会导致溢出问题,但最新的POSIX规范基本上通过为cs[格式说明符提供一个m赋值分配字符来解决了这个问题。这将允许scanf使用malloc分配所需的内存(因此必须稍后使用free释放)。
使用示例:
char *buf;
scanf("%ms", &buf); // with 'm', scanf expects a pointer to pointer to char.

// use buf

free(buf);

请参考这里。但此方法的缺点是它是POSIX规范的较新添加,而且在C规范中根本没有指定,因此目前仍然不太可移植。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接