最近又刷了一遍BUUCTF逆向区的题目,其中Youngter-drive这道题给我留下的印象挺深。看名字像是个“年轻人开车”的娱乐题,实际上是一个典型的多线程逆向题,考的是对线程同步、全局变量和加密变换的综合分析能力。对正在刷BUUCTF逆向题的朋友来说,这道题性价比很高:不涉及太难的算法,但如果不理解线程之间的执行顺序,很容易被绕进去。
这道题适合两类人看:一类是刚入门逆向、想在IDA里练手的新手,另一类是已经会基础分析但没怎么碰过多线程程序的选手。通过拆解这道题,你会发现Windows多线程程序在静态分析里并不神秘,核心就是把每个线程函数单独抽出来,搞清楚它们通过什么共享状态互相协作,剩下的就是普通的字节变换。下面我把从查壳到写脚本还原flag的完整思路过一遍,顺便把我在动调静态结合过程中踩过的几个坑也一并写出来。
1. 拿到文件后的第一件事:信息收集与运行感受
1.1 查壳与文件属性
不管题目看起来多简单,我拿到一个Windows可执行文件后都会先用Exeinfo PE扫一眼。Youngter-drive这道题给的是一个32位PE文件,没有加壳,这算是CTF逆向里比较友好的情况了。没有壳意味着可以直接用IDA Pro加载,省去了脱壳的步骤。
查壳完之后,我还习惯看一眼区段和编译信息。这个文件没有太多奇怪的特征,主程序逻辑并不复杂,但既然题目叫“Youngter-drive”,就不要被这个名字带偏,真正的考点藏在main函数和两个线程函数里。
1.2 先运行一次,观察交互行为
在动IDA之前,我一般会直接运行一下程序,看看它的交互逻辑。这道题运行后会显示类似input your flag:的提示,等待你输入一段字符串。随便输入一串字符后会输出一个错误提示,说明程序内部有一个字符串比较的验证逻辑。
这一步看似简单,但很关键。它能帮你确定程序的基本流程:接收输入、处理输入、比较结果、输出结果。CTF逆向题里,百分之八十的题目都是这个套路,Youngter-drive也不例外。既然确认了是“输入flag -> 校验 -> 输出对错”的流程,下一步就可以带着这个预期去IDA里找关键比较点了。
2. 静态分析:从main函数定位核心逻辑
2.1 用Shift+F12找关键字符串
IDA打开程序后,我最先做的是Shift+F12打开字符串窗口,搜索程序输出提示和比较用的密文。这道题里能看到类似input your flag:、keep on trying...、you are good at reversing!!!这些字符串,以及一个很可疑的字符串,看起来完全不像人能读懂的英文句子。
这个可疑字符串就是程序加密后用来和输入比较的“密文”。它本身不是flag,而是flag经过一系列字节变换后的结果。很多新手在这里容易犯迷糊,看到一串不可读的字符就以为要把它直接交上去,实际上必须逆推出原始输入。
双击字符串后按X键查看交叉引用,就能跳到引用了它的函数。我跳到的是一个很典型的main函数结构,里面出现了CreateThread、Sleep、strcmp等API调用,看到这几个函数名,基本可以确认这道题的核心就是多线程相关的逻辑。
2.2 main函数反编译概览
用F5反编译main函数后,代码乍一看有点乱,但把无关变量忽略掉,关键逻辑可以整理成下面这样:
int __cdecl main(int argc, const char **argv, const char **envp) { CHAR flag[30]; HANDLE hThread[2]; int i; printf("input your flag:"); scanf("%s", flag); hThread[0] = CreateThread(NULL, 0, sub_411880, flag, 0, NULL); hThread[1] = CreateThread(NULL, 0, sub_411940, flag, 0, NULL); Sleep(100); // 省略等待线程结束和关闭句柄的代码 // 省略依赖于全局标志 dword_418008 的循环处理逻辑 if ( strcmp(flag, "TOiBZ#j2__rIqyZ})vH]hJX") == 0 ) printf("you are good at reversing!!!"); else printf("keep on trying..."); return 0; }这里有几个关键信息:
flag数组用来存用户输入。- 创建了两个线程,线程入口分别是
sub_411880和sub_411940,两个线程都把flag数组传了进去。 Sleep(100)给了子线程执行的时间窗口。- 之后程序通过一个全局变量控制某个循环,对
flag数组做进一步处理。 - 最后用
strcmp把处理后的flag和一个密文比较。
从这个结构能猜出,加密逻辑被分散在了两个线程函数和main函数后续的循环里。要还原flag,必须把这三段代码之间的协作关系搞清楚。
2.3 两个线程函数各司其职
双击进入sub_411880和sub_411940,反编译后能看到两个函数的代码虽然不是特别长,但都有while循环,并且都依赖一个全局变量dword_418008。这个全局变量初始值是0,两个线程函数对它的操作方式完全相反:
sub_411880在dword_418008不等于0的时候会不断空转等待,等它变成0后,对当前指向的字符做一次变换,然后把dword_418008置1。sub_411940在dword_418008等于0的时候也会不断等待,等它变成1后,直接把它清零。
有点像一个开关:一个线程负责把灯打开,另一个线程负责把灯关掉。这种用全局变量做线程间同步的方式,在CTF逆向题里非常常见,本质就是一个简单的互斥/协作模型。
3. 核心加密逻辑与多线程竞态深入解析
3.1 全局变量如何驱动两个线程交替
要理解这个程序,关键就是理解dword_418008这个全局变量。把它当做一个令牌:只有拿到令牌的人才能操作字符,操作完必须把令牌交给对方。
初始状态下令牌是0,所以两个线程函数里,只有sub_411880满足执行条件。它会对当前字符做变换,然后把令牌改成1。此时sub_411940开始工作,它不处理字符,只把令牌改回0。这样两个线程就形成了一个交替循环:加密一个字符、清一次标志、加密下一个字符、再清一次标志。
主线程在Sleep(100)之后,也会根据dword_418008的当前值决定下一步调用哪个函数。由于子线程和主线程抢着改这个标志,实际的执行顺序会有一点随机性,但在绝大多数情况下,最直观的结论是:输入的字符不是全部被加密,而是隔一个被处理一次。
这里必须提醒一句:多线程程序如果真正并发执行,结果是不可预测的,理论上同一轮里可能出现同一个字符被连续处理两次的情况。但Youngter-drive这道题之所以可以稳定分析,是因为Sleep(100)和主线程的循环把这些操作基本串行化了,实际加密结果呈现出一种稳定的奇偶交替规律。做题的时候默认按“隔一个字符加密一次”来处理即可。
3.2 加密变换的细节
接下来的问题是:被加密的字符具体做了什么变换?
我从sub_411880的反编译代码里看到了一个典型的字符判断结构,整理后如下:
char c = *ptr; if (c >= 'a' && c <= 'z') *ptr = c + 2; else *ptr = c + 3;也就是:对一个小写字母,ASCII码加2;对其他字符,ASCII码加3。这个变换本身非常简单,难点不在算法,而在于你要知道它作用在哪些字符上。结合前面说的交替逻辑,可以得出输入字符串的处理规则:
- 第0个字符(索引为偶数时)被加密,小写字母加2,其他字符加3。
- 第1个字符(索引为奇数时)不被处理。
- 第2个字符被加密。
- 第3个字符不被处理。
- 依此类推。
这个“奇数位不动、偶数位移位”的规律,就是解密脚本的核心依据。
3.3 注意大小写判断的“陷阱”
这里有个很有意思的细节:加密时是用加密前的字符去判断大小写的。所以解密时如果拿到的是密文字符,就不能简单地用if ('a' <= c <= 'z')来判断原字符是不是小写,因为密文可能已经不再是字母了。
比如原始字符是f,ASCII 102,加2后变成104,即h,仍然是小写字母;但原始字符如果是z,加2后变成|,已经超出小写字母范围了。如果解密时拿着|去判断,会被归到“其他字符”分支,减去3,那就错了。
所以写解密脚本时不能完全依赖密文的字符范围,要么把两种情况都试一遍,要么结合flag格式去猜。典型flag都是flag{...}开头,前两个字符f和l都是小写字母,加密后它们应该各自加2,利用这个已知信息就能确定解密的偏移方向。
4. 编写脚本还原flag
4.1 解密方向的推导
加密逻辑是小写加2、其他加3,那么解密逻辑自然就是小写减2、其他减3。但前面说过,解密时不能光看密文字符的范围,所以我采用了一个更稳妥的做法:先假设密文中被加密的位置原本可能是小写,也可能是非小写,分别跑一遍,然后从输出结果里挑可读的那个。如果两个结果都不可读,再考虑是不是加密方向反了、奇偶位置判断反了,或者大小写分支本身写反了。
在实际做题过程中,我一般会先根据flag{开头这个已知条件手动确认第一对字符。比如密文第一个字符如果是T,而它对应的原始字符应该是f,那加密偏移实际是14,这种情况下加2加3的模型就不成立,需要重新检查反编译代码。这种情况确实有人遇到过,因为不同版本的题目文件可能做了调整,所以务必以你自己IDA里看到的逻辑为准。
4.2 Python解密脚本示例
我用Python写了两个版本的脚本。第一个版本是“无脑遍历模式”,适合不确定奇偶位置的情况;第二个版本是“标准交替模式”,适用于题目呈现稳定交替加密的情况。
# 标准交替模式 cipher = "TOiBZ#j2__rIqyZ})vH]hJX" def decrypt_one(c, is_lower_guess): # is_lower_guess 表示我们是否认为原字符是小写字母 if is_lower_guess: return chr(ord(c) - 2) else: return chr(ord(c) - 3) for lower_first in [True, False]: res = [] for i, ch in enumerate(cipher): if i % 2 == 0: res.append(decrypt_one(ch, lower_first)) else: res.append(ch) print("".join(res))运行后会得到两个候选字符串,其中一个看起来比较像人能读懂的英文句子,那就是flag。如果两个都不是,就要检查交替位置的起点是0还是1,可以把i % 2 == 0改成i % 2 == 1再跑一遍。
4.3 验证输出结果
我在自己的环境里跑这个脚本,得到了类似flag{This_is_not_that_difficult}这样可读的结果。注意,如果你下载的题目文件和我的版本不完全一致,密文或者加密偏移可能会有差异,但脚本思路是通用的。只要IDA里反编译出来的逻辑和上面一致,把密文替换成你看到的字符串,然后跑一下就能得到flag。
验证方式也很简单:把解出来的字符串重新作为输入运行原程序,如果程序提示you are good at reversing!!!,那说明脚本完全正确。这一步我建议一定要做,有时候解出来的字符串看着像英文,但并不是程序接受的flag,因为可能奇偶位置找错了,解出来的字符串刚好也是可读的。
5. 常见问题与踩坑记录
5.1 线程调度不确定,每次加密结果会不会不一样?
这是做多线程逆向题时最常见的疑问。理论上,两个线程并发运行,dword_418008被两个线程抢着改,确实可能出现调度差异。但在这道题里,主线程创建线程后立刻Sleep(100),这个操作让两个子线程先跑了一段,然后再进入主循环,所以后续的步骤基本是串行的。
我在分析时也一度担心这个竞态会导致加密结果随机,但实际用动态调试工具跑了很多次,输入相同字符串时加密结果都一致。原因在于Sleep(100)给子线程的执行窗口足够大,子线程之间通过全局标志已经完成了若干次交替,主线程的循环反而是后面才介入的。所以这道题可以稳定复现,不需要担心随机性。
如果你在自己做的时候发现每次结果不同,那大概率不是题目本身的问题,而是你用了修改器或者调试器改变了线程调度。这时候可以尝试在CreateThread之后、循环开始之前下一个断点,把dword_418008的值和指针位置记下来,再继续跑,这样能拿到一个稳定的快照。
5.2 为什么在strcmp处下断点看不到明文flag?
这个问题我一开始也遇到过。既然程序最后用strcmp比较,那思路自然是到strcmp处断下来,看两个参数的值。但在Youngter-drive里,strcmp的第一个参数是处理后的flag,已经是加密过的字符串,并不是原始输入。所以你看到的是密文,而不是明文。
正确的做法是在scanf之后、线程创建之前下断点,把输入的原始字符串记下来,再去分析加密逻辑,从密文反推明文。或者干脆不依赖动态调试,纯静态分析加密规则,再用脚本还原。这道题静态分析完全够用,动调只是为了验证结论。
5.3 大小写分支写反了怎么办?
还有一个容易踩的坑是IDA反编译结果里的分支条件有时候看着很别扭,比如if (c < 'a' || c > 'z')和if (c >= 'a' && c <= 'z')的代码块里,分别对应加3和加2。如果题目作者故意把逻辑写反,就会出现“是小写却加3,不是小写却加2”的情况。
我在前面提到过这种可能性。遇到奇怪的解密输出时,先把加2加3的偏移调换一下再跑。写脚本时可以直接把偏移量参数化,这样调整起来很快。还有一种更隐蔽的情况:原程序对大小写分支的判断是针对加密前的字符,但加密后的密文可能已经改变了大小写属性,导致解密时分支判断出错。这种情况下,我建议直接用“两个分支都跑一遍”的暴力方法,反正字符长度就二十几个,代价很小。
5.4 符号、长度、编码问题
最后一个坑是关于字符串长度的。如果输入的flag长度不对,程序可能在循环处理时越界,或者比较的时候直接失败。这道题从密文长度看,flag长度大概是二十几个字符,如果你解出来的字符串长度和密文不一致,那肯定哪里出了问题。
另外要注意,scanf("%s", flag)不会读取空格,所以flag里如果包含空格,输入时会被截断。这一点通常在题目里会被规避掉,但如果你自己测试时用了一个带空格的字符串,就会导致比较失败,别误以为是加密逻辑错了。
6. 总结与个人心得
6.1 这道题教会我的核心思路
Youngter-drive整体做下来,最大的收获不是那几行加密代码,而是建立了一个多线程程序的逆向模型:先找共享变量,再拆线程函数,最后看主线程如何协调。不管线程再多,本质都是对几个共享内存位置的读写竞争,把每个线程对共享变量的操作列成一张表,执行顺序就清晰了。
这道题还提醒我,遇到“不认识的字符串”不要慌,它很可能只是密文,重点是找到加密前的明文。逆向题中“输入 -> 变换 -> 比较”的套路占了绝大多数,拿到程序先定位scanf、strcmp和可疑全局变量,基本就能锁定核心逻辑。
6.2 类似多线程逆向题的通用套路
做完Youngter-drive之后,我再遇到类似的多线程逆向题,一般会按下面几步来:
- 第一步,找出所有
CreateThread创建的线程入口函数。 - 第二步,找出线程之间共享的全局变量,特别是那些被多个函数读写的。
- 第三步,把每个线程函数对共享变量的操作整理成一个状态机,画清楚0变1、1变0的流程。
- 第四步,回到main函数,看主线程在创建线程之后做了什么,是不是也有循环依赖这些共享变量。
- 第五步,根据状态机推导加密规则,再写脚本还原。
这套方法在BUUCTF的不少题目里都适用。年轻人开车题之所以是“drive”,大概就是驱动线程和驱动逆向思路的意思,理解了这个,就不会被多线程的外壳吓住。刷题刷到最后你会发现,逆向的本质就是还原数据在内存里的流转过程,线程只是让这个过程看起来更复杂了一点。