十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

BUUCTF逆向题Youngter-drive:多线程同步与加密逻辑解析

BUUCTF逆向题Youngter-drive:多线程同步与加密逻辑解析 最近又刷了一遍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 用ShiftF12找关键字符串IDA打开程序后我最先做的是ShiftF12打开字符串窗口搜索程序输出提示和比较用的密文。这道题里能看到类似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)来判断原字符是不是小写因为密文可能已经不再是字母了。比如原始字符是fASCII 102加2后变成104即h仍然是小写字母但原始字符如果是z加2后变成|已经超出小写字母范围了。如果解密时拿着|去判断会被归到“其他字符”分支减去3那就错了。所以写解密脚本时不能完全依赖密文的字符范围要么把两种情况都试一遍要么结合flag格式去猜。典型flag都是flag{...}开头前两个字符f和l都是小写字母加密后它们应该各自加2利用这个已知信息就能确定解密的偏移方向。4. 编写脚本还原flag4.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”大概就是驱动线程和驱动逆向思路的意思理解了这个就不会被多线程的外壳吓住。刷题刷到最后你会发现逆向的本质就是还原数据在内存里的流转过程线程只是让这个过程看起来更复杂了一点。
返回列表