
这是我在NSSCTF平台刷Reverse方向遇到的两道题P1996和P2654一个偏逻辑混淆一个偏算法还原。两个题难度都不算高但放在一起刷完正好把很多逆向分析的基础手法串起来了——查壳、定位主函数、绕过混淆、识别加密结构、写Python脚本还原flag。这篇文章就把两题的完整分析过程和中间踩过的坑整理出来给同样在刷NSSCTF Reverse方向的朋友一个参考。1. 两题整体思路与考点拆解1.1 题目类型与刷题重点P1996和P2654在NSSCTF平台里都属于典型的教学型Reverse题目的不是考多高深的虚拟机保护或花指令对抗而是考你能不能老老实实把程序逻辑读明白并且用脚本把分析结果转化成flag。P1996的考点集中在数组操作和异或处理上。题目把输入的flag做了一系列的字节交换和异或再和程序内置的密文比对。这种出题思路在CTF里非常常见算是Reverse入门必练的基本功也是后续分析更复杂算法的基础。P2654的考点则更偏向加密算法逆向。题目用一个线性同余序列生成key流再用这个key流对输入做混淆属于典型的“伪随机流密码”结构。你需要从反汇编或反编译代码里识别出线性同余生成器的参数恢复种子状态然后逆向写出解密脚本。理解了这道题RC4、MT19937这类流密码的逆向思路也就通了。1.2 环境与工具准备两道题我全部在Linux环境下完成静态分析用IDA Pro动态调试用gdb写脚本用Python 3配合pwntools库。如果你平时用Windows把IDA换成x64dbg也完全可以只是下面的操作演示我按本地的Linux环境来。工具清单IDA Pro 8.3加载二进制后直接看反编译伪代码效率最高。gdb pwndbg用来动态验证函数调用关系也方便直接dump内存。Python 3 pwntools用来写爆破脚本和算法还原脚本。CyberChef做临时的十六进制转字符、异或验证很方便。提示NSSCTF的题多数是ELF文件在Linux下分析和动态调试比Windows方便很多。建议在虚拟机里装一个Ubuntu或Kali专门用来刷题gdb动态调试的体验会好很多。1.3 拿到题目后的第一个动作很多新手拿到一个ELF文件就急着拖进IDA按F5这个习惯其实不好。我一般会先做三件事第一用file命令查看文件类型。确认是32位还是64位是否strip过这些都是后续分析的基础信息。第二用checksec查看程序的安全属性。比如是否有PIE、Canary、NX虽然做题不一定会用到但了解这些能帮你预判程序结构也方便排查问题时快速定位。第三先把程序跑一遍。用几个不同的输入试一下观察输出行为。这个步骤非常关键能让你在正式分析前就对程序的交互逻辑有一个直观印象后续在IDA里定位主流程也会更快。拿P1996来说我第一次运行时输入了123456程序输出Try again!再输入一个长度为40的字符串程序还是输出Try again!没有额外报错。这些信息告诉我这个程序大概率就是“读输入—校验—输出结果”的典型结构主函数逻辑不会太复杂。2. P1996从逻辑混淆里扒出校验真相2.1 主流程定位别被无关函数带偏用IDA加载P1996后我在左侧函数窗口里快速扫了一遍导出和导入函数。程序符号没有被stripmain函数可以直接找到这是最省事的情况。双击进入mainF5反编译核心逻辑一下就出来了int __cdecl main(int argc, const char **argv, const char **envp) { char input[32]; char *flag; printf(Input your flag: ); __isoc99_scanf(%32s, input); if ( check(input) ) puts(Correct!); else puts(Try again!); return 0; }逻辑非常简单关键在check函数里。双击跟进发现里面有一段比较冗长的代码夹杂着很多看起来没什么用处的中间变量明显是故意做了一些代码上的混淆处理。_BOOL8 __fastcall check(const char *input) { char buf[32]; unsigned int idx; char tmp; strcpy(buf, input); for ( int i 0; i 32; i 2 ) { tmp buf[i 1]; buf[i 1] buf[i]; buf[i] tmp; } idx 0; while ( idx 0x1F ) { buf[idx] ^ get_byte(idx); idx; } return strcmp(buf, aP9dH...) 0; }这里的get_byte不是库函数而是IDA反编译时把一段内联计算识别成了一个局部函数。实际上它只是一个表达式根据idx返回一个固定的字节。我双击进去看了一下本质就是一个根据索引取常量的函数。注意在看反编译代码时像get_byte这种函数名是IDA自动生成的不代表程序里真的有一个独立的函数。只要看到这种模式就要意识到这里可能是一段内联的常量计算不妨把它当作黑盒来对待——先明确输入输出再决定要不要展开。2.2 核心校验的逻辑还原这段逻辑可以拆成两部分第一步循环交换偶数位置和下一位置字节。也就是i0时交换buf[0]和buf[1]i2时交换buf[2]和buf[3]以此类推。这是最简单的“两两交换”不涉及步长跳变还原起来没什么难度。第二步对交换后的每个字节异或一个随索引变化的常量。get_byte(idx)这个函数我把它对应的数据整理出来之后发现它相当于def get_byte(i): return (i * 7 3) 0xFF这样一看整道题的加密逻辑就非常清晰了对输入flag按奇偶位置两两互换。每个字节按idx异或(idx * 7 3)的值。和内置密文比较相同则输出Correct!。所以爆破的思路就是反向操作先把内置密文按同样的规律异或回去再两两交换恢复原始顺序得到的字符串就是flag。2.3 爆破思路与脚本实现内置密文在IDA里是十六进制ASCII我把它提取出来然后写Python脚本还原。这个过程中有一个很关键的点就是必须先还原异或再还原交换。因为加密顺序是“先交换、后异或”解密就是“先异或、后交换”顺序反了结果会完全不对。enc bytes.fromhex(LMNOP...) # 从IDA提取的密文 dec xor_table [((i * 7 3) 0xFF) for i in range(len(enc))] # 第一步按索引还原异或 tmp bytes([c ^ dec[i] for i, c in enumerate(enc)]) # 第二步两两交换 flag bytearray(tmp) for i in range(0, len(flag), 2): flag[i], flag[i1] flag[i1], flag[i] print(flag.decode())脚本跑出来前几个字符就已经能看出NSSCTF的字样了说明分析思路完全正确。后面跟着的是一段有意义的英文短语拼在一起就是这道题的flag。实操心得在写这类脚本时bytes.fromhex()提取的字符串里不能有多余的换行、空格或\x前缀否则转换会直接报错。我一般把IDA里的数据复制到文本编辑器里用正则把非十六进制字符全部替换掉再交给脚本处理能省不少时间。3. P2654伪随机序列加密的逆推3.1 从可执行文件里嗅出加密函数的味道P2654拿到手里第一感觉就不太一样。file一看是64位ELF程序入口点附近的代码明显复杂很多而且函数列表里出现了好几个带random、seed类似命名的符号。虽然没有被完全strip但符号已经做了混淆处理main的名字也改成了一个无意义的短字符串。我在IDA里先把main找到F5反编译后代码逻辑也不算复杂大体是这样的结构int main() { unsigned char flag[48]; unsigned char key[32]; unsigned long long state; state get_seed(); printf(Input: ); scanf(%32s, flag); for (int i 0; i 32; i) { state next_state(state); key[i] (state 8) 0xFF; flag[i] ^ key[i] i; } if (!memcmp(flag, target, 32)) puts(Correct!); else puts(Wrong!); }一眼看过去核心就是一个由next_state驱动的key流生成器每一轮生成一个字节的key对输入字节做flag[i] ^ key[i] i的操作最后和target比对。这种模式在流密码加密里非常常见。分析的重点就落在了next_state和get_seed的实现上。3.2 识别线性同余生成器双击跟进next_state反编译结果非常干净unsigned __int64 __fastcall next_state(unsigned __int64 a1) { return a1 * 0x5851F42D4C957F2DULL 0x14057B7EF767814FULL; }很容易识别出来这是标准的线性同余生成器LCG迭代公式是state state * a c其中乘法常数a 0x5851F42D4C957F2D增量c 0x14057B7EF767814F。在64位无符号整数运算下溢出会自动截断到64位相当于自然完成了模2^64的运算。再跟到get_seed它内部只是从一个全局变量里读了一个固定的初始值然后做了几次异或运算打乱。本质上初始种子是一个固定常量所以整个key流是完全确定的不需要动态调试也根本不存在随机性。这是这类题最容易迷惑人的地方——函数名叫random实际输出在程序编译时就已经定死了。我之前遇到过一次把LCG参数分析错了的坑原因是IDA反编译时把乘法显示成a1 * 0x5851F42D4C957F2DULL但如果你直接在Python里复现必须确保所有常量都是无符号64位整数否则Python的无限精度整数不会自动溢出结果就直接对不上。所以在脚本里我用了 0xFFFFFFFFFFFFFFFF来手动截断。3.3 加密变换的数学还原整个加密链路可以写成初始状态state seed第i轮加密前先更新状态state (state * a c) mod 2^64取key[i] (state 8) 0xFF加密操作target[i] input[i] ^ (key[i] i)那么逆向就非常简单了只要从target出发用完全相同的key流把异或还原回去生成key[i]还原input[i] target[i] ^ (key[i] i)由于加密和解密使用的是同一个key流这种结构被称为同步流密码。记住一个判断规律如果加密是c p ^ key那么解密就是p c ^ keykey流不需要变换只需要保持和加密时完全一致。from pwn import * a 0x5851F42D4C957F2D c 0x14057B7EF767814F seed 0x9E3779B97F4A7C15 # 从IDA中提取的初始种子 state seed target bytes.fromhex(AABBCCDD...) # 从IDA提取的目标密文 flag bytearray() for i in range(32): state (state * a c) 0xFFFFFFFFFFFFFFFF key (state 8) 0xFF flag.append(target[i] ^ ((key i) 0xFF)) print(flag.decode())执行结果同样直接出flag开头是NSSCTF{中间是一段明确的逆向后文本。3.4 分析过程中最关键的一步这道题我整个分析过程比较顺利但还是在(key i) 0xFF这个细节上卡了一下。注意到代码里key的取值是一个字节但key i的值可能超过255比如当key是200、i是100时加和是300。用Python打印时如果直接输出这个300在还原异或时就会出错。必须确保在异或前对key i做字节截断也就是(key i) 0xFF。换句话说很多C语言里的字节运算默认会做整数提升但在赋值给unsigned char时会截断。反编译代码里可能只显示成flag[i] ^ key[i] i看起来挺直接但实际操作时C语言会在赋值时自动取低8位。如果不注意这一点脚本写完了怎么跑都不对浪费二十分钟去排查。注意在线性同余生成器的逆向里最重要的不是会写脚本而是能准确识别出LCG参数并处理64位溢出。很多这类题把种子初始值藏在全局变量里或者在启动时从某个环境变量读取都需要你在IDA里仔细追踪数据流不能只盯着main看。4. 实战踩坑记录与逆向通用技巧4.1 卡了我最久的三个问题第一个坑是P1996里strcpy造成的长度误判。反编译代码里char buf[32]但strcpy(buf, input)并不检查长度如果输入长度正好32缓冲区不会越界如果脚本里按32字节去处理内置密文然后两两交换得到的flag可能会在末尾多出一个空字节。我第一次跑脚本时flag末尾多了一个不可见字符检查了半天才发现是在提取密文时多复制了一个\x00进去。以后凡是看到strcpy都要留意字符串终止符的位置。第二个坑是P2654的64位溢出去掉的坑。这个前面已经详细说了不再重复。但我想强调一个排查技巧如果你写完脚本跑出来的结果前几个字符是对的后面开始出错大概率是某个地方的溢出处理和你脚本里的不一致。这时候把加密和解密的中间状态每一轮都打印对比很容易定位到是第几个字节开始分叉从而找出是哪个常数写错了。第三个坑是动态调试和静态分析结果对不上。我在P1996一开始想用gdb动态调试看check函数里的循环到底怎么跑的结果发现程序在scanf之后直接跳到了一个奇怪的地址完全不是我预期的主逻辑。后来才意识到这份ELF启用了PIE并且编译器做了某些优化导致gdb里看到的地址和IDA里的静态地址差了随机偏移。解决办法很简单要么在gdb里set disable-randomization on要么干脆用IDA的调试器。4.2 从小白到入门的排查清单这里整理一份我刷Reverse题时常用的排查清单每次卡住就对着过一遍大多数问题都能解决先确认file输出和checksec结果不要跳过基础信息。用strings看看程序里有没有明显的内置常量或提示字符串有时能直接捞到有用的东西。定位到main后先画一个大致的函数调用关系图理清数据流方向。对于加密类题目优先寻找类似LCG、XOR、Base64、TEA、RC4的特征结构。写脚本时优先使用pwntools的u64、p64、xor工具函数减少手写出错的概率。如果脚本输出的结果只有一部分正确就逐字节打印中间状态和理论值对比。遇到数组索引和异或混淆时把循环展开几轮手算几个字节验证逻辑。4.3 做题时的工具使用习惯顺手分享几个我用下来的习惯。IDA里批量提取密文时我习惯用Hex View窗口复制数据再用正则清洗格式直接转成Python的bytes.fromhex()格式。这样比手动一个一个字节敲要稳定得多。gdb动态调试时如果是交互式输入我会先准备好一个输入文件然后用r input.txt来跑省去每次手动输入的麻烦也方便反复测试不同输入。注意输入文件最后要有一个换行否则scanf可能读不到结束符。脚本方面我通常会把LCG参数先单独打印出来验证一次确认生成的key流前几个字节和IDA里手动模拟的结果一致再去做完整还原。这个习惯帮我避免了很多因为参数敲错而浪费的时间。4.4 这类题目还能怎么变着考刷多了NSSCTF之后会慢慢发现P1996和P2654是两个比较基础的模板但出题人很喜欢在它们基础上叠加各种变化基于P1996把两两交换改成每隔三个字节交换或者把字节交换顺序打乱成一张置换表就变成了更复杂的置换加密。基于P2654把LCG换成MT19937或者把单字节key扩展成多字节key再混合输入长度就是非常经典的自定义流密码题目。在两道题的基础框架上加入S盒、多轮变换、加盐或者把比较函数改成逐字节延迟比较来对抗静态分析。我在刷其他平台题目时遇到过好几道题核心逻辑和这两道一模一样只是把混淆稍微加厚了一层或者把密钥生成部分的参数藏得更深了一些。所以这两个题吃透了后面很多中等难度的Reverse题都能有一种“基础盘”的熟悉感。最后再分享一点个人心得。在NSSCTF刷Reverse最容易提升能力的不是做难题而是把一个简单题的完整分析流程走完——从静态分析到数据提取再到脚本还原每一步都认真推敲为什么。P1996考的是你能不能识别出交换和异或的顺序P2654考的是你能不能还原线性同余生成器并处理溢出细节。这两个点比单纯记住某一种题型更重要因为在真实题目里算法结构永远是千变万化的但分析思路是相通的。多刷几道题去验证这套思路慢慢就会形成肌肉记忆了。