ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

裸字节反汇编实战:从6800680168096309学习x86变长指令

裸字节反汇编实战:从6800680168096309学习x86变长指令 简介F9DASM 是一款面向嵌入式开发、复古计算与逆向工程场景的处理器反汇编工具支持 6800/6801/6802/6803/6808/6809 及 6301/6303/6309 系列芯片可处理 Intel Hex、Motorola S09、Flex9 Binary 等多种输入格式并借助指令信息文件提升反汇编准确度。压缩包共 20 个文件主要包含 C 语言源码、Visual Studio 工程文件、Makefile、说明文档与许可证等整体仅 128KB适合有 C 语言基础和处理器架构常识的开发者阅读源码或二次编译使用。目前已有 118 人浏览学习可据此快速评估工具价值。通过源码不仅能了解反汇编器的核心实现思路还能按需扩展支持格式、调整输出细节是一个轻量且便于裁剪的参考实现。 上周处理一个崩溃现场对方丢过来一段指令缓冲区的十六进制dump其中有一行是6800680168096309。乍一看就是八个字节丢进调试器里也没显示成代码因为它根本不在符号表覆盖的范围内。我平时习惯把这种裸字节丢给f9dasm——一个我自用的命令行拆卸器disassembler社区里常被直译成“拆卸器”几秒钟就拆出了两行汇编push 0x68016800和or dword ptr [rbx0x9], esp。这八字节看似不起眼却把x86变长指令里最经典的两个考点都占了一条需要吞掉4字节立即数的PUSH一条需要靠ModRM字节才知道操作数怎么取的OR。这篇文章就围绕6800680168096309展开聊聊这类裸字节从哪来、该怎么拆、f9dasm内部是怎么处理它的以及实际使用中容易踩的几个坑。适合刚接触逆向、固件分析、Shellcode阅读或者想真正看懂机器码的人。1. 裸字节的常见来历为什么需要f9dasm这种“拆卸器”先别急着看这串16进制怎么拆聊聊我更常见到的场景。做逆向和底层调试的人几乎每周都会遇到类似的东西崩溃转储里的一段指令缓冲区、调试器内存窗口里复制出来的一行字节、从固件dump或者流量包里抠出来的可疑片段、又或者是别人发来的一句“你看下这东西是不是代码”。这些字节有几个共同特点没有ELF/Mach-O格式头没有任何符号信息甚至没有明确的起始位置。它们就是从某个地址开始的一段连续内存你根本不知道它到底是不是指令也不知道如果它是指令应该从哪个字节开始拆。6800680168096309正是这样一个片段——8字节不长不短不是完整函数也不是明显对齐的指令流。遇到这种输入我的第一反应不是开IDA或者Ghidra太重了。我就想要一个能拼在命令行里、丢进去立即出结果的工具。f9dasm就是这么来的。它做的事情很单纯输入一串hex字节指定架构和位宽输出按指令边界切好的汇编助记符。它只做“拆”不做“解”——也就是不会尝试把汇编还原成C语言的if/else和循环。因为很多场景下我只想知道“这段字节按代码解释到底长什么样”而不需要一套完整的高级语言恢复引擎。名字里的“f9”没什么深意最早只是我测试目录里的一个文件名后来项目成型了也懒得改。工具本身的定位也是如此不打算做成一个大型逆向框架它就是个趁手的小钳子专门负责把机器码夹成一节一节的指令。使用方式非常直接$ f9dasm --arch x86-64 --bits 64 6800680168096309 00000000 68 00 68 01 68 push 0x68016800 00000005 09 63 09 or dword ptr [rbx0x9], esp这里68 00 68 01 68是第一条指令长5字节09 63 09是第二条指令长3字节。两条合计8字节恰好不剩不多。这个“恰好”其实是个信号——后面我会说人工构造的测试向量通常都这么精确真实代码片段很少这么乖巧。2. 手工拆解6800680168096309PUSH与ModRM的两道门槛在把一切都交给工具之前我强烈建议你先手拆一遍。倒不是非得练出手工反汇编的本事而是x86的变长指令有它固定的解码顺序这个顺序你不亲手走一遍后面看工具输出也会一头雾水。2.1 第一条指令68如何决定自己要吞掉四个字节先看字节流给它编上索引后面好说话索引: 0 1 2 3 4 5 6 7 字节: 68 00 68 01 68 09 63 09第一个字节是0x68。查x86操作码表0x68是PUSH imm在32位和64位模式下它后面跟的是一个4字节立即数小端序。所以一旦读到0x68拆卸器就知道自己必须向后读4个字节作为操作数。也就是说第一条指令其实是68 00 68 01 68操作码是第一个68后面四个字节00 68 01 68按小端拼起来得到0x68016800。因此第一条完整的指令是push 0x68016800这里有个特别容易看花眼的地方第2个字节索引2恰好又是一个68。如果对x86不够熟看到68 00 68 01 68可能会以为第二条指令从索引1或索引2开始了。但实际上索引1到索引4这四个字节已经被第一条68当成立即数“吞”掉了。操作码决定了这条指令吞几个字节而不是看到哪个字节像操作码就从哪里重开一轮。这是x86变长指令理解的第一道坎。在64位模式下push imm32会把32位立即数符号扩展成64位后压栈。这里的0x68016800最高位是0所以扩展后高32位就是0压进去的是0x0000000068016800RSP减8。如果在32位模式下同样的指令则是往栈上压一个32位数ESP减4。2.2 第二条指令09 63 09里藏着ModRM和disp8第一条指令用了5个字节剩下从索引5开始09 63 09。0x09是OR r/m32, r32。和0x68这种“自带操作数长度”的操作码不同OR这类指令还需要一个ModRM字节来说明操作数寻址方式。所以读取操作码0x09之后下一件必须做的事是读紧接着的0x63把它当作ModRM解析而不是当作一条新指令。把0x63展开成二进制是01100011每一位段表示位段值含义mod7-6位01带8位位移的内存寻址reg5-3位100源寄存器ESPrm2-0位011基址寄存器RBXmod01意味着有效地址是[基址寄存器 8位位移]而8位位移就是ModRM后面的下一个字节0x09。所以第二条指令解析为or dword ptr [rbx0x9], esp这里再提一个容易看错的地方在x86-64长模式下没有REX前缀时这条OR的默认操作数大小是32位所以寄存器显示为ESP而不是RSP。内存操作数也是32位宽地址由RBX加上位移9得到。很多刚上手64位汇编的人容易把这里的ESP直接脑补成RSP进而误解指令的行为。如果换成32位模式同样的09 63 09会变成or dword ptr [ebx0x9], esp地址位宽是32位但指令字节完全一样。同一个字节序列在不同位宽下语义有微妙差异这是底层分析绕不开的细节。2.3 换位宽、换起点同一串字节变成不同东西看完了正常的64位拆法再做两个对照实验你会发现这八字节有多“多面”。第一个实验是切换到16位模式。在16位模式下0x68后面跟的是2字节立即数所以68 00 68 01 68 09 63 09会拆成push 0x6800 push 0x6801 push 0x6309然后再剩下一个孤零零的09没法组成完整指令。同样八个字节在16位模式下得到的是三句压栈加一个残片和64位模式下的结果几乎可以说是两段不同的代码。第二个实验是同样在64位模式下但从索引1开始拆也就是把第一个68当作数据跳过00 68 01 68 09 63 090x00是ADD r/m8, r8它的ModRM是0x68mod01reg101rm000disp8是下一个字节0x01。于是得到一条 3 字节的add byte ptr [rax0x1], ch剩下68 09 63 09又是一个不完整的PUSH。整体变成了一堆毫无规律的东西。这个对照实验说明一个核心问题拆卸结果对“从哪个偏移开始拆”极度敏感。差一个字节可能从一段有语义的代码变成一段垃圾。这也是为什么f9dasm这类工具必须允许你手动指定起始偏移而不是自作聪明地默认从头开始。3. f9dasm的拆卸核心从字节流到指令长度的一小段逻辑手拆一遍之后再看f9dasm的实现就轻松多了。它不复杂核心就是一个“读操作码 → 决定后续要补哪些字节 → 算总长度 → 前进”的循环。3.1 拆卸器的最小工作循环x86指令的长度不是固定的但解码路径是固定的操作码可能带前缀→ ModRM → SIB → disp → imm。每一步是否需要由前一步的结果决定。比如0x68告诉解码器“我需要立即数而且长度是4字节32/64位模式下”于是解码器直接跳着读4字节不用再去看ModRM。而0x09告诉解码器“我需要ModRM”于是解码器必须继续读ModRM再根据ModRM算出是否要disp、SIB等。用一段最小Python示意你就能明白f9dasm的核心循环长什么样实际工程里的操作码表比这大得多但骨架一致def next_insn(buf, bits64): length 1 op buf[0] # push imm (0x68) if op 0x68: imm_size 2 if bits 16 else 4 imm int.from_bytes(buf[1:1 imm_size], little) length imm_size return (fpush 0x{imm:0{imm_size * 2}x}, length) # or r/m32, r32 (0x09) if op 0x09: modrm buf[1] length 1 mod (modrm 6) 0b11 reg (modrm 3) 0b111 rm modrm 0b111 if mod 0b01: disp buf[2] length 1 return (for dword ptr [base{rm}0x{disp:x}], reg{reg}, length) # 其他mod值分支省略 raise ValueError(funsupported opcode 0x{op:02x} at offset 0)真正完整的f9dasm不是用一堆if硬写的它会维护一张操作码表每条表项记录“这个操作码是否需要ModRM”“立即数是1/2/4/8字节”“缺省操作数大小是多少”等属性。主循环就是查表、积累长度、输出助记符。这样新增指令只需要加表项不需要改循环逻辑。3.2 已有objdump/ndisasm为什么还要自维护一个这个问题经常被问到。客观说objdump和ndisasm都能拆出上面的结果f9dasm绝对谈不上不可替代。但我还是维护了它理由有三个。一是输出格式的可控性。objdump默认输出是ATT风格虽然可以加-Mintel切换但它的完整输出里还带文件头、章节信息、地址段写脚本处理时很啰嗦。f9dasm输出一行一指令干干净净方便和其他分析管线串起来。二是环境受限时的可用性。有些分析环境里没有完整的binutils或者机器上连objdump都没装但Python总归是有的f9dasm这种轻量实现随时可以拖过去用。三是学习价值。自己写一个拆卸器比翻十遍指令手册都更能理解变长指令是怎么回事。等你能让工具正确输出09 63 09这条指令时ModRM里mod/reg/rm三者的关系基本就刻进脑子了。这里有一个对比实际使用时可以参考工具定位输出风格典型使用场景objdump完整二进制分析ATT可切Intel拆文件、带上下文反汇编ndisasm纯字节流反汇编Intel快速看一段裸字节capstone反汇编引擎库可定制嵌入自己的工具f9dasm个人命令行拆卸器一行一指令配合脚本快速验证4. 拆这8字节时最容易栽的三个跟头用f9dasm拆6800680168096309本身很顺利但把这串字节放到真实分析场景里有几个问题你迟早会遇到我一个个说。4.1 字节不够不完整指令必须显式报错假设你拿到的是6800680168096309的前7个字节也就是68 00 68 01 68 09 63。第一条PUSH占了5字节还剩09 630x09需要ModRMModRM0x63又要求mod01后面跟一个disp8可这里没有第8个字节了。这时候工具最忌讳的做法是“猜”。有的反汇编器会尝试把不完整的尾部当数据跳过或者硬凑一条指令。f9dasm的默认行为是直接报错指出哪个偏移处存在不完整指令。原因很简单在分析Shellcode或畸形数据时一个静默的错误拆解可能让你走上完全错误的方向。宁可断在这里也不能瞎猜。4.2 0x63到底是MOVSXD还是ModRM取决于它出现在角色位把09 63 09给一个刚入门的人看他可能会疑惑0x63在64位模式下不是一个单独的指令MOVSXD吗为什么这里把它算作ModRM这里有一个至关重要的观察点0x63作为操作码时确实是MOVSXD但在这条指令里它的位置不是操作码位而是ModRM位。0x09已经被认定为操作码并宣布“我需要一个ModRM”所以随后读到的任何字节都只能按ModRM来解释直到这条指令结束。同一个字节放在操作码位置是一条指令放在ModRM位置就是一个寻址描述符角色完全取决于解码上下文。这也是x86难读的原因之一——你不能像查字典一样挨个字节独立查含义必须顺着一条指令的消费顺序走。4.3 它可能根本不是代码数据与代码的判别最后一件必须时刻记住的事6800680168096309可能根本不是代码。把它整体当成一个小端64位整数值是0x0963096801680068——一个看起来很像随机哈希或者时间戳的数字。谁说它就不能是某个结构体里的两个字段判断一段字节是不是代码常规思路有几条看它所在的段是否有执行权限看是否有跳转指令引用到这个地址看从该地址开始连续解码若干条指令后语义是否自洽看它出现在什么上下文里比如是崩溃现场的EIP附近还是某个被当成缓冲区存储的区域。f9dasm本身不做这些判断它只回答“如果你非要按指令拆会得到什么”。这是它的边界也是它的诚实之处。拆卸器输出的是“按代码解释的视图”不是“它确实是代码”的结论。5. 拆完不等于分析完两条指令能推出什么工具出结果了push 0x68016800和or dword ptr [rbx0x9], esp但这不意味着分析结束。我见过太多人拿到汇编助记符就以为自己懂了其实离真相还很远。5.1 试着还原高级语言与手工汇编的意图先试着从编译器生成的代码角度理解第一条指令。PUSH一个立即数最常见的情况是函数调用前压参数比如C代码里的func(0x68016800)在调用约定要求栈传参时就会编译出类似的push。也可能是在手工汇编里用压栈的方式快速把一个常量放到栈顶备用。第二条OR DWORD PTR [RBX0x9], ESP就更有意思了。假设RBX指向某个对象那这行就是在给偏移9字节处的一个字段做按位或运算等价于C里的obj-flags | something。但这里的源操作数竟然是ESP也就是栈指针这在常规编译代码里非常罕见——谁会把自己的栈指针当成标志位去OR除非ESP此刻并不是真的栈顶而是被人为当作一个通用寄存器来传递临时值。这种用法在Shellcode和混淆代码里并不稀奇。另一种可能性是这个8字节序列根本就是人为构造的测试向量目的就是同时检验PUSH的imm32消费逻辑和OR的ModRMdisp8解析逻辑。第2.3节里我提过它恰好8字节拆完一点不多一点不少这种“精确”在真实函数中反而不常见。所以最合理的结论可能是这串字节本身就是为测试拆卸器而设计的样本而不是某段真实程序的中间片段。你看脱离上下文机器码没有唯一的高级语言答案承认这一点比强行给出一段C代码更专业。5.2 和objdump交叉验证确认拆卸结果f9dasm是自己的工具但自己的工具也可能有bug。为了确认这一条最稳妥的做法是拿标准工具交叉验证一遍printf \x68\x00\x68\x01\x68\x09\x63\x09 sample.bin objdump -D -b binary -m i386:x86-64 -Mintel sample.bin输出可以看到0000000000000000 .data: 0: 68 00 68 01 68 push 0x68016800 5: 09 63 09 or esp,DWORD PTR [rbx0x9]注意objdump的Intel输出里or的方向是or esp, DWORD PTR [rbx0x9]这是Intel语法里目标在前、源在后的表示。它和f9dasm输出的or dword ptr [rbx0x9], esp说的是同一件事只是助记符书写顺序不同。这个交叉验证的过程我基本每次都做尤其是当输出结果会写进分析报告的时候。我自己现在的习惯是拿到任何裸字节先问三个问题——目标架构是什么位宽是多少应该从哪个偏移开始拆。这三个问题有了答案才把字节交给f9dasm拆完再顺手用objdump或ndisasm核一遍。看起来多花了几分钟但在这个行业里一个看似不起眼的误拆可能让你后面整整一天的分析都建立在错误的基础上。这八字节教给我的也正是这一点。本文还有配套的精品资源点击获取
返回列表