反汇编 / Hexview
Shadow CE 里两个”看内存”的眼睛:一个看字节(Hex),一个看指令(Disasm)。学会用这两个窗口,80% 的逆向场景都够了。
一句话
两个上下分栏的阅读视图,共用一个 MemoryViewWindow 外壳,双 TCP 独立走”后台拉页” + “前台刷新”。
看起来简单,但每一个细节都是为了”不卡”设计的:页级 LRU 缓存、双缓冲 paintEvent、零分配渲染。
实拍
上半 Disasm(地址 / Bytes / Opcode 三列,寄存器红、立即数蓝、字符串引用绿),下半 Hex(16 字节一行 hex + ASCII 侧栏),底部一条 status 把当前光标位置按 byte/word/i32/i64/f32/f64 一次解释齐。
看起来长什么样
┌────────── Memory View ─────────────────────────────────────┐
│ [goto 0x_______] [module▾] [⟳ refresh] status... │
├────────────────────────────────────────────────────────────┤
│ │
│ libfoo+0x2A4 ldr x0, [x1, #0x20] │ ←
│ libfoo+0x2A8 ldr x1, [x0, #0x8] │ disasm view
│ libfoo+0x2AC cbnz x1, libfoo+0x2C4 ↘ │ (上半)
│ libfoo+0x2B0 b libfoo+0x300 │ │
│ libfoo+0x2C4 mov x2, x1 ↙ │
│ │
├────────────────────────────────────────────────────────────┤
│ 00 7ff1234a00 48 65 6c 6c 6f 20 57 6f 72 6c 64 00 00 00 │
│ 00 7ff1234a10 50 6c 61 79 65 72 53 74 61 74 65 00 00 00 │ ← hex view
│ 00 7ff1234a20 00 00 00 00 01 00 00 00 64 00 00 00 00 00 │ (下半)
│ 00 7ff1234a30 [3c 00 00 00] 00 00 00 00 ff 00 ff ff ff │
│ ↑ 光标 │
│ │
├────────────────────────────────────────────────────────────┤
│ Data Inspector: │
│ i32=60 u32=60 f32=8.41e-44 str="<...>" ... │
└────────────────────────────────────────────────────────────┘- 上半 disasm:看目标是什么代码
- 下半 hex:看字节、改字节
- 右边 status:地址、模块、fetch 状态、选中范围
- 底部 Data Inspector:把选中字节按各种类型解读(整数、浮点、字符串、ptr)
共用的壳:MemoryViewWindow
memory_view.py 里 419 行,它是容器,不是基类——hex 和 disasm 不继承同一个父,而是各自独立的 QWidget,被壳放在上下两个 splitter 里。
容器负责的事:
- 拉两个 TCP 连接:一个做后台 fetch(懒加载新页),一个做前台 refresh(定时刷现有可见页)
- 维护
MemoryCache(128 × 4KB 的 LRU) - goto / 模块选择 / 状态栏
- 组装 Data Inspector
- 信号串联:hex 的 cursor 变了 → disasm 跟随、反过来也一样
为什么要两条 TCP?拉新页可能要等服务端走一趟 PTE walk(几毫秒);如果和”刷新当前屏幕”共用一条 socket,刷新就会被阻塞导致 UI 卡顿。两条独立 socket 互不打扰。
Hex view —— 字节级读写
视觉骨架
每一行长这样:
[row#] [8B 地址] [16 B hex, 两个 8 字节一组空一格] [16 B ASCII]
00 7ff1234a00 48 65 6c 6c 6f 20 57 6f 72 6c 64 00 00 00 00 00 Hello World.....- 行号:可选,用来快速跳
- 地址列:8 字节 hex,等宽字体
- Hex 列:16 字节,中间用两个空格分隔成两组 8 字节
- ASCII 列:可打印字符显示,不可打印的用
.占位 - 字体 DejaVu Sans Mono 9px,强制 NoAntialias
关键特性
1. 光标 + 选中两套状态机
- 光标 = 当前输入的插入位置(一个字节半步)
- 选中 = 鼠标拖的一段范围
- 光标红色
#ff3333,选中蓝底#264f78 - 键盘导航:方向键动光标;Shift + 方向键扩展选中
2. 21 级 fade 颜色
字节刚变过 → 亮红 #ff6666;过一段时间 fade 21 级逐渐变回白。代码里预计算了 21 个 QColor,每次 paint 按时间差查表——零运行时颜色计算。
视觉效果:内存扫描或断点触发刷新后,屏幕上哪些字节”正在动”一眼就看出来。
3. 编辑模式
- Tab / Enter 切换到编辑模式
- 输入 hex 字符 → 缓冲 → 按 Enter 提交 → 走
ce_client.write_bytes(addr, data)→ 服务端写页 → 本地 cache invalidate → 下一轮 refresh 看到新值 - 编辑中的 byte 背景变蓝
#3060a0
4. 渲染优化:QPixmap 双缓冲
def paintEvent(self, ev):
if self._dirty:
self._buf = QPixmap(self.size())
p = QPainter(self._buf)
self._render_rows(p) # 画整个可见区
p.end()
self._dirty = False
# 把 pixmap 一口气 blit 到屏幕
QPainter(self).drawPixmap(0, 0, self._buf)只有数据变了才重画 pixmap。鼠标滑过、光标闪烁这些都不触发重画——只是把同一张 pixmap 再 blit 一次。
5. 预计算字符串表
_HEX = [f"{i:02X}" for i in range(256)] # ["00", "01", ..., "FF"]
_ASCII = [chr(i) if 32 <= i < 127 else "." for i in range(256)]
_FADE_COLORS = [QColor(...) for _ in range(21)]paint 的时候绝不 f-string,绝不 QColor(…)——全部查表。
Disasm view —— 反汇编 + 跳转
视觉骨架
每一行:
[地址] [指令] [注释 / 符号]
libfoo+0x2A4 ldr x0, [x1, #0x20] ; this.field_20
libfoo+0x2A8 ldr x1, [x0, #0x8] ; + vtable pointer
libfoo+0x2AC cbnz x1, +0x18 ↘
libfoo+0x2B0 b libfoo+0x300 │
libfoo+0x2C4 mov x2, x1 ↙- 地址列:优先用
module+offset,没有模块就退化为 hex - 指令列:Capstone 反汇编输出
- 注释列:符号名、字符串引用、可能的 vtable 偏移注释
- 跳转箭头 L 形线:画在最左边一道通道
关键特性
1. Capstone 作为反汇编引擎
import capstone
md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
md.detail = True # 要 operand 信息用来着色
for insn in md.disasm(bytes_, base_addr):
line = (insn.mnemonic, insn.op_str, insn.address)
# ... 进一步 tokenize 着色逐条 4 字节 decode——不一次把整个页丢给 capstone。为什么?防非法指令截断:如果页中间有数据段,capstone 撞上非法字节会跳过若干字节,后续指令对齐全错。我们每 4 字节决一次,错的那条就当作 .byte 0x?? 显示,下一条仍然从 +4 开始。
非法指令的 fallback 显示成:
libfoo+0x300 .byte 0x12, 0x34, 0xAB, 0xCD2. 语法高亮(tokenize 着色)
正则把一条指令拆成若干 token:
| Token | 颜色 | 示例 |
|---|---|---|
| mnemonic | 白 #ffffff | ldr, mov, add |
| branch mnemonic | 黄 #ffdd66 | b, bl, cbz, tbnz, ret |
| register | 深红 #C1000D | x0, w1, sp |
| immediate | 蓝 #007fff | #0x20, #42 |
| symbol | 绿 #008000 | libfoo+0x2A4 |
| comment | 灰 #666666 | ; this.field_20 |
一眼就能看出”这行是控制流(黄 mnemonic)“还是”这行是 data move(白 mnemonic)”。
3. 跳转箭头(L 形线)
每条分支指令的左边画一道 L 形线指向目标:
cbnz x1, +0x18 ↘
│ ← 绿色:无条件跳转
b libfoo+0x300 │ ← 红色:条件跳转不成立
mov x2, x1 ↙ ← 金色:bl / blr(函数调用)| 颜色 | 类型 |
|---|---|
绿 #008000 | 无条件跳转 (b, ret) |
红 #cc4444 | 条件跳转 (cbz, cbnz, tbz, b.cond) |
金 #ccaa44 | 函数调用 (bl, blr) |
跳转目标在视野内 → 画完整的 L;在视野外 → 画半截 + 箭头指向屏幕边缘。
4. 中文指令提示 _INSN_HINTS
鼠标悬停在某条指令上,tooltip 显示中文解释:
_INSN_HINTS = {
"ldr": "load register — 从内存读到寄存器",
"str": "store register — 把寄存器写到内存",
"mov": "move — 寄存器赋值",
"b": "branch — 无条件跳转",
"cbnz": "compare branch non-zero — 不为 0 则跳转",
"bl": "branch with link — 函数调用,lr=下一条",
# ...
}对初学 ARM64 的人特别友好——不用每次查 ARM ARM。
5. 百分比可拖拽列
列宽不是固定像素,而是百分比:地址 20% / 指令 45% / 注释 35%。拖拽分隔线的时候改百分比,窗口 resize 不破相。
两视图联动
用户在 hex 里点了 0x7ff1234a08 的字节
│
▼
hex 发 signal: cursor_at(0x7ff1234a08)
│
▼
MemoryViewWindow 捕获 → 转发给 disasm
│
▼
disasm scroll 到包含 PC=0x7ff1234a08 的那行 + 高亮反过来也一样:disasm 点选一条指令 → hex 跳到那条指令对应的 4 字节位置。
Go to address 对话框(按 Ctrl+G)支持三种格式:
- 绝对地址:
0x7ff1234a00 - 模块相对:
libfoo+0x2a0 - 符号名:
_ZN5Foo10methodNameEi(如果有 symbol table)
字节是怎么拉来的
视图并不直接读服务端——它只问 cache。cache 没有再发 TCP。
# hex view 渲染需要 addr..addr+16B:
chunk = cache.read(addr, 16)
if chunk is None:
# cache miss → 标记为"zeros placeholder"立刻画灰色 00
# 同时把 page 加入后台 fetch 队列
cache.fetch_async(page_of(addr))
chunk = b"\x00" * 16cache miss 不阻塞 UI——先画占位,后台 fetch 回来后通过 signal 触发 repaint。
MemoryCache 是怎么装货的
- 容量 128 × 4KB = 512 KB
- LRU 淘汰:访问最久远的先扔
- 按 4KB 页对齐:
read(addr, 16)里面会找所在页 + offset - 有时间戳:fade 效果用它计算”上次变化 N ms 之前”
后台 fetch 线程
class FetchWorker(threading.Thread):
def run(self):
while running:
page_addr = self.q.get() # 阻塞拿
data = client.read_memory(page_addr, 4096)
cache.put(page_addr, data)
self.page_ready.emit(page_addr) # 通知 UI 重画queue drop-if-full:用户往下快速滚的时候,老页的请求还没回来,新的一页又排了——队列满了就丢老的,避免越堆越多。
刷新线程(refresh ticker)
每 N ms(Settings 可调,默认 500ms)扫一遍屏幕上可见的页,发 read_memory,把 cache 更新。只刷可见页,不刷 LRU 里所有 128 页。
ce_client 的三条指令
| CMD | 作用 |
|---|---|
0x09 READ_BYTES | 读一段字节(page aligned) |
0xCB READ_DISASM | 同 READ_BYTES 但会把结果标记为”disasm page”(不同 TTL) |
0x0A WRITE_BYTES | 写一段字节 |
读写 payload 都是 {addr, len, data?},服务端做 PTE walk(见 内存扫描)。
快捷键一张表
| 快捷键 | Hex view | Disasm view |
|---|---|---|
| 方向键 | 移动光标 | 滚动一行 |
| Page Up/Down | 翻页 | 翻页 |
| Home/End | 行首/行尾 | 当前页顶/底 |
| Ctrl+G | Go to address | Go to address |
| Ctrl+F | Find bytes / string | Find instruction / symbol |
| Space | 暂停 refresh | 暂停 refresh |
| Enter | 编辑模式 | 跟随跳转目标 |
| Esc | 退出编辑 | 取消选中 |
| Ctrl+C | Copy 选中 hex | Copy 选中汇编 |
右键菜单:
- Hex:
Copy as hex / ascii / pointer / Paste bytes / Set BP here / Add to address list - Disasm:
Copy instruction / Copy disasm range / Follow jump / Set BP at this PC
渲染性能这些细节
- 零每帧 QFont/QColor 构造:全部
__init__时建好 setPen调用数最小化:同色段合并成一串drawText- Scrollbar 拖动节流:16ms 内连续事件合并成一个重画
- Paint 时间预算:目标 <2ms 一帧;超过就打 warning 到 dmesg
- Cache miss 不阻塞:永远画占位先,数据回来再重画
Data Inspector —— 字节的多重人格
选中一段字节(1-16 字节),底部 Data Inspector 按各种类型解读:
| 类型 | 字节数 | 例 |
|---|---|---|
| i8 / u8 | 1 | -1 / 255 |
| i16 / u16 | 2 | -1 / 65535 |
| i32 / u32 | 4 | 常用:小整数、枚举、HP/MP |
| i64 / u64 | 8 | 指针、大数、handle |
| f32 | 4 | 坐标、速度、比例 |
| f64 | 8 | 精确坐标、时间戳 |
| char[] | ≤16 | ASCII 字符串 |
| ptr → … | 8 | 当作指针解,展示前 32 字节目标 |
小 tip:找游戏变量时,i32=100 u32=100 f32=1.4e-43 同时出现通常是整型血量;如果 f32=100.0 才亮整数可能就是浮点血量(很多现代游戏这么写)。
模块解析 —— 从 vaddr 到 libfoo+offset
Shadow CE 在首次连接时拉了目标进程的 /proc/$pid/maps,构建:
modules = [
(0x7fa1234000, 0x7fa1300000, "libunity.so"),
(0x7fa2000000, 0x7fa2040000, "libfoo.so"),
...
]反解就是二分查找:
def resolve(vaddr):
for (lo, hi, name) in modules:
if lo <= vaddr < hi:
return f"{name}+0x{vaddr-lo:x}"
return f"0x{vaddr:x}"这让 disasm / address list 里显示的地址跨进程重启仍有意义——游戏重启后 ASLR 变了,libfoo.so 的基址会变,但 libfoo+0x2a4 永远指向同一条指令。
ARM64 反汇编小抄
看 disasm 不想每次查 ARM ARM?记住这几个常见 pattern:
函数序言 (prologue)
stp x29, x30, [sp, #-0x20]! ; 保存 fp/lr,同时 sp -= 0x20
mov x29, sp ; fp = sp
stp x19, x20, [sp, #0x10] ; 保存 callee-saved x19/x20函数尾声 (epilogue)
ldp x19, x20, [sp, #0x10] ; 恢复
ldp x29, x30, [sp], #0x20 ; sp += 0x20
ret ; 跳 lrC++ this 访问
ldr w0, [x0, #0x10] ; this->field_10 (int32)
ldr x1, [x0, #0x18] ; this->field_18 (ptr/int64)第一个参数默认在 x0,对象方法的 this 就是 x0。
vtable 调用
ldr x8, [x0] ; x8 = vtable
ldr x9, [x8, #0x20] ; x9 = vtable[4] (第 5 个虚方法)
blr x9 ; 间接调用看到 ldr ... [x0] 紧跟 ldr ... [x8, #imm] 然后 blr —— 很可能是虚方法调用。
数组 / struct 访问
ldr x0, [x19, x20, lsl #3] ; x0 = x19[x20] (元素 8 字节,例如指针数组)典型实战:从扫描结果到理解代码
流程:
1. Memory Scan 找到地址
例如找到 0x7fa28c1200 = 当前血量。
2. 加入 address list,右键 “Find what writes this”
3. 游戏里扣一点血 → 命中日志
HwbpWindow 显示:
PC = libunity.so + 0x1A2B4C4. 双击这条命中 → 打开 Memory View at PC
disasm 自动跳到 libunity.so+0x1A2B4C:
libunity.so+0x1A2B4C ldr w0, [x19, #0x100] ; old = *(this + 0x100)
libunity.so+0x1A2B50 sub w0, w0, w1 ; new = old - damage
libunity.so+0x1A2B54 str w0, [x19, #0x100] ; *(this + 0x100) = new
libunity.so+0x1A2B58 ret一眼看出:x19 是 PlayerState 对象,+0x100 是血量字段。
5. 往上翻几条找 call site
libunity.so+0x1A2B00 mov x1, w0 ; damage
libunity.so+0x1A2B04 mov x0, x19 ; this
libunity.so+0x1A2B08 bl libunity.so+0x1A2B4C ; 上面那个函数找到了 Player::TakeDamage(int) 函数。往回 bl 的 xref 就是”谁打我”。
6. 复制 libunity.so+0x1A2B4C 到笔记 / 指针链分析
见 指针扫描。
Hex 编辑几个小坑
- float 不能输
3.14,得输c3 f5 48 40(little-endian 4 字节)。要改浮点先切到 Data Inspector 里的 f32 输入格 - Unicode 字符串:ASCII 直接输可以;CJK 得先知道编码(UTF-8 / UTF-16 / GBK 都可能)
- 写 PROT_EXEC 段:默认禁止(服务端 PTE walk 会拒绝);要 patch 代码段用 极致优化硬件断点 的 ihook
- Pointer 类型写入:地址要是 8 字节 little-endian,别手抖写成 big-endian
什么时候不该用 Memory View
- 想看结构体整体:Shadow CE 现在没有 “parse as struct X” 的功能,只能手动看 hex
- 想看一个大内存 dump:LRU 128 × 4KB = 512 KB 视野;超过就把老页扔掉重取(还是能看,就是 scroll 回来要重拉一下)
- 想看汇编 + 伪 C 对照:没接 Ghidra / IDA decompiler;真的需要拿地址去别的工具反编译
深度资料
client/hex_view.py(593 行)— QWidget 自绘 + 双缓冲 + fadeclient/disasm_view.py(731 行)— Capstone 集成 + 跳转箭头 + tokenizeclient/memory_view.py(419 行)— MemoryViewWindow 外壳 + 双连接client/mem_cache.py(194 行)— 128 页 LRU + 时间戳client/ce_client.py— 命令字节0x09 / 0xCB / 0x0A
所有渲染细节参数(fade 级别数、refresh 间隔、cache 大小)都可在 Settings → Display / Timers 里调。