ARTICLE DETAIL

资讯详情

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

UPX脱壳与base62解码:BUUCTF逆向题[GUET-CTF2019]re解析

UPX脱壳与base62解码:BUUCTF逆向题[GUET-CTF2019]re解析 刷完几道简单题就兴冲冲点开 BUUCTF 的 reverse 列表看到[GUET-CTF2019]re这种短名字我第一反应是又来一道送分题。结果从晚上八点折腾到凌晨一点中途一度怀疑自己是不是真的适合搞逆向。卡住我的不是复杂算法而是两个特别基础的环节UPX 壳没处理好、以及把程序里一个自定义码表的编码函数当成了普通查表函数。后来把整个流程重新捋顺之后我发现这题其实非常适合拿来当“加壳逆向题”的标准练习样本。如果你也卡在这一题或者正准备系统学习 CTF 逆向这篇文章值得慢慢看完。1. 拿到题目先别急着分析查壳这件小事决定了后面顺不顺1.1 我用什么工具判断文件类型和壳拿到一个二进制文件第一个动作永远是看一眼它是谁而不是直接拖进 IDA。我习惯先在终端跑一遍file命令file re输出大概是ELF 64-bit LSB executable, x86-64。确认是 Linux 下的 64 位程序之后再用 ExeinfoPE 或者 DIEDetect It Easy查壳。DIE 对常见壳的识别很准它会直接告诉我这是 UPX 加壳。为什么查壳这么重要因为 UPX 加壳之后程序真正的入口不是 main而是一段解压 stub。程序运行时先把原始的代码和数据解压到内存然后才跳到真正的入口执行。如果你不脱壳直接拖进 IDA看到的往往是从入口跳转到一段看起来像乱码的代码F5 出来的伪代码基本不能看。我见过不少新手在这一步浪费大量时间本质就是壳没脱干净。另外立刻确认是 UPX 还有个额外好处这类壳十有八九可以用官方 upx 工具一键还原后面就不用走手动脱壳的辛苦路。1.2 脱壳upx -d 一条命令搞定但要知道它为什么能搞定确认是 UPX 后我直接尝试upx -d re跑完之后再用file re确认如果输出里已经没有UPX字样说明脱壳成功。实测下来UPX 版本最好保持在 3.94 到 3.96 附近。新版 upx 对旧格式偶尔会报NotPackedException: not packed by UPX这种情况大概率不是题目没加壳而是工具版本和壳的版本不对付。换个旧版 upx 再试通常就解决了。如果官方 upx 也脱不掉那就要手动找 OEP。核心思路是“esp 定律”对 UPX 这种壳几乎百试百灵用调试器加载程序停在入口第一句记录当前 esp 的值对 esp 指向的地址下硬件访问断点继续运行程序执行完解压 stub、跳回真实入口时会访问这个栈地址断点触发此时单步几下就能到达真正的 OEP。等走到一条retn或jmp跳到正常代码段的位置就可以 dump 内存了。为了验证脱壳效果我习惯把脱壳后的文件拖进 IDA看一眼节区名称。原始 UPX 文件的节名通常是UPX0、UPX1脱壳后应该变成正常的.text、.data、.rodata。看到这些正常节名基本可以放心往下分析了。提示查壳和脱壳虽然是重复劳动但每次都要做。不查壳直接分析是在给自己挖坑查完壳不验证同样可能带着一个半脱壳的文件浪费大量时间。2. 脱壳后进 IDA主函数的“假死”行为差点让我以为是反调试2.1 从 main 入口开始梳理调用关系脱壳后拖进 IDA按ShiftF12打开字符串窗口能看到不少提示信息。找Please input flag这类字符串或者flag相关引用双击交叉引用就能跳到调用它的函数。这道题的文件是 ELFIDA 能直接识别出int __cdecl main(int argc, const char **argv, const char **envp)按 F5 查看伪代码。第一次看到伪代码的时候我其实有点懵逻辑明明很简单就是读入字符串、调用一个函数、判断结果、返回。但为什么实际运行的时候输入什么内容都没有反应仔细看伪代码才发现main 里先通过scanf或read读入一串字符接着调用了一个校验函数。而这个校验函数的第一步就是在检查输入字符串的长度和开头格式。具体来说它要求输入字符串必须满足某个长度要求而且前几个字符得是flag{这种格式。一旦输入不符合条件校验函数直接返回 0整个程序看起来就像什么都没发生一样。这个陷阱很容易让人误判以为程序有反调试或者以为自己的脱壳方式出了问题。实际上就是输入格式不达标程序故意静默退出。2.2 那个关键的函数不是加密更像是在“编码”顺着 main 的调用链继续往下看我注意到了一个交叉引用非常集中的函数它只被 main 调用一次函数内部却放着一个超长的字符表内容是数字加大小写字母。如果只看字符串引用很容易把它当成数据段里的无关常量或者某个格式化输出函数用的字符集。但继续看汇编逻辑就会发现端倪这个函数的循环里不断出现除法、取模、查表、累加操作而且循环次数跟输入长度相关。这种形态不像典型的加密算法更像是一种编码函数。AES、RC4、TEA 这类加密算法通常伴随密钥扩展、S 盒替换、多轮异或代码结构复杂得多。而这个函数做的事情特别朴素把输入字节串当作一个大数反复取模、查表、拼字符。用大白话说它就是把一串字节转换成了另一种字符表示类似把十进制整数转成十六进制字符串只是基数更大、码表是自定义的。看到这里我已经能确定这道题的核心不在加密而在编码。接下来需要考虑的就一个问题这到底是什么编码以及如何把程序内嵌的目标字符串反向解回 flag。3. 认出自定义 base62 编码看到码表与除模运算的组合就该警觉3.1 为什么它不叫加密而叫编码很多人会把“编码”和“加密”混为一谈这在逆向分析里会带来致命误解。加密有密钥解密需要密钥编码没有密钥只是换了一种表示形式任何拿到算法和码表的人都能双向转换。题目里的这个函数没有密钥、没有初始化向量整个流程就是“大整数不断除以 62、取余数、映射到码表字符”这属于典型的进制转换型编码不需要爆破也不需要猜什么玄学逻辑认出来之后直接写逆向脚本就行。理解“编码 vs 加密”还有个实际好处当你意识到它是编码时程序里内嵌的目标字符串就是你唯一需要的“密文”剩下的就是照猫画虎写解码。如果你误以为它是加密可能会纠结于找 key、找初始向量方向直接偏掉。3.2 区分 base58、base62 还是 base64 的几个标志这道题最容易卡人的点是很多人把 base62 当成了 base64 变种。我一开始也犯了同样的错看到码表里有数字、大写字母、小写字母下意识以为它是 base64。后来数了一下字符数才反应过来码表一共 62 个字符根本不是 64 个。编码类型码表字符数编码方式特征码表典型内容base6464按 6 bit 分组每 3 字节变 4 字符代码里常见移位和掩码数字、大小写字母、、/base5858把字节串当大整数循环除 58 取余去掉0OIl等易混淆字符的字母数字base6262把字节串当大整数循环除 62 取余数字 大小写字母顺序排列自定义 baseN任意循环除 N 取余映射到自定义码表通常是可打印字符的排列判断方法很简单先数码表长度再观察有没有移位掩码操作。base64 类编码通常会对 6 bit 分组做位运算代码里能看到 6、 0x3F这类操作base58 和 base62 则是把整个输入当成大整数循环里出现的是除法和取模。本题的码表内容是0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz共 62 个字符循环里又是典型的除法取模确定是 base62 无疑。3.3 从伪代码还原编码算法把 IDA 的伪代码和汇编对照着看可以还原出下面这段 C 逻辑。程序先把输入字符串按字节存成一个数组然后把这个数组看作一个大整数进入循环// 伪代码用于理解题目逻辑 char table[] 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz; char output[256]; int idx 0; unsigned long long big_number read_input_as_bignum(); do { output[idx] table[big_number % 62]; big_number / 62; } while (big_number ! 0);最后程序会把output里的字符序列和一段内嵌的目标字符串做比较。代码还原到这个程度逆向工作其实已经完成了大半。剩下的就是从程序里取出目标字符串并按同一套规则反向解码。这里我要特别提醒一句看到“编码函数”不要去硬逆整个程序应该直接把它当成一个可逆函数来对待。程序怎么编码的你就怎么解码这是最高效的思路。4. 提取程序内嵌的目标串一行行把 flag“解”回来4.1 在数据段找到比较用的目标字符串目标字符串才是真正的“密文”。回到 IDA 伪代码窗口找到 compare 或者strcmp相关的位置双击硬编码的字符串就能跳到.rodata段。它通常是一长串可打印字符长度十几到二十几不等。复制的时候要小心把单引号里的内容完整取出来不要漏掉开头或结尾的字符。如果在字符串窗口看到的是db xxxxx这种形式直接复制引号内的文本即可。有的字符串可能包含反斜杠转义比如\n、\r如果出现这种字符说明目标串不是纯文本脚本里需要额外处理。不过这道题的目标串属于纯 ASCII 可打印字符复制出来直接用就行。提示如果你在 IDA 里怎么都找不到目标字符串可以看一下strcmp或者memcmp的调用点再跳转到它的第二个参数指向的地址。很多题目会把比较字符串放到一个不显眼的位置交叉引用是最快的定位方式。4.2 Python 解码脚本与执行细节解码脚本其实很短核心逻辑是 base62 解码把目标字符串从左到右遍历每个字符在码表里查出一个数值累乘 62 再累加最后得到一个大整数再把这个整数转成 bytes。table 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz target 这里替换成你从IDA里提取的目标字符串 def decode(s, table): n 0 base len(table) for ch in s: n n * base table.index(ch) # 大整数转bytes高位在前 length (n.bit_length() 7) // 8 return n.to_bytes(length, big) result decode(target, table) print(result)执行之后如果一切正常输出就是完整的flag{...}格式字符串。我这边跑出来的结果可以直接提交 BUUCTF。验证方法很简单运行原始程序./re把解出来的 flag 原样输入程序会给出正确提示。4.3 解码结果的常见坑字节序、前导零、多解第一次脚本跑出来很可能是乱码这不是脚本写错了多半是字节序问题。程序在编码时把输入看作大整数而大整数在内存里的表示有两种习惯高位在前或者低位在前。题目通常默认高位在前所以我用int.to_bytes(..., big)。如果解出来是一堆不可见字符改成little再试一次通常能解决。另一个常见坑是前导零。编码过程中如果输入的第一个字节是\x00程序可能把它当 padding 处理解码后字节串开头会多出一个空字符。这时候去掉开头的\x00再打印即可。还有如果解码结果长度不对或者中间出现大量不可打印字符优先检查码表顺序。有些题会故意把码表打乱这种情况下脚本里的table字符串必须和程序里的码表完全一致一个字符都不能差。我这次的实际经验是在 IDA 里把码表字符逐一对照确认没有大小写混排或顺序颠倒之后脚本一次就解出来了。所以如果解码不顺利先把码表核对三遍不要急着怀疑算法本身。5. 复盘这题教给我的逆向通用经验5.1 本次踩坑记录这题我踩了三个坑逐个说清楚。第一个坑是脱壳后的入口点不明确。使用upx -d脱完壳理论上 IDA 能直接定位 main但我的 IDA 版本偶尔会把入口识别成sub_XXX好在通过字符串交叉引用依然能找到主逻辑。这个问题的本质是 IDA 的自动分析不够完美遇到定位不准的情况别死磕入口直接搜字符串再反查引用。第二个坑是格式陷阱。程序对输入有严格的前缀和长度检查输入不满足条件就直接静默退出看起来像“程序坏了”。以后遇到运行后无任何输出、程序直接结束的情况先怀疑输入格式而不是怀疑环境或反调试。检查方式很简单在主函数里找到读入后的第一条 if 语句看它比较什么。第三个坑是我在识别编码时先入为主。看到 62 个字符的码表我第一时间套用了 base64 的思路结果怎么解都不对。后来从头数了一遍码表字符长度才恍然大悟是 base62。做逆向题不要急着猜结论数据摆在那里数一数、比一比往往比凭经验猜更靠谱。5.2 一套可复用的解题流程经过这道题我把自己的通用流程固定成了下面几步后续刷题里反复用到用file命令确认文件类型和架构。用 DIE 或 ExeinfoPE 查壳确认是 UPX 后直接upx -d。脱壳后用 IDA 打开先看字符串窗口通过提示字符串定位 main。从 main 的调用关系找出核心校验函数观察函数内部是否有码表和除法取模循环。数清码表长度判断是 base58、base62 还是 base64 变体。提取程序内嵌的目标字符串写 Python 脚本反向解码得到 flag。这套流程尤其适合 BUUCTF 里带 UPX 壳的逆向题。遇到码表题脚本可以直接复制改掉table和目标串剩下的不用重写。最后再分享一个小技巧如果解码出来的结果看起来像 flag但长度差一位或者某个字符不对很可能是编码函数里对输入做了特殊处理比如把输入整体右移、或者预先在头部插入固定字节。这时候不要只盯着解码函数也要回头看 main 对输入做了什么预处理。我在不少题上吃过这个亏——算法本身没错只是输入在进入编码函数前被动了手脚。养成对照汇编看伪代码的习惯能少走很多弯路。
返回列表