TheQmaks/marrow
GitHub: TheQmaks/marrow
Marrow 是一款针对 HotSpot JVM 的动态插桩工具包,通过运行时解析 vmStructs 元数据实现无需 JVMTI 和调试符号的方法 hook、字段监控与堆遍历,跨 JDK 8-25 通用且仅限 Windows x64。
Stars: 0 | Forks: 0
# Marrow
一个针对 HotSpot 的、相当于 Frida 的动态插桩工具包。读取字段、hook 方法、替换返回值、通过硬件断点监控变量、克隆类——所有这些都可以通过将 JS 脚本实时推送到运行中的 JVM 来完成。
**无需 JVMTI。无需 `-agentpath`。无需 `-XX:+UnlockDiagnosticVMOptions`。无需 JDWP。无需 Attach API。无需 PDB / DbgHelp。**
Marrow 驱动 HotSpot 的方式与 HotSpot 自身的 Serviceability Agent 相同:通过在运行时读取导出的 `gHotSpotVMTypes` / `gHotSpotVMStructEntries` 数组并直接访问内存。每一个字段偏移量、每一个方法入口、每一个 Klass 布局都是从你当前使用的 JVM 中解析出来的,而不是基于写死的内置表。诸如 `JavaCalls::call`、`JNIHandles::make_local`、`InstanceKlass::initialize` 这样的内部 HotSpot 符号,通过结构化的交叉引用遍历器(PE 导出表 + RIP 相对调用点分析)进行解析——无需 `.pdb` 附带文件,无需调试信息。这就是为什么单个二进制文件能在 JDK 8 到 JDK 25 上通用的原因。
其主要功能面——方法 hook、字段读取、堆遍历、类枚举、硬件断点监控、通过 `JVM_DefineClass`(PE 导出,无需 PDB)进行类注入——都是纯粹的内存操作加上 vmStructs,不依赖 JNI Function API。
从 JS 调用 Java 方法是一个已有文档说明的折中方案:
- 对象参数分派通过 `_invokeJC` 进行——这是一个桥接到 HotSpot 内部 `JavaCalls::call` 的通道,其地址在运行时通过交叉引用解析。
- 在 JDK 8 / 11 上,原始类型的静态分派仍然通过 `_invokeJNI` 进行,它与会 JNIEnv vtable 通信。更深入的 JC (JavaCalls) 路径存在一个因 JDK 版本而异的 `JavaCallArguments` 布局问题,该问题被推迟到后续的逆向工程工作中解决。
- JC 异常检查使用 `JNIEnv->ExceptionCheck`,因为 vmStructs 在 JDK 17+ 上没有暴露 `Thread::_pending_exception`。通过经验性的 Thread 布局遍历器进行替换(类似于 `_compiler_flags` 间隙启发式方法)已被列入未来的工作计划。
引导过程依赖 JNI Invocation API (`JavaVM->GetEnv`) 来获取用于类定义和异常轮询的 `JNIEnv*`——这是每个 JVM 客户端都会使用的嵌入器契约,与正逐渐被移除的 JNI Function API 接口不同。
## 状态
- **平台。** Windows x64。Linux/macOS 不在规划中。
- **兼容性。** HotSpot JDK 8, 11, 17, 21, 25——包括 JDK 和 JRE 发行版,支持所有受支持的 GC(G1、Parallel、Serial、Shenandoah、ZGC)。
- **成熟度。** v1.0.2——每个测试套件在每个支持的运行时上都通过了严格模式。API 遵循 semver 承诺;破坏性变更会触发主版本号升级。v1.0.2 彻底移除了 PDB / DbgHelp 解析层,并用 PE 导出 + 结构化交叉引用遍历取代了它。移除了 `Marrow.pdbSymbolAt` / `Marrow.pdbSymbolsLike`(没有可用的替代品——它们是面向调试映像用户的诊断辅助工具)。移除了 `_diagJniNewLocal`(它是一个 JNI Function API 诊断功能,并非关键负载)。
## 它的功能
Marrow 提供两种接口,均由相同的 vmStructs 元数据提供支持:
**进程外 (Python)。** 针对目标 JVM 使用 `ReadProcessMemory`/`WriteProcessMemory`。遍历类字典,解码 oops,转储字符串,快照堆。目标中不运行任何代码——适用于取证和事后分析。
**进程内 (C++ agent + JS)。** 将 `marrow_agent.dll` 注入目标,通过命名管道向其推送 JS 脚本。Agent 嵌入了 Duktape 并暴露了兼容 Frida 的 `Java.*` API 以及较低级别的 `Marrow.*` 原语层。Hook 方法、替换返回值、在真实实例上调用方法、遍历活动堆、安装硬件断点字段监控。
同一个脚本可以随心所欲地进行热重载。`Java.reload()` 会拆除所有先前的 hook 并重新评估引导程序。
## 为什么不直接使用 Frida?
Frida 针对的是原生代码,并将 JVM 视为黑盒。而 Marrow 原生理解 HotSpot:
| | Frida | Marrow |
|----------------------------|-----------------------------|-------------------------------------------|
| Java 方法 hooks | 通过 Java.use()(基于 JNI) | 直接:修补 `Method::_from_interpreted_entry` |
| 字段读取/写入 | 通过 JNI | vmStructs 偏移量 + 原始内存 |
| 硬件字段监控 | 否 | 是 — DR0–DR3 观察点 |
| 堆类直方图 | 否 | 是 — 遍历 GC 区域 |
| Klass 克隆 | 否 | 是 — 向 SystemDictionary 注册克隆 |
| 字节码重写 | 否 | 是 — 完整的方法体交换 |
| 进程外读取器 | 否 — 必须注入 | 是 — Python `RemoteReader` |
| 需要 JVMTI/Attach | 有时需要 | 从不需要 |
| 精简版 JVMs(无 PDB) | 受限 | 可用 — vmStructs 是导出的,不依赖于符号 |
代价是:Marrow 仅限 HotSpot 和仅限 Windows。Frida 是跨平台的。请根据你的实际需求进行选择。
## 快速开始
### 1. 构建
你需要 MSVC 2022 + CMake 3.15+。Visual Studio 2022 Build Tools 即可满足要求。
```
cd jvm-probe/cpp
cmake -B build -G "Visual Studio 17 2022" -A x64
cmake --build build --config Release
```
生成产物:
- `cpp/build/Release/marrow.exe` — CLI(注入器 + 脚本流传输器)
- `cpp/build/Release/marrow_agent.dll` — 加载到目标中的 agent
### 2. 启动目标 JVM
```
cd tests/target
javac -d build *.java
java -cp build Target
# 目标 PID: 12345
# tick=0
# tick=1
# ...
```
### 3. Hook 一个方法
`hello.js`:
```
// Observe every Target.tick(n) call without changing its behaviour.
Java.use('Target').tick.attach(function(n) {
Marrow.log('tick observed: n=' + n);
});
// Replace Callable.addInts so it always returns 1234, regardless of args.
Java.use('Callable').addInts.implementation = function(a, b) {
Marrow.log('addInts(' + a + ',' + b + ') hijacked');
return 1234;
};
```
推送它:
```
$exe = "cpp\build\Release\marrow.exe"
$dll = "cpp\build\Release\marrow_agent.dll"
& $exe inject 12345 $dll
& $exe agent 12345 eval (Get-Content hello.js -Raw)
```
观察器会在目标每次执行 tick 时触发;替换是同步的——当 JVM 中的任何内容调用 `Callable.addInts(a, b)` 时,处理程序将从调用线程运行,并且 JVM 会收到 `1234`。无需重启 JVM,无需 agent 标志。
### 4. 进程外——完全无需注入
```
from vm_meta import VMMeta
from walker import ClassWalker
from string_reader import StringReader
from oop_reader import OopDecoder
vm = VMMeta.from_pid(12345)
dec = OopDecoder(vm)
sr = StringReader(vm, dec)
target_klass = next(k for k in ClassWalker(vm) if k.name == 'Target')
print(f'Target loaded at {target_klass.address:#x}')
```
相同的元数据,目标中从未进入任何 DLL。
## Hook 是如何触发的
```
JS: T.tick.implementation = fn
│
▼
┌─────────────────────────────────────────────┐
│ Agent allocates 132-byte trampoline in RWX │
│ - saves all GPRs + rflags │
│ - copies arg slots from native stack │
│ - calls the agent thread with snapshot │
│ - dispatch returns uint64 = (skip<<63) │
│ | replace_rax; trampoline reads rax │
│ after pops, no shared-memory probe │
│ - if skip bit set: RETs with masked rax │
│ - else jumps to original │
│ `_from_interpreted_entry` │
└─────────────────────────────────────────────┘
│
▼
Patches Method::_from_interpreted_entry → trampoline
Disables JIT for the method via Method::_access_flags
(or _compiler_flags on JDK 21+) so no nmethod is published
│
▼
Handler runs synchronously from the JVM thread,
under a recursive Duktape mutex. Reentry guard
per cookie lets the handler call the same method
on itself (Frida-style auto callOriginal).
```
`callOriginal`、`setReturn`、`.attach`(异步观察器)、`Java.choose`(活动实例枚举)和 `Java.cast`(代理到任意 oop 上)均基于此原语构建。
## JIT 存活
由 JS 驱动的 JVM 插桩面临的难题是 HotSpot 的分层编译器:在一个热点方法上安装 hook,经过几百次调用后,HotSpot 会发布一个新的 nmethod,而你的跳板并未覆盖其已验证的入口点。从 v0.3 如实记录的文档(在持续的 `callOriginal` 热循环中命中率约为 7%)到 v0.5 的最终解决:
- **在安装时对被 hook 的方法禁用 JIT。** v0.4 在 `Method::_access_flags` 上设置了 `NOT_C1_COMPILABLE | NOT_C2_COMPILABLE | NOT_C2_OSR_COMPILABLE` 位(JDK 8/11/17)。HotSpot 的 `CompilationPolicy::can_be_compiled()` 返回 false → 永远不会发布 nmethod → 对于 `callOriginal` 而言不存在发布/修补的竞争失败。
- **JDK 21+ 将这些位移到了** 一个单独的 `Method::_compiler_flags` 字段中,而当前的 vmStructs 主线并未暴露该字段。v0.5 通过经验性方法找到了它——遍历 Method 暴露的字段,按偏移量排序,识别出唯一的 4 字节间隙;那就是 `_compiler_flags`。通过回读进行验证;如果启发式方法在未来的 JDK 布局中出错,access_fields 回退机制仍会触发。
结果:在持续热循环的 `callOriginal` 插桩下,实现了 **5000/5000 命中**,与 `-Xint` 基准相同,适用于从 JDK 8 到 25 的所有版本。无需 JVMTI,无需 Attach API,源代码中也无需硬编码的因 JDK 版本而异的偏移量。
## 跨 JDK 矩阵
`tests/agent_smoke.py` 覆盖了每种受支持的 JDK 和 GC 组合上的所有 CLI/agent 命令。五个压力测试套件在整个矩阵中以严格模式运行(不接受不稳定的测试结果)。
| JDK | G1 | Parallel | Serial | Shenandoah | ZGC | 宽 oops |
|-----|----|----------|--------|------------|-----|-----------|
| 8 | ✓ | ✓ | ✓ | (n/a) | (n/a) | ✓ |
| 11 | ✓ | ✓ | ✓ | ✓ | (Win: 14+) | ✓ |
| 17 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| 21 | ✓ | ✓ | ✓ | ✓ | ✓ 分代 | ✓ |
| 25 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
**严格模式回归矩阵。** 六个测试套件 × 十个运行时 (JDK 8/11/17/21/25 + JRE 8/11/17/21/25) = **60/60 PASS**:
| 套件 | JDK 8/11/17/21/25 | JRE 8/11/17/21/25 |
|----------------|-------------------|-------------------|
| agent_smoke | 24/24 ×5 | 24/24 ×5 |
| verify_stress | 34/34 ×5 | 34/34 ×5 |
| verify_stress2 | 13/13 ×5 | 13/13 ×5 |
| verify_stress3 | 11/11 ×5 | 11/11 ×5 |
| verify_stress4 | 5/5 ×5 | 5/5 ×5 |
| verify_stress5 | 6/6 ×5 | 6/6 ×5 |
自行运行矩阵:
```
# Single JDK
$env:MARROW_TEST_JDK = "17"
python tests/agent_smoke.py
foreach ($s in @('','2','3','4','5')) { python "tests/verify_stress$s.py" }
# JRE flavor — 相同的测试,$MARROW_TEST_RUNTIME 选择 runtime
$env:MARROW_TEST_RUNTIME = "jre"
python tests/agent_smoke.py
# Out-of-process Python API matrix
python tests/matrix_smoke.py # ReadProcessMemory layer
python tests/matrix_cpp_smoke.py # CLI + injected agent
```
## 示例
真实的 Java 程序配合 Marrow 脚本来破解它们。每个目录都包含 `App.java` 以及一个或多个 `.js` 脚本:
| 演示 | 展示的原语 |
|-----------------------------------|-----------------------------------------|
| `examples/01_license_bypass` | `T.method.setReturn(value)` |
| `examples/02_password_sniff` | `T.method.implementation = fn` |
| `examples/03_game_score` | `Java.choose` + 字段设置器 |
| `examples/04_profile` | `Java.traceClass` + 计数器 |
| `examples/05_crypto` | hook `Cipher.doFinal`,解码参数 |
| `examples/06_leak` | 堆差异快照 |
| `examples/07_field_watch` | `Java.watchField` (DR0–DR3) |
| `examples/08_network` | hook `URL` / `HttpURLConnection` |
| `examples/09_native_recv` | `Java.onNative('ws2_32.dll', 'recv')` |
| `examples/10_hotkey_toggle` | `Java.onKey` + 静态字段翻转 |
| `examples/11_object_arg` | hook 函数, `Java.cast(argOop, 'Klass')` |
| `examples/12_object_inspect` | 遍历参数树,解码嵌套的 String |
| `examples/13_ergonomic_cheat` | 完整 UI:热键切换作弊套件 |
| `examples/14_hotloop_survival` | JIT 下 5 万次迭代的 callOriginal — 100% |
| `examples/15_tls_trust_bypass` | 击败固定证书的 X509TrustManager |
| `examples/16_request_observer` | 异步 `.attach` HTTP 请求日志 |
| `examples/17_auth_intercept` | 强制登录 + 记录凭据 |
关于**进程外 Python API**(无需 DLL 注入,仅使用 `ReadProcessMemory`/`WriteProcessMemory`),请参阅 `examples/python/`。包含 18 个独立脚本,涵盖类遍历、堆检查、ConstantPool 手术硬件观察点、ZGC 解码等。
## 性能
以下数据来源于 `tests/bench_hooks.py`,在 Windows x64、默认 GC 的 JDK 17 上测得。工作负载:通过 Marrow 的 JS 代理在紧凑循环中调用静态 `addInts(int,int)` 方法。
| 变体 | 每次操作 |
|----------------------------------|--------|
| 基线(无 hook,JS+JNI 分派) | ~21 μs |
| `.implementation = fn` 开销 | +0.3 μs |
| 安装延迟 | 每次安装/卸载约 ~450 ms |
21 μs 的基线主要由 Marrow 的 JS 侧分派路径(JNI 接口 vtable 查找 + 参数转换)决定;没有 Marrow 的原生 Java 调用是亚微秒级的。安装延迟反映了 Marrow 在入口点补丁周围执行的 SuspendThread/ResumeThread 操作;一旦安装完成,运行时开销可忽略不计。对于高吞吐量的可观察性,首选 `.attach`(异步)而不是 `.implementation`(同步);异步路径写入每个 cookie 的环形缓冲区,并且 JS 处理程序在排空时运行,而不是在每次触发时运行。
## API 稳定性 (v1.0+)
Marrow 从 v1.0.0 开始遵循 [语义化版本控制](https://semver.org/)。保证稳定的公开接口包括:
- **JS Frida 兼容 API**:`Java.use`、`Java.cast`、`Java.choose`、`Java.invoke`、`Java.invokeStatic`、`Java.toString`、`Java.drain`、`.implementation = fn`、`.attach(fn)`、`.callOriginal`、`.setReturn`。
- **JS Marrow 原语**:`Marrow.log`、`Marrow._invokeJC`、`Marrow._invokeJNI`(有文档说明的折中方案——请参阅 README 顶部)及其参数形式。
- **CLI**:`marrow.exe inject`、`marrow.exe agent eval `、`marrow.exe dump`、`marrow.exe threads`、`marrow.exe classes`。
- **进程外 Python API**:`VMMeta`、`Reader`、`ClassWalker`、`OopDecoder`、`StringReader`。
不在 semver 范围内的:
- 内部 C++ 符号(`marrow_hook_dispatch` ABI、`HookContext` 布局、跳板 ASM 编码)——这些在 v0.4 → v0.6 → v0.7 之间发生了变化,并且可能再次改变。
- 诊断辅助工具(`Marrow._dbg*`,内部探测脚本)。
- 经验性启发(例如 `_compiler_flags` 间隙检测)——它们可能会在没有警告的情况下在未来的 JDK 上选择不同的偏移量,但面向用户的语义(“hook 在 JIT 下保持有效”)仍然得到保证。
对稳定接口的破坏性变更将触发主版本号升级。
## 限制
- 仅限 Windows x64。不支持 Linux,不支持 macOS。
- 仅限 HotSpot——不支持 OpenJ9,不支持 GraalVM Native Image。
- Windows 上的 ZGC 需要 JDK 14+(Temurin 11 不附带它)。
- JDK 21 上的 ZGC 需要 `-XX:+ZGenerational`(旧版单代没有我们可以解码的导出颜色掩码)。
- 在 v2.0+ 的每个 JDK 中,`Class.forName` 对注入类的可达性已被阻止。v1.0 提供了一个 `_defineClassNative` 快捷方式,它通过 `JVM_DefineClass`(通过 PDB 解析)或 `JNIEnv->DefineClass` 进行——这两者都违反了项目“无 JNI,无 PDB”的原则。更深层的路径(Metaspace 分配 + 通过类似于 `_compiler_flags` 间隙检测的经验性启发进行每 JDK 版本的 SystemDictionary 偏移检测)已列入未来的工作计划。
- JC 异常检查辅助程序内部仍然使用 `JNIEnv->ExceptionCheck`,因为 vmStructs 在 JDK 17+ 上没有暴露 `Thread::_pending_exception`。有文档说明的折中方案;用经验性的 Thread 布局遍历器替换它已列入未来的工作计划。
- 精简版的 `jvm.dll` 适用于所有情况(我们不需要 PDB)。从 v0.5 开始完全支持仅 JRE 发行版——相同的测试矩阵在我们交付测试的每个 JRE 上都通过了严格模式。
## 文档
- [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md) — 15 分钟的实践之旅。
- [docs/API.md](docs/API.md) — `Java.*` 和 `Marrow.*` 的完整参考。
- [examples/](examples/) — 带有 Java 源码 + JS 脚本的注释演示。
- [CHANGELOG.md](CHANGELOG.md) — 版本历史。
- [CONTRIBUTING.md](CONTRIBUTING.md) — 如何构建、测试和贡献。
## 许可证
[Apache License 2.0](LICENSE)。
Marrow 在其 agent 内部嵌入了 [Duktape](https://github.com/svaarala/duktape)(MIT 许可证)。
## 致谢
Marrow 站在 HotSpot 自身的 Serviceability Agent 的肩膀上——导出的 `gHotSpotVMTypes` / `gHotSpotVMStructEntries` 数组使得无 PDB、与版本无关的内省成为可能。灵感也来源于 [Frida](https://frida.re)(我们刻意保持与其兼容的 JS API)以及 JVM 原生类转储的研究方向。
标签:API接口, Bash脚本, EDR绕过, Frida替代, GHAS, HotSpot虚拟机, JavaScript脚本, Java动态分析, Java安全, JVM安全, SecList, SSH蜜罐, vmStructs, Windows x64, 云资产清单, 代码生成, 内存取证, 内存操作, 动态Hook, 动态二进制插桩, 动态分析框架, 后渗透执行, 字节码操作, 安全测试, 底层安全, 攻击性安全, 数据可视化, 方法Hook, 无JNI, 无JVMTI, 无侵入式插桩, 渗透测试工具, 硬件断点, 类注入, 绕过安全机制, 自动化分析, 跨版本JDK, 跨站脚本, 运行时Hook, 进程注入, 逆向工具, 逆向工程, 高交互蜜罐