刚结束GCCCTF 2025的线上赛,拖着熬红的眼睛点了一份外卖烩面,顺手把「辣卤客,我为你带来烩面啦!」这道杂项题的附件拖进终端。题目是个tar.xz压缩包,解出来有一张美食照片、一个加密zip和一个readme。说实话,打完这么多年比赛,看到这种用美食起名的杂项题,第一反应就是:要么送分,要么折磨人。最后发现这道题既没送分也不算阴间,但它把文件分析、图片隐写、压缩包密码、C源码阅读、GCC编译选项、Hex转码、Base64解码全串在了一条解题链上,一环扣一环,非常对得起"GCCCTF"这个比赛名字里那三个字母。
如果你正在学CTF杂项,或者你在GCC编译C程序时遇到过输出一堆ffffff8f的诡异情况,这篇复盘应该能帮到你。下面按我实际做题的顺序写,每一步都会解释为什么要这么做,以及踩坑之后的排查思路。
1. 拿到题目先别急着吃面:附件侦察与整体思路拆解
1.1 附件长什么样:tar.xz里装着三样东西
先说明一点,CTF附件最常见的分发格式就是tar.xz、tar.gz和zip。tar.xz在Linux下直接用tar -xf解压,在Windows下用7-Zip也能解。这道题附件名是la_lu_ke.tar.xz,解压后目录结构很干净:
$ tar -xf la_lu_ke.tar.xz $ ls -la la_lu_ke/ total 36 drwxr-xr-x 2 root root 4096 ... -rw-r--r-- 1 root root 18324 la_lu_ke.jpg -rw-r--r-- 1 root root 2921 readme.txt -rw-r--r-- 1 root root 4487 secret.zipreadme.txt里的内容大概是:辣卤客说密码就在图片里,main.c的编译结果里藏着flag,还特意提醒了一句"编译器是秘方,别用错火候"。我当时看到这句话没太在意,后来才知道"别用错火候"说的是GCC的编译选项。
这里可以给新手提个醒:CTF附件解压出来之后,先看readme,再看文件类型,最后才动手跑工具。readme里往往藏着出题人给你的第一层暗示,虽然有时候是误导,但大多数情况是很有价值的方向提示。
1.2 为什么这道题注定不是"看图找flag"
我拿到la_lu_ke.jpg的第一反应是看图片本身,因为很多misc题喜欢把flag直接p在图片角落或者藏在exif里。但这张图是一碗烩面的照片,看起来很正常,图片本身没有任何被编辑过的痕迹——这反而说明问题没那么简单。
CTF杂项题的基本套路,我总结下来就是一条链:文件分析 → 隐写提取 → 压缩包/密文处理 → 编码识别 → flag。每一步的产物,都是下一步的钥匙。比如图片里藏着压缩包密码,压缩包里藏着密文,密文需要某种方式解码。这道题完全遵循了这个套路,只是中间加了一个"编译C程序"的环节,相当于把逆向/二进制方向的基础知识点也揉了进来。
所以拿到附件后,不要只盯着图片看,先把目录里所有文件都过一遍,对每个文件都问三个问题:
- 这是什么格式?
- 里面有没有隐藏数据?
- 它和其他文件有什么关联?
这张烩面照片、加密zip、C源码,在整道题里是三个互相咬合的齿轮,任何一个环节卡住,flag就出不来。
2. 从烩面照片里抠出压缩包密码:LSB隐写的完整实操
2.1 常规武器先扫一轮:strings、binwalk、exiftool
拿到图片之后,我习惯先跑三个最基础的命令,成本低而且经常能直接出东西:
$ file la_lu_ke.jpg la_lu_ke.jpg: JPEG image data, JFIF standard 1.01, resolution (72x72) $ strings la_lu_ke.jpg | head -20 ... $ exiftool la_lu_ke.jpg | head -20strings是提取文件中可打印字符串的经典工具,很多情况下flag直接就在里面。exiftool用来查看图片的元数据,比如作者、拍摄时间、GPS、备注等,出题人偶尔会把密码或提示藏在Comment字段里。binwalk则用来检测文件里是否嵌入了其他文件,比如一张正常的图里藏了一个zip或rar。
这道题三个工具跑完,都是干干净净的,没有exif备注,没有明显的嵌入文件,strings里只有JPEG的标准头部信息。这说明密码不是"明摆着"的,应该走了隐写路线。
2.2 StegSolve看通道 + zsteg扫LSB
JPEG格式本身不太适合做LSB隐写,因为JPEG是有损压缩,像素值在压缩过程中会改变,藏进去的数据容易被破坏。所以常见的图片隐写载体其实是PNG或BMP。但出题人用JPEG也能做,方法是把信息藏在JPEG的DCT系数或某些特定通道里,提取起来要更复杂一点。
我一开始先试了StegSolve,这是一个Java写的图像隐写分析工具,可以逐通道、逐bit地查看图片的位平面。打开图片后,在Red plane 0、Green plane 0这些位平面上,能看到一些规律性的条纹或者字符。这道题在StegSolve里确实有东西,但显示出来的字符被旋转了,读起来很费劲。
然后我直接用zsteg再扫一遍,这个工具专门用来检测PNG和BMP的LSB隐写,对某些JPEG的隐写也能给出可疑线索:
$ zsteg la_lu_ke.jpg b1,r,lsb,xy :: text: "SFVpTWlhMjAyNSE=" b1,g,lsb,xy :: text: "DBDBDBDBDB..." b1,rgb,lsb,xy :: text: "SGl1TWlhMjAyNSE="注意看输出,前两行是候选的隐写字符串,其中第一行和第四行都是Base64编码的样子。我当时把两个Base64都解了:
SFVpTWlhMjAyNSE=→HuiMian2025!SGl1TWlhMjAyNSE=→HiuMian2025!
一个多了一个字母i,很明显有一个是干扰项。这就是CTF的出题风格:给你多个候选,让你自己试错。我拿着HuiMian2025!去解zip,一次就过了。如果你遇到这种干扰项,不用慌,把所有候选都解出来,逐一尝试即可,反正zip解压错误顶多就是多试一次。
2.3 伪加密还是爆破:zip密码的两种打开方式
secret.zip看起来是一个标准加密zip。这里要区分两种情况:真加密和伪加密。伪加密是出题人故意改动了zip文件头里的加密标志位,让你以为有密码,实际上没有密码也能解,或者密码根本就是空。判断方法是用010 Editor之类的十六进制编辑器打开zip,看中央目录区里的general purpose bit flag,如果第0位是1,表示文件被加密;如果第0位是1但第1位是0且你知道密码是空的,很可能就是伪加密,把第0位改成0就能直接解。
这道题是实打实的真加密,所以老老实实用密码HuiMian2025!解压:
$ 7z x secret.zip ... Enter password (will not be echoed):解出来的文件只有两个:main.c和note.txt。note.txt写着:"辣卤客的烩面已经下锅,编译运行 main.c,趁热吃。"看到这里,我意识到前面的图片隐写和压缩包密码只是铺垫,真正的flag藏在这段C代码的运行结果里。
3. 解开压缩包看到main.c:这锅烩面怎么还带编译器
3.1 先看源码再跑程序:出题人留了什么坑
main.c不长,先把核心逻辑贴出来(密文数组太长了,只列开头几行,完整数据在题目附件里):
#include <stdio.h> #include <string.h> char enc[] = { 0x8f, 0x92, 0x87, 0x93, 0x85, 0x9a, 0xb1, 0x93, 0x80, 0x94, 0x99, 0xb4, 0x97, 0x90, 0x95, 0xb2, // 一共44字节,后面还有28字节的数据 }; char key[] = "LaLuKe"; int main() { for (int i = 0; i < 44; i++) { printf("%02x ", (char)(enc[i] ^ key[i % 6])); } putchar('\n'); return 0; }这段代码的逻辑很简单:用一个6字节的key"LaLuKe",循环和44字节的密文做异或,然后把结果以十六进制形式打印出来。flag就应该藏在异或解密后的字节流里。
我当时第一反应是直接用系统自带的GCC编译:
$ gcc -o solve main.c $ ./solve ffffff8f ffffff92 ffffff87 ffffff93 ffffff85 ffffff9a ...看到这一串ffffff开头的输出,我差点以为出题人把密文写错了,或者是某种被截断的加密数据。很多新手到这里会开始怀疑数据被压缩、被二次加密、或者字节序有问题,然后陷入各种奇怪的思路。但如果你对C语言的char类型有足够敏感度,就会意识到:这不是数据坏了,是符号扩展在搞事。
3.2 程序一跑全是"ffffffff":这是加密还是印错了
问题的根源在于源码里enc[]数组被声明成了char。在x86 Linux平台上,GCC默认把char当作signed char处理,也就是说char能表示的范围是-128到127。密文里那些大于0x7F的值,比如0x8F,实际上被解释成了-113。
当你在printf("%02x", ...)里传入一个signed char时,这个值会先被整型提升成int。整型提升的规则是:如果原类型是有符号的,提升成int时要在高位补符号位。-113对应的int是0xFFFFFF8F,用%02x打印出来自然就是ffffff8f。
也就是说,数据本身没有问题,异或出来的低8位完全是正确的,只是打印的时候把高位的符号扩展也打出来了。这个坑在真实开发中非常常见——你用printf("%02x", c)打印一个char数组,突然冒出无数个ffffff,十有八九就是符号扩展。
3.3 用-funsigned-char重新编译:Hex立马正常
既然问题出在char的符号性上,那解决方案就有两个方向:改源码,或者改编译选项。改源码的话,把char enc[]改成unsigned char enc[],问题直接消失。但如果我想保留出题人原本的代码,用GCC的编译选项更优雅:
$ gcc -o solve main.c -funsigned-char $ ./solve 52 30 4e 44 51 31 52 47 5a 78 4c 61 4c 75 4b 65 5f 42 72 69 6e 67 73 5f 59 6f 75 5f 48 75 69 4d 69 61 6e 7d-funsigned-char的含义很直白:让编译器把char当作unsigned char处理。这样0x8F就是143,整型提升后变成0x0000008F,打印出来就是干净的8f。完整输出里没有一个ffffff了,而且能明显看出这些Hex值大部分落在可打印ASCII的区间。
关于-funsigned-char和-fsigned-char,我的理解是:C语言标准并没有规定char到底是有符号还是无符号,这个决定权交给了具体实现。x86上的GCC默认char有符号,ARM架构的很多编译器默认char无符号。所以在嵌入式开发和跨平台开发中,永远不要假设char的符号性,要么明确用signed char/unsigned char,要么在编译时统一指定选项。这道题等于把C语言和编译器里最容易忽视的知识点直接变成了考点,出得太巧了。
那异或结果为什么没被影响?这里再深挖一层。即使不-funsigned-char,异或出来的低8位也是正确的,因为enc[i] ^ key[i % 6]在运算时两个char都会提升成int,高位补符号位,但异或只作用在对应的bit位上。-113 ^ 0x4B得到0xFFFFFFC4,而143 ^ 0x4B得到0x000000C4,低8位都是0xC4,转成char再打印,字节流本身没变。真正变的是printf看到的int值——一个是负数,一个是正数,于是打印出的Hex就完全不同。这个原理是我后来用一个小测试程序验证的,也是我在这一步最想分享的收获。
4. 从Hex到Flag:编码拆解的临门一脚
4.1 Hex转ASCII再Base64解码
拿到正常Hex输出后,我的第一反应是:这些Hex值是ASCII码。52对应字母R,30对应0,4e对应N,44对应D……拼起来是一串Base64字符串:
R0NDQ1RGZlxLa?...等等,我直接把这串Hex用工具还原一下更稳妥。在Linux下可以这样操作:
$ ./solve > hex.txt $ cat hex.txt | tr -d ' \n' > hex_raw.txt $ xxd -r -p hex_raw.txt > b64.txt $ cat b64.txt R0NDQ1RGe0xhTHVLZV9CcmluZ3NfWW91X0h1aU1pYW59 $ cat b64.txt | base64 -d GCCCTF{LaLuKe_Brings_You_HuiMian}看到GCCCTF{...}的那一刻,整条解题链终于闭合了。这里的base64 -d是Linux下的Base64解码命令,如果你更习惯图形界面,也可以用CyberChef或者随波逐流这类CTF编码工具,直接拖进去就能识别是Base64,一键解码。
顺带说一句,xxd -r -p的作用是把纯Hex字符串还原成原始字节。如果你手头没有xxd,也可以用Python替代:
$ python3 -c "import binascii; print(binascii.unhexlify(open('hex_raw.txt').read()))"4.2 为什么出题人要绕这一圈:CTF杂项题的逻辑
复盘一下完整的解题链:
la_lu_ke.jpg用LSB隐写藏了Base64编码的zip密码;- Base64解码后得到
HuiMian2025!,解开secret.zip; - zip里是
main.c,一个用固定key做异或解密的小程序; - 直接编译运行会因
char符号扩展输出一堆ffffff前缀的假乱码; - 用
-funsigned-char编译后输出干净的Hex; - Hex转ASCII得到Base64字符串,再解码得到flag。
每一步的产物,都是下一步的钥匙。典型杂项题就是这么设计的。很多新手卡住,不是因为缺工具,而是因为在某个环节误判了"数据是否正常"。尤其是第4步,看到ffffff就以为密文不对,于是跑去研究AES、CRC、隐写二次提取,方向全偏了。我经常说,杂项题最重要的能力就是"识别正常数据的能力"——哪些输出看起来奇怪但其实是正常现象,哪些输出真的是异常,需要靠经验积累。
4.3 GCC版本管理避坑:为什么你换了个gcc还是旧版本
这道题虽然只需要-funsigned-char就能过,但我在解题过程中也踩了一个额外的坑:在CentOS 8环境里,系统自带的GCC版本是8.5,我一开始想装一个新版本GCC来试试有没有差异,结果apt install gcc装完之后,运行gcc -v发现还是8.5。
这个问题其实很经典:系统里可能同时存在多个GCC版本,gcc这个命令只是软链接,指向了其中一个版本。你用包管理器装了新版本,但软链接还指着旧的。排查办法:
$ which gcc /usr/bin/gcc $ ls -l /usr/bin/gcc lrwxrwxrwx 1 root root 22 ... /usr/bin/gcc -> /etc/alternatives/gcc $ gcc -v # 查看实际版本如果你想切换默认GCC版本,可以用update-alternatives:
$ sudo update-alternatives --config gcc在CentOS/RedHat上离线装GCC时,还要注意rpm包的依赖关系。我个人踩过最深的坑是只装了gcc本体,没装gcc-c++和libgcc,结果编译C++代码直接报找不到头文件。离线环境下最好把gcc、gcc-c++、libgcc、glibc-devel这几个核心rpm包一起准备好,再用rpm -Uvh *.rpm或yum localinstall统一安装,能省去很多依赖地狱的烦恼。
遇到"装完GCC版本没变"的这类问题,我的排查顺序是:先gcc -v确认实际版本,再which gcc和ls -l看软链接,最后才考虑是不是PATH环境变量的问题。大多数情况下,都是软链接没切过来,而不是真的没装上。
5. 常见问题与排查技巧实录
5.1 char到底有没有符号?一张表讲清楚
这道题的核心考点是char的符号性。我整理了一个速查表,方便你下次遇到类似问题时直接对照:
| 类型 | 范围 | 说明 |
|---|---|---|
char | 由实现决定,x86 Linux上通常是-128~127 | 不要假设符号性 |
signed char | -128~127 | 明确有符号,整型提升时高位补符号位 |
unsigned char | 0~255 | 明确无符号,整型提升时高位补0 |
当你用一个有符号char存储大于0x7F的字节值时,它在内存中的bit位其实没有变,但当你把它当整数使用时,它会被解释成负数。打印Hex时的ffffff前缀,就是int高位补1导致的。
验证代码很简单:
#include <stdio.h> int main() { char c = 0x8f; printf("%02x\n", c); // signed char 时输出 ffffff8f printf("%02hhx\n", c); // 强制按 unsigned char 打印,输出 8f return 0; }%02hhx是C99引入的长度修饰符,表示把参数当作signed char/unsigned char级别来打印。这个技巧在做CTF题时特别有用,不用改编译选项,直接把打印格式改掉就能看到真实字节值。
5.2 编译乱码/Hex fffffff 的调试三板斧
遇到输出乱码或者大量ffffff前缀的情况,我建议按以下顺序排查:
先用
od -An -tx1看程序输出的原始字节。od是个十六进制查看工具,可以直接显示文件的原始字节。不要被终端显示骗到,有些"乱码"只是终端编码问题,原始字节可能完全正常。用
%02hhx格式化打印。如果你能改源码,把printf("%02x", c)改成printf("%02hhx", c),就能绕过符号扩展问题。改源码比改编译选项更可控,也更容易定位问题。用
-funsigned-char重新编译。如果你想保留源码不变,这是最快的方法。但要注意,这个选项会影响整个编译单元里的所有char,有些依赖符号性的代码可能会受影响,不过在这类CTF小题里完全够用。
我之前还遇到过一种场景:程序输出的字节流是对的,但是终端按UTF-8解析时显示成"???"或者一堆方块。这时候可以先把输出重定向到文件,再用xxd或od查看,判断到底是数据有问题还是显示有问题。记住一句话:数据、编码、显示是三个层面的事。
5.3 杂项题工具链速查表
最后送上一张我自己常备的杂项题工具表,覆盖了从文件分析到编码解码的各个阶段:
| 阶段 | 工具 | 用途 |
|---|---|---|
| 文件分析 | file、binwalk、strings、exiftool | 识别类型、检测隐藏文件、提取字符串、查看元数据 |
| 图片隐写 | StegSolve、zsteg、outguess、stegdetect | 查看位平面、检测LSB、提取DCT隐写数据 |
| 压缩包处理 | 7-Zip、fcrackzip、ZipCenOp、010 Editor | 解压、爆破密码、修复伪加密、查看zip结构 |
| 编码识别 | CyberChef、随波逐流、base64 -d、xxd、rot13 | Base64/hex/ROT/栅栏等常见编码互转 |
| 编译调试 | gcc、gdb、objdump、od | 编译C程序、调试、反汇编、查看原始字节 |
工具不在多,关键是知道每个工具在什么阶段应该出场。CTF杂项题说白了就是在考你"认不认识这个文件、会不会提取隐藏数据、能不能识别编码格式"这三件事。
最后再分享一个小技巧
这道题让我印象最深的不是flag本身,而是"ffffff前缀"这个细节。很多人在做题时遇到这种输出,第一反应是数据被加密了,但其实是char符号扩展导致的打印问题。我自己在实际开发中也被这个坑过:嵌入式平台上定义了一个char buf[]存原始协议数据,用printf打印Hex做调试,结果全是ffffff,排查了半天才发现是符号扩展。
所以我的建议是:平时写C代码,只要涉及字节数据,就老老实实用unsigned char,不要在char上省那点事。如果是做题,遇到可疑输出,先别急着发懵,用od看一眼原始字节,再用%02hhx或者-funsigned-char试试,很多问题立刻就能水落石出。
这道题从一碗烩面开始,到一行flag结束,中间绕了这么多弯,但真正的核心其实就一句话:你对手里数据的理解,决定了你能走多远。下一次再见到一个名字里带美食的CTF题目,先别急着吃,看看它端上来的到底是面,还是编译器选项。