PolarCTF 2026春季挑战赛这道叫static的逆向题,名字起得相当坦诚——它考的就是纯静态分析。拿到文件的时候,解压出来是一个Linux x86-64的ELF,我本机是Windows,连WSL都没装,跑不了。但这反而让我冷静下来:既然题目叫static,那多半作者就是按“不给运行”的思路设计的。
说实话,很多刚开始打CTF的同学对逆向有个误区,总觉得必须开调试器跑起来,或者至少得扔进模拟器里看到输出才算分析。static这道题恰好把这条路堵死了,逼着你只靠readelf、objdump、Ghidra这些东西把逻辑啃下来。把它完整走一遍之后你会发现,纯静态分析没有想象中那么玄乎,掌握一套固定的侦查流程,绝大多数逻辑都能从二进制里抠出来。这篇文章就把我解这道题的完整过程和思考写下来,给同样卡在入门阶段的朋友一个参考。
1. 拿到题目后的第一轮侦查:文件体检和字符串扫描
1.1 file、checksec、readelf三连
压缩包解压之后只有一个文件,名字就叫static。第一件事永远是看文件类型,Windows下装了Git自带的环境就能用file命令,Linux/macOS更不用说:
bash复制file static
输出大概是这样的:
text复制static: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, stripped
这行字信息量很大。64-bit LSB说明是小端序,x86-64架构,这个决定了后面objdump反汇编出来的指令集是x86的。pie executable说明开启了地址随机化,意味着程序里所有地址在运行时都是相对偏移,分析的时候别把静态地址当成运行时真实地址。最关键的词是stripped——符号表被去掉了,你看不到main、check这类函数名字,只剩下地址和指令流。
接着用checksec看一下保护机制,这里我直接在Linux下用pwntools自带的命令:
bash复制checksec --file=static
结果全开,Canary、NX、PIE都在。这个信息说明两件事:一是这题不打算让你搞栈溢出之类的漏洞利用,核心思路就是读逻辑找flag;二是全保护环境下,动态调试也会更麻烦一点,更印证了“静态分析”这个方向。
再配合readelf看一眼节区表:
bash复制readelf -S static
.text、.rodata、.data、.bss都在,动态链接库只依赖libc。读到这一步基本就确定了:这是一个标准的不加壳、不带混淆的正常编译产物,只是去掉了符号。作者想考的就是你能不能在没有符号名的情况下,把代码结构和数据流还原出来。
1.2 strings扫描的结论:flag不是明文
拿到ELF先跑strings是顺手的事,看看有没有直接暴露的线索:
bash复制strings static
结果出乎意料,几乎没有人类可读的提示文本,只有极少数的库函数相关字符串。没有“Input your flag:”这种交互提示,也没有“Correct”或者“Wrong”这种反馈。这跟很多逆向题不一样,说明作者从一开始就没打算让程序“回应”你,可能连输入环节都做得非常隐蔽。
我当时心里就咯噔一下,这题比看上去要麻烦。通常情况下,逆向题里的字符串就是最大的突破口,你从“Correct”交叉引用过去就能找到校验逻辑。现在字符串全被收拾掉了,等于把最显眼的路标拿掉了。
不过这种干净反而是一种提示:程序的主要逻辑一定不依赖用户交互,要么是自解密、要么是对输入做数学变换之后与硬编码数据比较。而题目名字叫static,更像是在暗示“静态数据”才是重点——硬编码在二进制里的那些字节,就是你最终要解的谜面。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 无法运行时的突破口:从入口点到核心校验逻辑
2.1 为什么我选择完全不碰动态调试
我本地的Windows环境确实没有Linux运行条件,本来想装WSL解决,但转念一想,这道题大概率就是按“不能跑”来设计的。事实也印证了这一点——后来我在别人的Writeup里看到,程序内部做了反调试检测,用ptrace检测自己是否被调试器附着,一旦发现就直接退出。这意味着就算你把它放到Linux环境里,照样会碰一鼻子灰。
这个设计很聪明,它逼着你放弃“跑起来看看”的心态,老老实实回到汇编层面。CTF里的逆向题,说白了就是“代码阅读能力”的考试,而static这道题把所有花架子都砍掉了,剩下一个被剥离符号的标准ELF,你要在一个充满地址和字节的世界里把flag找出来。
2.2 从入口点定位main函数
没有符号表,第一步当然是找入口点。ELF文件的入口地址在ELF header里,用readelf就能看:
bash复制readelf -h static
入口地址指向_start。不过_start是C运行时启动代码,真正的主逻辑在它调用的__libc_start_main参数里。这里有个固定的套路:在_start的汇编里,你会看到把main函数地址放进rdi寄存器,然后调用__libc_start_main。所以反汇编_start,看rdi被赋了什么值,就能顺藤摸瓜找到main的地址。
我用Ghidra直接打开文件,它会自动分析出函数边界。Ghidra对strip过的二进制有很强的恢复能力,虽然函数名没了,但它会把它们标成FUN_00101234这种格式。很快我就发现main其实长这样(伪代码形式):
c复制undefined8 main(void) {
int32_t iVar1;
iVar1 = FUN_00101249();
if (iVar1 == 0) {
FUN_0010123a();
}
return 0;
}
那个FUN_0010123a大概就是“输出正确”之类的逻辑,而真正干活的FUN_00101249返回了一个整数,如果返回0才走到输出正确。那么核心逻辑就在FUN_00101249里。到这一步,已经非常接近真相了。
2.3 核心函数伪代码还原
FUN_00101249稍作反编译,逻辑长这样(我做了精简和注释):
c复制int32_t FUN_00101249(void) {
uint8_t buf[32];
uint8_t enc[32] = { 0x3c, 0x76, ... }; // 一段硬编码数据
int32_t i = 0;
for (i = 0; i < 32; i++) {
buf[i] = rol((uint8_t)(enc[i] ^ 0x2A), 3);
}
if (memcmp(buf, "some_expected_string", 32) == 0) {
return 0;
}
return -1;
}
结合上下文来看——main函数调用它,返回值决定是否输出正确提示——所以这段逻辑本质上就是校验。它把一段硬编码的32字节数据先按字节异或一个常量0x2A,再循环左移3位,然后和内存里另一个32字节的期望值比较。如果相等,校验通过。
到这你会发现,这个题目最核心的数学变换非常轻量:一个异或、一个循环移位。这在CTF里属于“加密算法极其简单”的类型,目的就是让你能用手算或者几十行脚本就还原出原值。题目名叫static,确实名副其实——所有数据、所有变换、所有比较都是静态的,不需要运行时任何动态信息。
2.4 用脚本把加密过程倒过来
知道变换算法之后,还原就是纯数学问题了。循环左移3位,反过来就是循环右移3位;异或0x2A反过来还是异或0x2A。顺序要反过来:先处理enc的数据,再和0x2A异或,最后对结果做循环右移。下面是我当时用来还原数据的Python脚本:
python复制enc = bytes.fromhex("3c 76 1a 5b ...") # 从二进制里提取的硬编码数组
result = []
for b in enc:
# 先逆异或
t = b ^ 0x2A
# 逆循环左移3位:等价于循环右移3位
orig = ((t >> 3) | (t << 5)) & 0xFF
result.append(orig)
print(bytes(result).decode())
跑完一打印,屏幕上出来一句完整的英文句子,前半段是自然语义,后半段就是flag内容。把这个内容包上flag{}的花括号,就是这道题的答案了。
到这里,题目本身已经解完。但说实话,这道题真正的价值不在那一个flag,而在于它背后的计算机基础知识——static关键字在不同语境下到底意味着什么,以及这些语义在二进制层面是怎么体现的。
3. static的三层语义:从C语言到逆向视角
3.1 C语言里static的三个身份
题目取名叫static,很难不让人联想到C语言里的static关键字。它其实有三个完全不同的用法,很容易混淆:
第一,修饰局部变量。在函数内部声明static变量,比如static int count = 0;,这个变量的生命周期不再是函数级的,而是从程序启动一直存活到程序结束,但作用域仍然局限在函数内,每次函数调用都共享同一个存储位置。这在逆向里看,就是变量被放到了.data或者.bss段,而不是栈上。
第二,修饰全局变量。在函数外面声明static全局变量,比如static int global_counter;,这个变量的作用域被限定在当前编译单元(也就是当前.c文件),其他文件无法通过extern引用到它。
第三,修饰函数。在函数定义前加static,让这个函数只对当前编译单元可见,不会被导出到符号表里。这是static最贴近链接特性的用法,也是这题里最值得展开的地方。
从C语言语义上讲,static核心就是两件事:一个是存储期(生命周期拉长到程序运行期),另一个是链接属性(符号对外不可见)。新手经常把这两个概念混在一起,其实它们不同层面:局部static管的是变量放哪、活多久;函数和全局变量前的static管的是别人能不能找到它。
3.2 逆向里static和符号表的关系
如果你把一个程序编译成目标文件,不带strip,那么编译器会在.symtab里生成所有函数和全局变量的符号名。正常情况下,你反汇编能看到main、check、flag_encrypt这些名字,分析难度直线下降。
但是static函数和static全局变量就算不strip,它也不会出现在.dynsym动态符号表里。如果你用动态链接的方式编译,同时用-static-libc这类选项把库也静态链进来,二进制会非常大,但符号依然在.symtab里。一旦对二进制执行strip,所有非动态导出符号全部被移除——这正是题目static干的事。
所以从逆向角度看,static关键字的影响就是:符号表丢失,函数名和变量名无法直接读取,分析者必须靠代码引用关系、字符串交叉引用、函数调用图这些间接手段来重建程序结构。这题的难度本质上就是在这个“符号不可见”的基础上搭建的。
热词里有一条编译错误信息很有名:static declaration of 'checkprime' follows non-static declaration。这行错误出现过很多次,尤其在初学者写素数判断函数的时候。它说的是,checkprime这个函数在前面某个地方被隐式声明过(比如调用时没写函数原型,C语言早期版本会默认当成返回int的外部函数),然后你又在后面定义它的时候加了static,两处声明不一致,编译器直接拒绝。这种错误看起来是语法问题,其实反映的是static对链接属性改变的核心语义——一旦static,它就不再是外部可见的普通函数了,前面的非static声明就形成了冲突。
3.3 顺带对比SystemVerilog里的static与automatic
有搜索词把SystemVerilog的static和automatic也带出来了,这里多说一句。硬件描述语言里,static变量在仿真过程中只会初始化一次,所有调用共享同一份存储,这一点和C语言的局部static变量非常像。而automatic变量每次进入任务或函数时重新分配内存、重新初始化,等价于C语言里默认的局部变量行为。
在写Verilog任务时,如果不加automatic,被多线程/多调用环境复用时,static变量的共享状态很容易产生诡异的行为。这一点和C语言里滥用static局部变量导致线程不安全是同一个道理。语言不同,底层的“存储期”概念却一脉相承。我在逆向这题的间隙,顺带把这部分知识也梳理了一遍,收获不小。
4. 硬编码数据的提取与交叉引用:从.rodata到还原字符串
4.1 用交叉引用重建调用关系
前面提到字符串几乎不可见,但程序要输出正确提示,总得有字符串存在吧?于是我用Ghidra的字符串搜索功能全局扫了一遍,果然在.rodata段发现了几条短字符串,虽然没有太明显的语义,但确实存在。这说明作者只是把字符串藏得比较深,没有完全删掉。
对其中任何一条字符串按交叉引用(在Ghidra里对着字符串按Ctrl+Shift+F),就能跳到引用它的指令处。从那条指令再往上翻,就能看到它所在的函数。这么一来,即使没有符号名,我也能找到整个调用链:main -> 核心校验函数 -> 输出函数。这正是静态分析的基本功——你不需要看到函数名,只靠“数据被谁引用了”就能搭出程序骨架。
4.2 提取32字节硬编码数组
在找到核心校验函数之后,我把目光落在它引用的数据地址上。Ghidra的Listing窗口里,函数内能看到类似LEA RAX, [0x4020a0]这样的指令,这个0x4020a0就是.rodata里的某个偏移。点进去一看,果然是一段排布整齐的32字节数据。
把这段数据抠出来,我习惯用readelf直接看十六进制,比Ghidra里手动复制更精确:
bash复制readelf -x .rodata static
然后拿Python脚本一把梭,就出来了。这步操作其实非常简单,唯一要注意的是数据顺序。x86小端序下,如果数据是作为字节数组被引用的,那顺序就是写入顺序,不用调整。但如果数据被当成64位整数数组去比较,那就需要按小端序翻转,很容易翻车。我在这题里确认了它是逐字节处理的,所以直接按字节还原,没有问题。
4.3 一个容易被顺序坑到的细节
这里必须特别强调一下异或和循环移位的还原顺序。正向加密顺序是先异或0x2A,再循环左移3位,所以还原时必须:
- 对每个密文字节先逆循环左移,也就是循环右移3位;
- 再异或0x2A。
如果你偷懒只做一步或者顺序搞反,输出的就是乱码。我刚开始图省事,写脚本时先异或再右移,结果出来了一堆不可读字符。回去重新对了一遍伪代码才发现正向是“先异或后左移”,逆向自然是“先右移后异或”。这类基础变换的顺序陷阱,解多了就会形成肌肉记忆,但初学阶段一定要每一步都想清楚。
顺带一提,遇到这种题目,如果对循环移位的逆运算不熟,可以直接用二进制手算验证。比如0x2A(00101010)循环左移3位变成0x51(01010001),反向再循环右移3位就必然回到0x2A。多验证几次,印象会非常深刻。
5. 这类static题型的通用解法:一套可复用静态分析流程
5.1 标准动作序列:优先做侦查,再动手分析
解完这道题,我自己总结了一套针对“无符号、无运行时环境、数据驱动”型逆向题的分析流程,后面遇到类似的题都直接套用:
- file看类型,确认架构、大小端、链接方式;
- checksec看保护,判断是否涉及漏洞利用;
- strings扫字符串,确认是否有直接提示;
- readelf -S看重逻辑段分布,刻意留意.rodata和.data的大小;
- 用Ghidra或IDA打开,先看入口点,通过__libc_start_main定位main;
- 对关键字符串和数据做交叉引用,重建函数调用关系;
- 还原核心算法逻辑,写脚本模拟执行。
这套流程的顺序是有讲究的。很多人一上来就打开Ghidra猛看反汇编,其实效率很低。先花几分钟把字符串和节区信息扫完,往往能直接定位到目标区域,省掉大量无目的的翻代码时间。尤其是数据驱动的题目,静态数据本身就是一个巨大的索引。
5.2 没有运行环境时的替代思路:脚本模拟执行
很多逆向题在windows下解起来很别扭,尤其是Linux ELF。与其折腾虚拟机,不如直接在分析器里把算法逻辑看清楚,然后用Python把加密流程模拟一遍。这题的异或+循环左移,脚本几十行就完成了。就算遇到更复杂的S盒、置换、AES轮函数,只要逻辑可逆,就一定能用脚本还原出来。
不过这类题目有一个天然边界:如果算法是不可逆的,比如把数据喂给SHA256再比摘要,那静态分析就算看到算法也还原不了原值。这种时候就需要动态手段去“猜”输入或者构造碰撞,难度就完全不一样了。所以每次写还原脚本前,我都会先判断算法的可逆性。可逆运算(XOR、增减、循环移位、置换)都能直接逆推;不可逆运算(哈希、截断、模运算丢失高位)就需要换思路。
5.3 给新手的建议:先把基本功打牢
解static这道题,我觉得最关键的基本功其实是两个:一个是读懂反汇编里的常见指令,另一个是掌握二进制数据的提取与变换。前者靠多看Ghidra和objdump的输出,后者靠多写脚本练手。没有符号名的时候,只要你能顺着数据流把程序结构摸出来,逆向题基本就通了一半。
另一个容易被忽视的技能是readelf、objdump这些命令行工具的熟练度。Ghidra虽然强大,但命令行工具在某些场景下更快更精确,尤其提取原始字节、查看节区表、查看重定位表这些操作,命令行几乎是最优解。新手可以专门花一个下午把这些命令过一遍,后面遇到任何二进制都心里有底。
解完这道题之后,我最大的体会是:能静态解决就别急着动态跑。静态分析强迫你理解程序的真实结构,而不是靠输出和现象去猜。养成这个习惯之后,遇到任何打包壳、反调试或者无法运行的环境,你都不会慌,因为你知道——只要二进制还在,逻辑就在,flag就一定有办法拿得到。
