Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 16 additions & 41 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,58 +53,33 @@ Key contracts:

### `rmi` — JRMP (Java RMI) wire-protocol parser

Read-only parser for a single direction of a JRMP Stream-protocol (`0x4B`) byte stream **addressed at `java.rmi.registry.Registry`**. Consumes either a client→server or server→client capture and produces a `Transmission` tree: optional `Handshake` (client side) or `Acknowledge` (server side), optional `ClientEndpoint` echo, then a `Messages []Message` list.
Read-only parser for a single direction of a JRMP Stream-protocol (`0x4B`) byte stream **addressed at `java.rmi.registry.Registry`**. Produces a `Transmission`: optional `Handshake` + `ClientEndpoint` (client→server) or `Acknowledge` (server→client), then `Messages []Message` (`CallMessage` / `ReturnMessage` / `PingMessage` / `PingAckMessage` / `DgcAckMessage`). Every frame satisfies `Message` (`Op() byte` + `ToString() string`).

**Scope: Registry only.** This module deliberately supports only one Remote interface — the well-known `sun.rmi.registry.RegistryImpl_Stub`. Any `MsgCall` whose header doesn't pass the dispatch gate (ObjID == `REGISTRY_ID` **and** methodHash == `RegistryInterfaceHash` **and** op ∈ [0..4]) is rejected at parse time in `readCall`. Non-Registry Remote interfaces each have their own method-hash table that we don't carry, and the project's use case (intercepting `rmiregistry` traffic for exploitation or analysis) is fully served by Registry coverage. If you need a general JRMP parser, this is not it.
**Scope: Registry only.** Any `MsgCall` whose header fails the dispatch gate (ObjID == `REGISTRY_ID` **and** methodHash == `RegistryInterfaceHash` **and** op ∈ [0..4]) is rejected at parse time in `readCall`. Registry uses the legacy `int32 operation = 0..4 + int64 RegistryInterfaceHash` form (op-index + shared interface hash); modern JRMP's `operation = -1 + per-method hash` is dynamic-proxy-stub only and NOT used here. `RegistryInterfaceHash` in `model.go` is calibrated against Zulu OpenJDK 17 — if it starts failing after a JDK upgrade, print `MethodHash` as hex and recalibrate (one-line edit). To add a new message type, extend `MsgXxx` in `model.go`, implement `Message`, add a case to `rmi/message.go:readMessage` — single extension point.

**Two entry points.** Pick by input shape, not by input source:
- `rmi.FromBytes(data []byte)` — for bytes already in memory (`.bin` captures, `io.ReadAll` of an HTTP body, etc.). Loops until `io.EOF` and returns the whole `Transmission`. **Never use with a live `net.Conn`**: the loop blocks on the next `PeekN(1)` after the last frame and deadlocks when the peer stays open waiting for a reply — which is the typical synchronous-RPC pattern.
- `rmi.Decoder` (`rmi/decoder.go`) — frame-by-frame, for live readers. `NewDecoder(r)` wraps any `io.Reader`; `Opening()` consumes the optional handshake prefix; `Next()` returns one `Message` per call or `io.EOF`. This is the only sensible choice for a long-lived TCP connection: callers apply `SetReadDeadline` on the underlying `net.Conn` between `Next()` calls to bound how long they wait for the next frame.
**Two entry points. Pick by input shape, not source.**
- `rmi.FromBytes(data []byte)` — fully-buffered captures only. **Deadlocks on a live `net.Conn`** (loops until EOF, the peek after the last frame blocks while the peer waits for a reply).
- `rmi.Decoder` — frame-by-frame over any `io.Reader`. Only sensible choice for live TCP. See the `Decoder` godoc for full usage.

Both rely on `serz.NewObjectStreamFromStream(*commons.Stream)` so the embedded serialization parse shares a byte cursor with the outer framing reader — no double-buffering, no peeked-byte loss at handoff.
**Opening phase has two live-reader traps.**

**Arg/payload-reading strategy.**
- `readCallArgs` — **exact count, no peek**. Once `readCall` passes the Registry dispatch gate, `registryArgCount(op)` gives the stub method's known arity, and `readCallArgs` reads precisely that many TCContents. The parser returns as soon as the frame's own bytes arrive — critical on a live TCP reader where a Registry client sends one Call and then waits for the server's response before sending anything else. A peek-ahead scheme would deadlock.
- `readReturn` — **sentinel**. Payload count is 0 (void method: bind/rebind/unbind) or 1 (list/lookup value / exception Throwable), and that choice depends on the originating Call's return type. Direction-agnostic parsing can't correlate Returns to outstanding Calls, so we fall back to a sentinel: read TCContents until `PeekN(1)` yields a byte outside `[JAVA_TC_BASE, JAVA_TC_MAX]` (= `[0x70, 0x7F]`) or `io.EOF`; assert count ≤ 1. TC_* and JRMP-flag (`[0x50, 0x54]`) ranges are disjoint, so the check is unambiguous. **On a live reader the terminating peek blocks** until the next frame's flag byte arrives, the peer closes (`io.EOF`), or the reader's deadline fires. Callers that process Returns over a live connection must set `SetReadDeadline` on the underlying `net.Conn`.

Every JRMP frame implements `Message` (`Op() byte` + `ToString() string`). Five frame types:

- **`CallMessage`** (0x50) — wraps an embedded serialization stream. The first `TC_BLOCKDATA` holds 34 bytes of primitive writes (`ObjID(22) + int32 op + int64 methodHash`). Remaining `TCContent` entries are the method arguments. On a successful parse, `Operation` is always one of the five `{Bind, List, Lookup, Rebind, Unbind}OpIndex` constants and `Decoded` is populated; a non-fatal decoder error (e.g. malformed string arg) can still leave `Decoded` nil while `Raw` / `ObjectArgs` hold the raw tree. `ToString()` renders the stream in wireshark-dissector style — see the rendering note below.
- **`ReturnMessage`** (0x51) — same embedded-stream shape, 15 bytes of primitives (`returnType + UID`) then ≤1 payload `TCContent` (value / exception / none-for-void).
- **`PingMessage`** (0x52), **`PingAckMessage`** (0x53) — single-byte frames, no payload.
- **`DgcAckMessage`** (0x54) — raw 14-byte UID written outside any `ObjectOutputStream` framing (the only JRMP frame that does *not* go through `serz`).

**The critical design point worth internalizing**: a single Java serialization stream has no explicit end marker — `serz.FromReader` terminates only on `io.EOF`. Inside JRMP, the next byte after a Return body is the next message's flag (`0x50..0x54`), which is neither `io.EOF` nor a valid `TC_*` tag. The sentinel in `readReturn` (`rmi/return.go`) walks `serz.ReadTCContent` and stops when `PeekN(1)` returns a byte outside `[serz.JAVA_TC_BASE, serz.JAVA_TC_MAX]`. **Do not refactor this to use `serz.FromBytes`** — doing so would fail on any stream with more than one frame. (Calls don't need the sentinel because `registryArgCount` gives the exact count.)

**Registry dispatch is op-index + interface hash, not per-method hash.** The JDK ships a precompiled `sun.rmi.registry.RegistryImpl_Stub` whose wire format is `operation = 0..4` (indexing into `{bind, list, lookup, rebind, unbind}`) paired with a single `int64 RegistryInterfaceHash` shared by all five methods. Modern JRMP's `operation = -1 + per-method hash` pattern applies only to dynamic-proxy stubs and is NOT used by Registry. `rmi/model.go` exposes the five op-index constants (`LookupOpIndex`, etc.) and the `RegistryInterfaceHash` constant (calibrated against a live Zulu OpenJDK 17 capture; see `testcases/rmi/*.bin` and `_tools/rmi-capture/`). If a real Registry capture starts failing the hash check after a JDK upgrade, print `MethodHash` as hex and recalibrate — it's a one-line edit.

Adding a new message type (e.g. if SingleOp/Multiplex support is ever added): extend the `MsgXxx` constants in `model.go`, define `*XxxMessage` implementing `Message`, add a case to the `switch` in `rmi/message.go:readMessage`. The dispatcher is the single extension point.

The endpoint-echo heuristic in `parser.go:maybeReadClientEndpoint` peeks the byte right after the handshake: if it falls in `[0x50, 0x54]` (JRMP message flag range) we skip the echo, otherwise read `writeUTF + int32`. This lets hand-crafted test fixtures omit the echo. The ambiguity only collides on pathological (≥ 20480-char) hostnames.

**Live-TCP ergonomics.** On a long-lived `net.Conn` a typical Registry session has the shape handshake → Call → (peer waits for Return) → Return → close. `FromBytes` would deadlock on the second peek after the first frame; `Decoder` is the only viable choice:
First, `Opening()` reads `Handshake + ClientEndpoint` in one call — **deadlocks on server-side reads** because a conforming Java client (`sun.rmi.transport.tcp.TCPChannel`) blocks after its 7-byte handshake waiting for the server's `ProtocolAck` before writing the endpoint echo. Servers must use the split primitives:

```go
d := rmi.NewDecoder(conn)
opening, _ := d.Opening() // optional — read handshake/ack/endpoint
for {
_ = conn.SetReadDeadline(time.Now().Add(30 * time.Second))
msg, err := d.Next()
if errors.Is(err, io.EOF) { break }
if err != nil { return err }
handle(msg)
}
hs, _ := d.ReadHandshake() // 7 bytes only
_, _ = conn.Write((&rmi.Acknowledge{Host: h, Port: p}).ToBytes()) // unblocks client
ep, _ := d.ReadClientEndpoint() // echo now arrives
```

`Decoder.Next()` returns one frame per call. Registry Calls return as soon as their own bytes arrive (no peek past last arg). ReturnData uses the sentinel and may block between frames on a live reader until the next flag byte arrives or the reader's deadline fires — the caller is responsible for the deadline. `Opening()` is optional; if omitted, the first `Next()` transparently consumes and discards any handshake prefix. Calling `Opening()` twice, or after `Next()`, returns an error.
`ReadAcknowledge()` is the symmetric client-side primitive. A stage enum (`stageInitial → stageAfterHandshake → stageReady`) enforces ordering; `Opening()` and the three `Read*` primitives are mutually exclusive. `Next()` auto-advances from any earlier stage to `stageReady`. `Handshake` / `Acknowledge` / `Endpoint` all expose `ToBytes()` with zero-value defaults (`JRMI_MAGIC` / `ProtocolStream` / `AckFlag`) so server code can skip those fields.

**`ToString()` rendering is wireshark-dissector style — every byte is printed exactly once, with semantic labels inline.** `Call/ReturnMessage.ToString()` emits a compact `@Decoded` summary (method + scalar args; complex args referenced by handler) at the top, then `@Serialization` with a custom walk (`rmi/printer.go`):
**Arg/payload reading strategies are not interchangeable.**
- `readCallArgs`: **exact count**, no peek. `registryArgCount(op)` gives the stub method's arity; the parser returns as soon as the frame's own bytes arrive — critical because a Registry client sends one Call and then waits for the Return before writing anything else.
- `readReturn`: **sentinel**. Payload count (0 for void bind/rebind/unbind, 1 for list/lookup/Throwable) depends on the originating Call, which direction-agnostic parsing can't correlate. So we peek after the 15-byte primitive header and stop when the next byte falls outside `[JAVA_TC_BASE, JAVA_TC_MAX]` (= `[0x70, 0x7F]`). Works because TC_* and JRMP-flag (`[0x50, 0x54]`) ranges are disjoint. **On live readers this peek blocks until the next frame's flag byte, EOF, or a read deadline fires** — callers must `SetReadDeadline`.

- The leading `TC_BLOCKDATA` that carries the Call's 34-byte `ObjID + op + hash` (or the Return's 15-byte `returnType + UID`) is replaced in-place with its decomposed fields, each showing both decimal *and* the matching byte slice.
- Every subsequent `TCContent` arg has its header line annotated: `TC_STRING - 0x74 (Registry.lookup arg 0: "name")`, `TC_OBJECT - 0x73 (Registry.bind arg 1: "obj")`.
- Remote-stub TCContent subtrees (bind/rebind `obj`) appear exactly once, inside `@Serialization`; `@Decoded.Args` references them by handler — **do not reintroduce full-subtree dumping in `DecodedCall.ToString`**, that's the duplication this layout fixes.
**Critical invariant.** A Java serialization stream has no end marker — `serz.FromReader` terminates only on EOF. Inside JRMP the byte after a Return body is the next message's flag (`0x50..0x54`), which is neither EOF nor a valid `TC_*` tag. **Do not refactor `readReturn` to use `serz.FromBytes`** — it would fail on any stream with more than one frame. (Calls don't need the sentinel because the arg count is known.)

Fields outside `@Serialization` (`Handshake.Magic/Version/Protocol`, `Acknowledge`/`ClientEndpoint.Host/Port`) keep their raw hex because no other section carries those bytes.
**`ToString()` rendering is wireshark-dissector style — every byte is printed exactly once, semantic labels inline.** `Call/ReturnMessage.ToString()` emits a compact `@Decoded` summary at the top, then `@Serialization` via `rmi/printer.go`: the leading `TC_BLOCKDATA` is replaced by its decomposed primitive fields (decimal + hex for each), every subsequent `TCContent` arg gets an inline annotation (`TC_STRING - 0x74 (Registry.lookup arg 0: "name")`), and remote-stub subtrees appear **exactly once** inside `@Serialization` (referenced by handler in `@Decoded.Args`). **Do not reintroduce full-subtree dumping in `DecodedCall.ToString`** — that's the duplication this layout fixes.

### `class` — JVM `.class` parser

Expand Down
Loading
Loading