news 2026/10/7 16:52:28

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 或者 DIE(Detect 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,按Shift+F12打开字符串窗口,能看到不少提示信息。找Please input flag这类字符串或者flag相关引用,双击交叉引用就能跳到调用它的函数。这道题的文件是 ELF,IDA 能直接识别出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 一套可复用的解题流程

经过这道题,我把自己的通用流程固定成了下面几步,后续刷题里反复用到:

  1. 用file命令确认文件类型和架构。
  2. 用 DIE 或 ExeinfoPE 查壳,确认是 UPX 后直接upx -d。
  3. 脱壳后用 IDA 打开,先看字符串窗口,通过提示字符串定位 main。
  4. 从 main 的调用关系找出核心校验函数,观察函数内部是否有码表和除法取模循环。
  5. 数清码表长度,判断是 base58、base62 还是 base64 变体。
  6. 提取程序内嵌的目标字符串,写 Python 脚本反向解码得到 flag。

这套流程尤其适合 BUUCTF 里带 UPX 壳的逆向题。遇到码表题,脚本可以直接复制改掉table和目标串,剩下的不用重写。

最后再分享一个小技巧:如果解码出来的结果看起来像 flag,但长度差一位或者某个字符不对,很可能是编码函数里对输入做了特殊处理,比如把输入整体右移、或者预先在头部插入固定字节。这时候不要只盯着解码函数,也要回头看 main 对输入做了什么预处理。我在不少题上吃过这个亏——算法本身没错,只是输入在进入编码函数前被动了手脚。养成对照汇编看伪代码的习惯,能少走很多弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 16:48:43

Windows下MPI并行计算实战:mpiexec.exe原理与常见问题排查

如果你是在Windows上刚开始接触并行计算,大概率会遇到这种情况:程序装好了、代码编译通过了,可一跑就卡在一条命令上——mpiexec.exe。MPI(Message Passing Interface,消息传递接口)标准本身不复杂&#xf…

作者头像 李华
网站建设 2026/10/7 16:46:53

Django后端开发实战:微信小程序档案管理服务搭建

做了两年多小程序,我越来越觉得:微信小程序这层壳其实不难,难的是背后那套能支撑业务的数据服务。这次分享的“档案宝”正是这样一个项目——前端是原生微信小程序,后端用Django搭了一套完整的档案管理服务,涵盖档案录…

作者头像 李华
网站建设 2026/10/7 16:46:09

MODIS地表温度数据处理全流程:QC解析、坐标配准与不确定性量化

简介:本资源为2022年中国全域1km分辨率地表温度(LST)空间分布数据集,基于NASA MODIS MOD11A2产品加工生成,面向遥感、地理信息、生态与气候研究领域的科研人员及GIS初学者,支撑区域热环境分析、城市热岛评估…

作者头像 李华
网站建设 2026/10/7 16:45:38

Linux线程详解:从pthread创建到同步互斥与死锁排查

刚接触Linux线程时,我犯过一个特别低级的错误:在线程入口函数里直接操作了一个全局变量,两个线程同时跑,结果那个计数器忽大忽小,跟抽风一样。后来慢慢啃完概念、踩过死锁的坑,才算摸清这套东西的脾气。今天…

作者头像 李华
网站建设 2026/10/7 16:45:38

江苏土壤类型标准Shapefile:可计算、可配准、可建模的GIS生产级数据

简介:本资源为江苏省土壤类型空间分布标准GIS数据集,面向地理信息、农业遥感、环境科学等领域的科研人员与高校师生,支撑区域土壤属性分析、生态评估及空间建模等基础研究工作。数据基于1∶400万中国土壤图构建,采用三位数字编码体…

作者头像 李华