Skip to Content

反汇编 / Hexview

Shadow CE 里两个”看内存”的眼睛:一个看字节(Hex),一个看指令(Disasm)。学会用这两个窗口,80% 的逆向场景都够了。


一句话

两个上下分栏的阅读视图,共用一个 MemoryViewWindow 外壳,双 TCP 独立走”后台拉页” + “前台刷新”。

看起来简单,但每一个细节都是为了”不卡”设计的:页级 LRU 缓存、双缓冲 paintEvent、零分配渲染。


实拍

Memory View — 上 disasm 下 hex

上半 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, 0xCD

2. 语法高亮(tokenize 着色)

正则把一条指令拆成若干 token:

Token颜色示例
mnemonic#ffffffldr, mov, add
branch mnemonic#ffdd66b, bl, cbz, tbnz, ret
register深红 #C1000Dx0, w1, sp
immediate#007fff#0x20, #42
symbol绿 #008000libfoo+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" * 16

cache 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 viewDisasm view
方向键移动光标滚动一行
Page Up/Down翻页翻页
Home/End行首/行尾当前页顶/底
Ctrl+GGo to addressGo to address
Ctrl+FFind bytes / stringFind instruction / symbol
Space暂停 refresh暂停 refresh
Enter编辑模式跟随跳转目标
Esc退出编辑取消选中
Ctrl+CCopy 选中 hexCopy 选中汇编

右键菜单:

  • 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 / u81-1 / 255
i16 / u162-1 / 65535
i32 / u324常用:小整数、枚举、HP/MP
i64 / u648指针、大数、handle
f324坐标、速度、比例
f648精确坐标、时间戳
char[]≤16ASCII 字符串
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 ; 跳 lr

C++ 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”

装一个 用户态硬件断点内核 PTE 断点

3. 游戏里扣一点血 → 命中日志

HwbpWindow 显示:

PC = libunity.so + 0x1A2B4C

4. 双击这条命中 → 打开 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 自绘 + 双缓冲 + fade
  • client/disasm_view.py(731 行)— Capstone 集成 + 跳转箭头 + tokenize
  • client/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 里调。

Last updated on