首页
社区
课程
招聘
[原创] UPX 手动脱壳实战:从 GDB 到完整 ELF 的七步还原
发表于: 1天前 418

[原创] UPX 手动脱壳实战:从 GDB 到完整 ELF 的七步还原

1天前
418

UPX 手动脱壳实战:从 GDB 到完整 ELF 的七步还原

测试环境:Arch Linux / glibc 2.41 / UPX 5.2.0 / GDB 16.3

本帖同步发布于:
吾爱破解:594K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3f1#2x3Y4m8G2K9X3W2W2i4K6u0W2j5$3&6Q4x3V1k6@1K9s2u0W2j5h3c8Q4x3X3b7J5x3e0p5K6x3o6R3$3i4K6u0V1x3g2)9J5k6o6q4Q4x3X3g2Z5N6r3#2D9


0x01 UPX 干了什么

UPX 是一个压缩壳。它把 ELF 的代码段和数据段压缩,然后把自己的解压代码贴在文件里。程序启动时:

  1. UPX stub 先跑,把压缩数据解压到内存
  2. 解压完跳到 _start,动态链接器接管,跟正常程序一样跑

file 看到的是 "statically linked, no section header"。加壳后的文件确实没有 PT_INTERP(ldd 也会说"不是动态可执行文件"):

$ file hello_packed
hello_packed: ... statically linked, no section header
$ ldd hello_packed
        不是动态可执行文件
$ readelf -l hello_packed | grep INTERP
(空的——packed 文件里确实没有解释器)

但别被骗了。原程序是动态链接的——PT_INTERPPT_DYNAMIC 被 UPX 压进压缩数据里了,只在解压后才出现在内存中。证据是 strace 运行它的时候,照样能看到 ld.so 在加载 libc。

也就是说:壳是静态的,但里面包着一个动态链接的程序。这就是为什么 catch syscall read 能截到 ld.so——壳跑完之后,原程序的动态链接器照常开工。

file 和 ldd 都显示静态,但 strace 暴露了动态链接


0x02 starti — 停在起点

$ gdb hello_packed
(gdb) starti

停在 0x00007ffff7ffd958。这个地址一看就不是我们程序的 _start(入口点在 0x1090 附近,不该在高位地址)——这里是 UPX stub 的第一条指令。原程序还被压着,GDB 站在壳的门外面。

GDB starti 停在 UPX stub 入口


0x03 catch syscall read — 等解压完成

(gdb) catch syscall read
(gdb) c

停在 Catchpoint 1 (call to syscall read)。这时候 UPX 已经把原程序解压到内存了。

为什么是 read 因为 UPX 解压是纯用户态操作,不触发 syscall,壳自己不会碰 read。但解压完成后,动态链接器 ld.so 必须通过 read() 系统调用加载 libc——这是内核强制路径,壳躲不开。断在 read 就是断在"壳刚跑完、真程序要醒了"的那个瞬间。

catch syscall read 断在 read 系统调用


0x04 info proc mappings — 找解压出来的 ELF

(gdb) info proc mappings

在输出里找这几段连续的映射:

0x7ffff7ff8000 0x7ffff7ff9000 0x1000 r--    ← ELF header 段
0x7ffff7ff9000 0x7ffff7ffa000 0x1000 r-xs   ← 代码段 (memfd)
0x7ffff7ffa000 0x7ffff7ffb000 0x1000 r--    ← rodata 段
0x7ffff7ffb000 0x7ffff7ffd000 0x2000 rw-    ← data 段

代码段(r-xs)是 memfd 映射的——UPX 用 memfd_create 创建匿名文件把解压后的代码写进去。ELF header 通常就在代码段紧前面那个 r-- 页。

验证一下:

(gdb) x/4bx 0x7ffff7ff8000
0x7ffff7ff8000: 0x7f 0x45 0x4c 0x46    ← ELF magic

对了,就是它。0x7ffff7ff8000 就是解压后 ELF 的基址,后面的流程全部基于这个地址。

验证ELF基址


0x05 逐条找 PT_LOAD — 精确定位边界

ELF header 告诉我们 program headers 的位置和数量:

字段 偏移 含义
e_phoff +32 (8B) program headers 表从哪开始
e_phnum +56 (2B) 共多少条 program header
(gdb) x/gx 0x7ffff7ff8000+32      → e_phoff = 0x40
(gdb) x/hx 0x7ffff7ff8000+56      → e_phnum = 15

Program headers 表从 0x7ffff7ff8000 + 0x40 开始,共 15 条。每条 56 字节,结构如下:

偏移 字段 大小 说明
0 p_type 4B 类型 (1=PT_LOAD)
4 p_flags 4B 权限 (R=4, W=2, X=1)
8 p_offset 8B 文件中的偏移
16 p_vaddr 8B 虚拟地址
32 p_filesz 8B 文件中的大小

我们只需要 p_type = 1(PT_LOAD)的条目。翻来覆去就四行有用。用 x/7gx 逐条查看:

(gdb) x/7gx 0x7ffff7ff8000+0x40           # PHDR 0: p_type=6 (PT_PHDR, 跳过)
(gdb) x/7gx 0x7ffff7ff8000+0x40+0x38      # PHDR 1: p_type=3 (PT_INTERP, 跳过)
(gdb) x/7gx 0x7ffff7ff8000+0x40+2*0x38    # PHDR 2: p_type=1 ✅ PT_LOAD

每条输出的第一个 8 字节的低 4 字节就是 p_type。找到 p_type=1 后,读后面的值:

0x7ffff7ff80b0: 0x0000000400000001  ← p_type=1, p_flags=4 (R)
                0x0000000000000000  ← p_offset = 0x0
                0x0000000000000000  ← p_vaddr  = 0x0
                ...
                0x00000000000007b8  ← p_filesz = 0x7b8

用同样方式找完所有 15 条,得到 4 条 PT_LOAD:

LOAD p_flags p_offset p_vaddr p_filesz 内容
R (4) 0x0 0x0 0x7b8 ELF header + 辅助段
RX (5) 0x1000 0x1000 0x249 .text 代码段
R (4) 0x2000 0x2000 0x1c0 .rodata 只读数据
RW (6) 0x2dd0 0x3dd0 0x270 .data + GOT 表

为什么是四条? 不是规定,是这个程序恰好四条。一个 ELF 有几个 p_type=1 的 program header 就有几个 LOAD 段。


0x06 dump + 拼接 → 完整 ELF

根据上一步的 p_offset + p_filesz,精确 dump 四个段:

GDB 四条 dump memory 命令

退出 GDB 后,用 cat_segments.py 按文件偏移拼接。**注意——不能直接 cat!**我第一次就是 cat dump1 dump2 dump3 dump4 > extracted 然后 ./extracted,直接 SIGSEGV。因为段之间有空隙:LOAD 1 在文件 offset 0x7b8 结束,LOAD 2 从 0x1000 才开始,中间差了一大段。cat 把四个段直接粘死,loader 按 p_offset 去读的时候全错位了。必须按 p_offset 定位、用脚本段间补零:

$ python3 cat_segments.py extracted dump1:0x0 dump2:0x1000 dump3:0x2000 dump4:0x2dd0

验证:

$ ./extracted
Enter password: 1111
Wrong!
$ ./extracted
Enter password: ilovearis
Correct!

cat_segments.py 拼接输出

Ghidra 里打开也正常——程序逻辑完整恢复。

Ghidra 中查看 extracted 伪代码


0x07 脚本 + 常见问题

自动化脱壳脚本

不想每次手动走六步?我让 AI 帮我写了一个 upx_dump.py

$ gdb -batch -ex "starti" -ex "source upx_dump.py" \
       -ex "upx_dump output.elf" hello_packed

一行跑完,直接产出可执行 ELF。脚本自动完成:catch read → 搜 _start → 解析 ELF → 按 PT_LOAD 拼接 → 段间补零。

ps:我不敢保证脚本的兼容性,手动dump永远是最兼容的方式。

upx_dump.py 一行命令自动脱壳完整过程

手动拼接脚本

上面手动流程需要 cat_segments.py

python3 cat_segments.py output.elf dump1:0x0 dump2:0x1000 dump3:0x2000 dump4:0x2dd0

参数格式:文件名:文件偏移(hex),顺序任意,脚本自动按 offset 排序并补零填充。

Q: 这个时候就有弹幕要问啦:哎主播主播!既然最后还是用脚本填充零,那我 dump 的时候直接 dump 整页然后 cat dump1 dump2 dump3 dump4 > extracted 不就行了吗?根本不需要后续补零!

我的回答:程序可以跑,但不够干净。整页 dump 会把 LOAD 段尾部多余的零填充或相邻映射的垃圾数据也带进去。Loader 按 p_filesz 加载,不读多余数据,所以不影响执行。但 md5 对不上、readelf 可能报警告,文件也比原版胖。精确到字节就是干净——反正是脚本做的事,不费我的手。

Q: 为什么 /proc/PID/exe 拿不到解压后的文件?

大部分 UPX 变体在 memfd_create 创建匿名文件后直接在当前进程里跳转,不像某些变体用 fexecve 开新进程。/proc/PID/exe 指向的是磁盘上的原始加壳文件,不是内存里解压出来的那个。所以必须手动 dump。

两个脚本见附件:

  • cat_segments.py — 手动 dump 后拼接用
  • upx_dump.py — GDB 一键自动脱壳

总结

UPX 手动脱壳的核心就三步:

  1. 等解压catch syscall read,卡在动态链接器加载 libc 的时刻
  2. 定位边界 — 从 ELF header 找到 program headers,逐条找 PT_LOAD 的 p_offset + p_filesz
  3. 精确 dump — 按 p_vaddr 读取内存、按 p_offset 写回文件、段间补零

不需要理解 UPX 的压缩算法。不需要猜 OEP。不需要假设栈帧结构。只要程序是动态链接的,ld.so 就必然调 read()——这是内核 ABI 规定的,壳改不了。


致谢:本篇由我的AI--Aris协助完成。



[招生]科锐逆向工程师培训(2026年7月3日实地,远程教学同时开班, 第56期)!

最后于 8小时前 被Hex_编辑 ,原因:
上传的附件:
收藏
免费 3
打赏
分享
最新回复 (2)
雪    币: 12546
活跃值: (9616)
能力值: ( LV12,RANK:224 )
在线值:
发帖
回帖
粉丝
2
hello_packed 能上传一下不?
8小时前
0
雪    币: 36
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
neilwu hello_packed 能上传一下不?
已上传
8小时前
0
游客
登录 | 注册 方可回帖
返回