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中没有'Y',p就是 NULL,strlen(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。但如果你输入hello,strchr返回 NULL,程序直接走Check failed分支。
现在关键来了:如果我把要查找的字符改成'\0'呢?修改代码:
char *p = strchr(input, '\0');这时候无论输入什么,strchr都会返回指向字符串结尾的指针,offset 等于strlen(input)。如果程序原本的逻辑是“offset 为 0 就放行”,那么你只需要输入空字符串(直接回车),strlen为 0,offset 就是 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 的pwntools:
from pwn import * p = process('./test') p.send(b'hello\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 | 查看第二个参数(要查找的字符) | 判断是否可能为'\0' |
| 3 | 检查返回值的使用方式 | 看是当布尔值、做指针减法还是直接解引用 |
| 4 | 构造输入让返回值异常 | 尝试 NULL 或指向末尾 |
| 5 | 观察程序分支变化 | 确认是否绕过了检查 |
这个流程在大多数涉及字符串函数的 CTF 题目里都适用。关键是要理解:strchr的返回值不是一个简单的“有/无”标志,而是一个指针,指针的值和有效性都需要仔细处理。
4.4 一个容易忽略的细节
还有一个细节:strchr的第二个参数是int类型,但实际只用了低 8 位。如果你传一个大于 255 的值,它会自动截断。比如strchr(s, 0x100 + 'A')等价于strchr(s, 'A')。这个特性在某些题目里也会被利用,比如程序从输入里读一个整数作为要查找的字符,攻击者可以传入0x100,截断后变成0,也就是'\0'。
我在实际做题时踩过这个坑:当时一直想不通为什么输入256会让程序行为异常,后来才反应过来256 & 0xFF == 0,strchr实际查找的是空字符。
5. 防御与正确写法
5.1 永远检查 NULL
不管查找什么字符,只要不是'\0',strchr都有可能返回 NULL。所以正确的写法是:
char *p = strchr(input, c); if (p == NULL) { // 处理没找到的情况 return -1; } // 安全使用 p如果确实需要查找'\0',那更应该明确知道返回值一定非 NULL,且指向字符串末尾。这时候用strlen更直接。
5.2 处理二进制数据用 memchr
如果输入可能包含空字节,或者需要查找空字节本身,应该用memchr:
void *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 选手来说,反过来利用这些误用,往往就是拿分的关键。