Ghidra Pcode Rewriting: 在指定位置插入或修改Pcode

大类
逆向
技术标签
开发-逆向工具
原理研究-Ghidra
优先级
Medium
状态
In Progress
开始日期
Oct 20, 2025
最后更新
Nov 1, 2025
Public
Public

背景

一道奇怪的题目

强网杯2025的 tradre 题目使用ptrace重写了一部分的指令,相当于打断了控制流。固然可以使用ASM重编译的方式解决,但是涉及到了一系列dirty的汇编重写、重编译的问题。
对于这种情况,能够在反编译器的IR层面直接修改程序的内部逻辑明显才是更合理的选择。

为什么不用IDA的microcode?

在这道题目中,被抽走的代码全部都是原始的指令,没有做任何的变换,所以最好的方案是直接用工具原有的lifting逻辑得到IR,插入到正确的位置。
IDA的microcode IR之前已经研究过了,可以通过手动在每个对应的block的后面添加microcode来实现。但是IDA的问题在于:
  1. IDA没有提供单个指令的IR lifting接口,你只能制定一个函数或者EA范围,你不能直接提供给IDA一个bytes buffer和起始地址就直接让他lift到microcode,这是个很大的问题。
  1. IDA本身没有提供针对IR的parser,也就是说你没有办法直接用文本编写一个microcode指令,然后直接丢给IDA解析。你必须使用IDA提供的程序化接口强行手动写IR
所以这次我们试试ghidra,或许是一个更方便的方案?
 

准备工作

如何获取Pcode?

不用IDA的重要原因就是microcode不能方便的生成。那Pcode该怎么生成?有两套方案

1. 从反汇编获取Pcode

Ghidra给我们提供了PseudoDisassembler。Pseudo意为这个Disassembler的结果不会被写入到数据库里,只会用来给你在代码里面用。
使用PseudoDisassembler的接口可以对任意字节进行反汇编,得到SleighInstruction类。

2. 手写Pcode

Ghidra给我们提供了Call Fixup的机制,可以使用XML手写pcode。然后制定一个名称,放入“Program Specification Extensions”即可(https://ghidra.re/ghidra_docs/GhidraClass/Advanced/improvingDisassemblyAndDecompilation.pdf

Ghidra是怎么把Pcode传给Decompiler的?

Ghidra给每个指令生成Pcode,然后通过stdin喂给C++编写的decompiler,从而实现完整的反编译逻辑。
我们只要替换掉Ghidra在各个位置提供的Pcode,即可实现我们的想法。
可以在Ghidra源码里面翻到 DecompileCallback ,可以看到Pcode是通过getPcodePacked函数取出来,然后将Encode后的Pcode整个传给Decompiler的。

如何正确输出Pcode?

上面讲了可以用PseudoDisassembler,拿到Instruction之后就可以调用getPcodePacked。但是问题是如果我想把两个指令的Pcode拼起来怎么办?
观察getPcodePacked的实现:
PcodeEmitPacked emit = new PcodeEmitPacked(encoder, walker, context, fallOffset, override); emit.emitHeader(); emit.build(walker.getConstructor().getTempl(), -1); emit.resolveRelatives(); if (!isindelayslot) { emit.resolveFinalFallthrough(); } emit.emitTail();
可以看到Header - Body - Tail的结构。

思路1:给encoder增加一个wrapper

我们写一个wrapper,在正确的位置拦截掉header和tail是不是就可以了呢:
class EncoderWrap extends PatchPackedEncode implements PatchEncoder { public boolean interceptHeader = false; public boolean interceptTail = false; public boolean check() { if (interceptHeader) { // XXXX: 如果调用栈中包含 emitHeader,则直接返回 for (StackTraceElement ste : Thread.currentThread().getStackTrace()) { if ("emitHeader".equals(ste.getMethodName())) { return true; } } } if (interceptTail) { for (StackTraceElement ste : Thread.currentThread().getStackTrace()) { if ("emitTail".equals(ste.getMethodName())) { return true; } } } return false; } public void openElement(ElementId elemId) throws IOException { if (check()) { return; } super.openElement(elemId); } ... }
var tmpEncoder = new EncoderWrap(); tmpEncoder.clear(); tmpEncoder.interceptHeader = false; tmpEncoder.interceptTail = false; for (int i = 0; i < patchedIns.length; i++) { var instr = patchedIns[i]; if (i != 0) { tmpEncoder.interceptHeader = true; } if (i == patchedIns.length - 1) { tmpEncoder.interceptTail = false; } instr.getPrototype() .getPcodePacked(tmpEncoder, instr.getInstructionContext(), new InstructionPcodeOverride(instr)); } tmpEncoder.writeTo(((PatchPackedEncode)encoder).getOutputStream());
很不幸,这样不行,最后输出的内容会导致Decompiler崩溃,而且由于是RPC,很难调试,只好作罢

思路2:直接把整个getPcodePacked方法抄过来,然后自己控制Emit哪部分

用EncoderWrap可能是因为从stackframe来判断太不稳定了,所以我们直接手动Emit,或许就可以解决这些问题。
观察emit的逻辑:
  • emitHeader:主要输出Instruction的信息,比如fall offset(下一条指令的位置)等,没啥大问题
  • build:输出指令核心部分
  • resolveRelatives:负责处理labelref数组里面的重定位
  • emitTail:只闭合element,剩下啥都不干
核心的问题就是这个resolveRelatives能否正常工作,我们打开可以看到,addLabelRef并不会对labelref数组做操作,而是在dump里面随时输出随时patch,因此其实完全省略掉。
notion image
notion image
所以我们只需要用原始指令执行emitHeader和emitTail,然后把我们替换的指令的build输出出去就可以了
SleighParserContext protoContext = (SleighParserContext) context.getParserContext(); // if (delaySlotByteCnt > 0) {} // disable delay slot detect ParserWalker walker = new ParserWalker(protoContext); walker.baseState(); PcodeEmitPacked emit = new PcodeEmitPacked(encoder, walker, context, instrPrototype.getLength(), override); emit.emitHeader(); for (int i = 0; i < patchedIns.length; i++) { var insproto = (SleighInstructionPrototype)patchedIns[i].getPrototype(); var instr = patchedIns[i]; ParserWalker walker2 = new ParserWalker((SleighParserContext) instr.getInstructionContext().getParserContext()); walker2.baseState(); PcodeEmitPacked emit2 = new PcodeEmitPacked(encoder, walker2, context, instrPrototype.getLength(), override); emit2.build(walker2.getConstructor().getTempl(), -1); } emit.resolveRelatives(); emit.emitTail();

寻找合适的Hook点

思路1:hook getPcodePacked函数

直接hook这个getPcodePacked,会发现虽然decompiler输出有变化,但是Listing界面的pcode显示没有改变
这是因为Listing界面用的是getPcode函数

思路2:同时hook全部getPcode系列函数

都对了,但是似乎会导致栈变量识别失败。

思路3:从更底层的位置直接整个替换掉Pcode

猜测只hook Pcode的问题在于程序的flow信息是错误的,导致decompiler错误的跟踪了程序的CFG。除了这个问题可能还有其他的隐藏问题,或许直接替换Instruction的内部数据就可以一次性全解决了?
可以看到getPcode系列函数都是作用在SleighInstructionPrototype上的,能不能我们直接替换的内部结构,让他其他逻辑统一使用我们替换的Pcode?
  1. 分析内部field结构
  • SleighInstructionPrototype的初始化直接发生构造函数里的resolve函数调用,所以直接在这里hook
  • 通过调试观察结构,可以看到整个指令的信息存放在了
    • private ConstructState rootState;
  • rootState里面存放了ct,Constructor自然对应的是Sleigh里面的constructor概念
    • private Constructor ct;
  • Constructor里面存了ConstructTpl,是Sleigh解析slaspec后得到的模板
    • private ConstructTpl templ; // The main p-code template section
  • 里面进一步存了OpTpl,OpTpl就是实际的Pcode
    • private OpTpl[] vec; // The semantic action of constructor
  1. 用反射+unsafe写一个代码,复制一份新的rootState,然后修改里面的Pcode,发现虽然Pcode替换上去了,但是完全没有生效
  1. 打断点调试,可以看到代码里检测了hashCode相同的prototype,然后自动使用cache的prototype,所以我们不止需要shallowCopy,还同时需要修改hashcode
    1. notion image
  1. 修完后,会发现Disassemble的时候会出错,报错在cacheTreeInfo方法的walkTemplates调用里面,检查发现是因为没有同步更新substate(resolvedStates),这个field是用来针对Pcode里面的 MULTIEQUAL Opcode的(其实就是Phi Node)
var patchedIns = info.instrs; var opvec = new ArrayList<OpTpl>(); var resolvedStates = new ArrayList<ConstructState>(); for (var ins : info.instrs) { var state = ((SleighInstructionPrototype)ins.getPrototype()).getRootState(); var curOpVec = state.getConstructor().getTempl().getOpVec(); for (var i = 0; i < state.getNumSubStates(); i++) { resolvedStates.add(state.getSubState(i)); } opvec.addAll(Arrays.asList(curOpVec)); } var rootState = instrPrototype.getRootState(); var ct = rootState.getConstructor(); var newct = shallowCloneWithoutConstructor(ct); var newtempl = new ConstructTpl(opvec.toArray(new OpTpl[0])); setField(newct, "templ", newtempl); setField(newct, "id", (int)(0x1234568 + buf.getAddress().getOffset())); setField(rootState, "ct", newct); setField(rootState, "resolvedStates", resolvedStates); setField(instrPrototype, "hashcode", rootState.hashCode());
  1. 接着修完后续发现还会有更多的问题通过…虽然反汇编并不会报错,但是在getPcode的时候又会报错。
 
TL;DR:这条路走不通,原因如下:
  • Constructor、ConstructState这些东西太过复杂, Ghidra在这里实现了一整套树状的东西,并在上面做了大量的遍历操作。
  • 这些东西本来也不是让Java层去创建的:所有的这些函数完全都是从每个类的decode函数deserialize出来的,并没有标准的构造函数,因此完全无法从Java层直接构建。
  • 由于没有任何的内部代码在动态修改prototype,Ghidra在这一部分完全没有一个很好的设计文档,只有零散的函数注释,导致完全没有办法快速梳理出来这一块的逻辑在干什么。
因此,虽然这段代码在2019年Ghidra发布后就几乎没有改变过,但是并不值得冒着这么大的风险去调通这一大套东西。因此,直接修改SleighInstructionPrototype内部结构目前来看是不现实的。
 

最终方案:hook全部getPcode系列函数并更新InstructionPrototype

上面可以看到,直接hook getPcode是卓有成效的,只是缺少了控制流等信息。而直接修改底层Pcode牵扯到的东西又太多了。所以能不能结合一下这两者?
观察SleighInstructionPrototype内部对flowType和其他字段的修改,可以看到绝大部分的操作都是来自于cacheInfo函数。
notion image
而cacheInfo函数里,我们其实并不关心剩下的几个info,只有cacheTreeInfo涉及到控制流等关键信息,所以我们只要临时替换掉SleighInstructionPrototype的rootState,然后依次执行cacheTreeInfo,即可形成正确的内部信息。
var oriState = instrPrototype.getRootState(); for (var ins : info.instrs) { var state = ((SleighInstructionPrototype)ins.getPrototype()).getRootState(); setField(instrPrototype, "rootState", state); callMethod(instrPrototype, "cacheTreeInfo"); } setField(instrPrototype, "rootState", oriState);