1. 从一道CTF题看strchr的真实行为1.1 这道题到底在考什么qwb2019_one 这道题我第一次看到的时候觉得挺简单——不就是个字符串处理吗结果上手之后才发现坑全在strchr这个函数的返回值判断上。题目本身逻辑不复杂给了你一段输入程序内部做了一些字符串操作最后根据操作结果决定是否给你 flag。但问题在于程序对strchr返回值的处理存在一个非常经典的误用导致攻击者可以绕过原本的检查逻辑。先说说strchr是干嘛的。C 标准库里的strchr函数原型是char *strchr(const char *s, int c);它的作用是在字符串s中查找第一次出现字符c的位置返回指向该位置的指针。如果没找到返回NULL。看起来很简单对吧但问题就出在“没找到返回 NULL”这个点上。很多人在写代码的时候会下意识地认为“没找到”就是返回 0 或者 false然后写出这样的判断if (strchr(input, x)) { // 认为找到了 x do_something(); }这段代码本身逻辑是对的——找到了返回非 NULL 指针条件为真没找到返回 NULL条件为假。但真正的坑在于当你要找的字符是\0的时候strchr会返回指向字符串结尾空字符的指针而不是 NULL。因为空字符也是字符串的一部分strchr确实“找到”了它。1.2 为什么这个误用能成为考点在 qwb2019_one 这道题里程序的核心逻辑大概是这样的读取用户输入然后检查输入中是否包含某些特定字符根据检查结果走不同的分支。其中一个关键分支用到了strchr来判断某个字符是否存在。出题人故意把要查找的字符设置成了可以通过输入控制的值或者利用了strchr对\0的特殊行为让选手可以构造出绕过检查的输入。我实测下来这类题目的核心思路通常是程序想检查输入中是否含有某个“危险字符”如果含有就拒绝。但如果这个“危险字符”恰好是\0而选手在输入中嵌入空字节strchr会返回非 NULL程序认为“找到了危险字符”从而拒绝——这看起来是安全的。但反过来如果程序逻辑是“如果没找到危险字符就放行”而选手通过某种方式让strchr返回 NULL就能绕过检查。更常见的情况是程序用strchr的返回值直接做指针运算比如strchr(input, c) - input来计算偏移。如果strchr返回 NULL这个减法就会得到一个巨大的负数或者未定义行为后续如果把这个结果当作数组索引就可能造成越界读写。注意strchr在查找\0时返回的是指向字符串结尾的指针不是 NULL。这个行为是 C 标准明确规定的不是实现差异。2. strchr 误用的几种典型场景2.1 返回值当布尔值用这是最常见的一种误用。很多人写代码时会把strchr的返回值和布尔值混为一谈if (strchr(input, A) 1) { // 错误strchr 返回的是指针不是 1 }这种写法在 64 位系统上几乎永远为假因为指针的值不可能是 1。正确的写法应该是if (strchr(input, A) ! NULL) { // 正确找到了 A }但在 qwb2019_one 里问题更隐蔽。程序可能用了类似if (!strchr(input, c))的写法来判断“没找到”然后走放行分支。如果攻击者能让c变成\0那么strchr永远返回非 NULL!strchr(...)永远为假放行分支永远走不到——这看起来是更安全了。但如果逻辑反过来程序用if (strchr(input, c))来判断“找到了就拒绝”那么当c是\0时strchr永远返回非 NULL程序永远拒绝这又会导致拒绝服务。真正的利用点在于程序可能先对输入做了一些处理比如把某个字符替换成\0然后再用strchr检查。这时候strchr的行为就会变得不可预测。2.2 指针减法计算偏移另一种典型误用是用strchr的返回值减去原始指针来计算字符位置int offset strchr(input, X) - input;如果strchr返回 NULL这个减法在 C 标准里是未定义行为。在实际运行中通常得到的是一个很大的负数因为 NULL 是 0减去一个非零指针得到负值。如果后续代码把这个 offset 当作数组下标使用就会造成越界访问。在 qwb2019_one 中我猜测出题人可能就是利用了这一点程序先计算某个字符的偏移然后根据偏移去访问一个数组或者做分支判断。如果攻击者能控制输入使得strchr返回 NULL就能让 offset 变成一个异常值从而绕过原本的边界检查。2.3 与 strlen 配合时的陷阱还有一种情况是strchr和strlen一起用char *p strchr(input, Y); if (p) { int len strlen(p); // ... }这段代码本身没问题但如果input中没有Yp就是 NULLstrlen(NULL)会直接崩溃。在 CTF 题目里这种崩溃有时候可以被利用来做信息泄露或者绕过某些检查。qwb2019_one 的考点很可能就是程序用strchr找到某个字符后直接对返回值做了解引用或者长度计算但没有检查 NULL。选手通过构造不含该字符的输入让程序崩溃或者进入异常分支从而拿到 flag。3. 复现环境与实操步骤3.1 搭建本地测试环境要复现这类问题你需要一个 Linux 环境或者 WSL安装好 gcc 和 gdb。我平时用的是 Ubuntu 20.04直接apt install gcc gdb就行。然后写一个模拟 qwb2019_one 核心逻辑的小程序#include stdio.h #include string.h int main() { char input[256]; printf(Input: ); fgets(input, sizeof(input), stdin); // 模拟题目中的检查逻辑 char *p strchr(input, A); if (p NULL) { printf(Check failed: A not found\n); return 1; } // 计算偏移 int offset p - input; printf(Offset of A: %d\n, offset); // 根据偏移做判断 if (offset 0) { printf(Flag: flag{test_flag}\n); } else { printf(Try again\n); } return 0; }编译的时候记得关闭栈保护方便观察gcc -o test test.c -fno-stack-protector -no-pie3.2 构造输入触发误用运行这个程序输入A开头的内容比如Ahello你会看到 offset 是 0程序输出 flag。但如果你输入hellostrchr返回 NULL程序直接走Check failed分支。现在关键来了如果我把要查找的字符改成\0呢修改代码char *p strchr(input, \0);这时候无论输入什么strchr都会返回指向字符串结尾的指针offset 等于strlen(input)。如果程序原本的逻辑是“offset 为 0 就放行”那么你只需要输入空字符串直接回车strlen为 0offset 就是 0直接拿到 flag。这就是strchr误用的核心当查找字符是\0时返回值永远非 NULL且偏移等于字符串长度。攻击者可以通过控制字符串长度来让偏移等于任意想要的值。3.3 用 gdb 观察返回值如果你想更深入地理解可以用 gdb 跟一下gdb ./test (gdb) break strchr (gdb) run输入内容后gdb 会停在strchr调用处。用finish命令执行完函数然后p $rax查看返回值。你会看到当查找\0时$rax指向的是输入缓冲区的末尾而不是 0。提示在 64 位系统上函数返回值放在rax寄存器里。strchr返回 NULL 时rax为 0返回有效指针时rax是一个高地址值。4. 常见问题与排查技巧4.1 为什么我的输入里没法嵌入空字节很多新手会问我想在输入里放\0但是用scanf或者fgets读入的时候空字节会被截断啊。确实标准输入函数遇到\0会当作字符串结束符。但在 CTF 题目里程序通常是用read系统调用直接读二进制数据的不会截断。如果你在本地测试时想模拟可以用 Python 的pwntoolsfrom pwn import * p process(./test) p.send(bhello\x00world\n)这样空字节就能成功发送过去。程序用read读取时会完整读到缓冲区里后续strchr查找\0时就会在hello后面那个位置返回指针。4.2 strchr 和 memchr 的区别有些人会混淆strchr和memchr。memchr的原型是void *memchr(const void *s, int c, size_t n);它会在指定的内存区域内查找字符不管里面有没有空字节。而strchr遇到空字节就停了。所以在处理二进制数据时应该用memchr而不是strchr。qwb2019_one 的误用本质上就是程序处理的是二进制数据但用了strchr这个面向字符串的函数导致空字节后面的内容被忽略或者空字节本身被特殊对待。4.3 排查这类问题的通用思路遇到疑似strchr误用的题目我一般的排查流程是这样的步骤操作目的1用 IDA 或 objdump 找到strchr调用点定位关键逻辑2查看第二个参数要查找的字符判断是否可能为\03检查返回值的使用方式看是当布尔值、做指针减法还是直接解引用4构造输入让返回值异常尝试 NULL 或指向末尾5观察程序分支变化确认是否绕过了检查这个流程在大多数涉及字符串函数的 CTF 题目里都适用。关键是要理解strchr的返回值不是一个简单的“有/无”标志而是一个指针指针的值和有效性都需要仔细处理。4.4 一个容易忽略的细节还有一个细节strchr的第二个参数是int类型但实际只用了低 8 位。如果你传一个大于 255 的值它会自动截断。比如strchr(s, 0x100 A)等价于strchr(s, A)。这个特性在某些题目里也会被利用比如程序从输入里读一个整数作为要查找的字符攻击者可以传入0x100截断后变成0也就是\0。我在实际做题时踩过这个坑当时一直想不通为什么输入256会让程序行为异常后来才反应过来256 0xFF 0strchr实际查找的是空字符。5. 防御与正确写法5.1 永远检查 NULL不管查找什么字符只要不是\0strchr都有可能返回 NULL。所以正确的写法是char *p strchr(input, c); if (p NULL) { // 处理没找到的情况 return -1; } // 安全使用 p如果确实需要查找\0那更应该明确知道返回值一定非 NULL且指向字符串末尾。这时候用strlen更直接。5.2 处理二进制数据用 memchr如果输入可能包含空字节或者需要查找空字节本身应该用memchrvoid *p memchr(buf, c, buf_len); if (p NULL) { // 没找到 }这样就不会受到空字节截断的影响行为也更可预测。5.3 指针减法前先判空如果确实需要计算偏移一定要先判空char *p strchr(input, c); if (p ! NULL) { int offset p - input; // 使用 offset }千万不要在没判空的情况下直接做减法否则就是未定义行为在不同平台上可能得到完全不同的结果。5.4 用静态分析工具辅助检查现在很多静态分析工具都能检测出这类问题比如 Coverity、Clang Static Analyzer 等。它们会标记出“返回值未检查就使用”的情况。我在写 C 代码时习惯打开-Wall -Wextra编译选项gcc 会对一些明显的误用给出警告。虽然不能覆盖所有情况但能帮你发现大部分低级错误。注意编译器警告不是万能的strchr返回 NULL 后做指针减法这种情况gcc 默认不会报警告需要靠代码审查或者专门的静态分析工具。6. 从这道题延伸出的思考qwb2019_one 虽然是一道 CTF 题但它反映的问题在实际开发中非常普遍。我见过不少真实项目里开发者用strchr检查配置项、解析命令行参数、处理协议数据然后因为没检查 NULL 导致崩溃或者逻辑绕过。尤其是在网络协议解析中如果攻击者能控制输入让strchr返回 NULL而后续代码又没处理就可能造成拒绝服务甚至更严重的问题。另一个值得注意的点是C 标准库函数的返回值语义往往比表面看起来复杂。strchr只是其中一个例子类似的还有strstr、strrchr、memchr等。理解这些函数的精确行为是写出健壮 C 代码的基本功。我个人在做代码审计时会特别关注所有返回指针的库函数调用点检查返回值是否被正确判空、是否正确处理了边界情况。这个习惯帮我发现过好几个潜在的崩溃点。对于 CTF 选手来说反过来利用这些误用往往就是拿分的关键。