Skip to content
Closed
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
149 changes: 145 additions & 4 deletions declib/cli/decompiler_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
- sync copy work on a function from one server into another
- list_strings list strings in the binary, optionally filtered by regex
- get_callers functions (call sites only) that call a target
- read typed reads: int/string/struct at an address
- read_memory read raw bytes from the binary at an address
- install-skill install the bundled Agent Skill so LLMs learn the CLI
"""
Expand Down Expand Up @@ -1267,11 +1268,15 @@ def cmd_read_memory(args) -> int:
raise SystemExit(f"--size must be > 0 (got {args.size})")

with _with_client(args) as client:
data = client.read_memory(addr_value, args.size)
# Normalize absolute addresses to the server's lifted form so that
# `0x402320` and the image-relative `0x2320` both land on the same
# byte instead of the backend double-adding the image base.
lifted = _to_lifted_addr(client, addr_value)
data = client.read_memory(lifted, args.size)
if data is None:
raise SystemExit(
f"Backend could not read 0x{args.size:x} bytes at "
f"{_format_addr_hex(addr_value)}. The address may be "
f"{_format_addr_hex(lifted)}. The address may be "
"uninitialized, unmapped, or outside any loaded segment."
)
# Some backends return short reads when the request straddles the
Expand All @@ -1285,7 +1290,7 @@ def cmd_read_memory(args) -> int:

if args.json:
payload = {
"addr": addr_value,
"addr": lifted,
"size": actual_size,
"requested_size": args.size,
"bytes_b64": base64.b64encode(data).decode("ascii"),
Expand All @@ -1299,7 +1304,7 @@ def cmd_read_memory(args) -> int:
return 0

# Default: hexdump-style output.
for line in _hexdump(data, base_addr=addr_value):
for line in _hexdump(data, base_addr=lifted):
print(line)
if actual_size < args.size:
print(
Expand All @@ -1309,6 +1314,106 @@ def cmd_read_memory(args) -> int:
return 0


# ---------------------------------------------------------------------------
# read (typed: int / string / struct)
# ---------------------------------------------------------------------------

def _decode_scalar(type_str: Optional[str], chunk: bytes, endian: str = "little"):
"""Best-effort decode of a struct member's bytes based on its C type."""
if not chunk:
return None
t = (type_str or "").strip()
if t.endswith("*"): # pointer
return _format_addr_hex(int.from_bytes(chunk, endian, signed=False))
if "char" in t and "[" in t: # char array -> string
nul = chunk.find(b"\x00")
raw = chunk[:nul] if nul != -1 else chunk
return raw.decode("utf-8", errors="replace")
if "[" not in t and len(chunk) in (1, 2, 4, 8): # scalar integer
return int.from_bytes(chunk, endian, signed=False)
return None # unknown/aggregate: caller still has the hex


def cmd_read(args) -> int:
"""Typed reads: decode memory as an integer, C string, or struct."""
action = args.read_action
with _with_client(args) as client:
addr_value, _ = _parse_target(args.addr)
if addr_value is None:
raise SystemExit(f"Invalid address {args.addr!r}; expected hex (0x..) or decimal.")
lifted = _to_lifted_addr(client, addr_value)
endian = "big" if getattr(args, "endian", "little") == "big" else "little"

if action == "int":
size = args.size or (client.default_pointer_size or 8)
data = client.read_memory(lifted, size)
if data is None or len(data) < size:
raise SystemExit(
f"Could not read {size} bytes at {_format_addr_hex(lifted)}."
)
value = int.from_bytes(data[:size], endian, signed=args.signed)
_emit(args, {
"addr": lifted, "size": size, "signed": bool(args.signed),
"endian": endian, "value": value,
"hex": "0x" + data[:size].hex(),
})
return EXIT_OK

if action == "string":
data = client.read_memory(lifted, args.max_len)
if data is None:
raise SystemExit(f"Could not read a string at {_format_addr_hex(lifted)}.")
nul = data.find(b"\x00")
raw = data[:nul] if nul != -1 else data
text = raw.decode(args.encoding, errors="replace")
_emit(args, {
"addr": lifted, "length": len(raw),
"truncated": nul == -1 and len(data) >= args.max_len,
"string": text,
}, text_field="string")
return EXIT_OK

if action == "struct":
try:
struct = client.structs[args.name]
except KeyError:
raise SystemExit(
f"Struct {args.name!r} is not defined. Define it with "
f"`create-type` first, or check the name."
)
size = struct.size or 0
if not size and struct.members:
size = max((off + (m.size or 0)) for off, m in struct.members.items())
if not size:
raise SystemExit(f"Struct {args.name!r} has zero size; nothing to read.")
data = client.read_memory(lifted, size)
if data is None:
raise SystemExit(
f"Could not read {size} bytes for struct {args.name!r} at "
f"{_format_addr_hex(lifted)}."
)
members = []
for off in sorted(struct.members):
m = struct.members[off]
msize = m.size or 0
chunk = data[off:off + msize] if msize else b""
members.append({
"offset": off, "name": m.name, "type": m.type, "size": msize,
"hex": chunk.hex(), "value": _decode_scalar(m.type, chunk, endian),
})
if args.json:
_emit(args, {"addr": lifted, "struct": args.name, "size": size, "members": members})
else:
print(f"struct {args.name} @ {_format_addr_hex(lifted)} (size {size}):")
for m in members:
val = "" if m["value"] is None else f" = {m['value']}"
print(f" +0x{m['offset']:<4x} {str(m['name'] or ''):<16} "
f"{str(m['type'] or ''):<18}{val} ({m['hex']})")
return EXIT_OK

raise SystemExit(f"Unknown read action: {action}")


def _hexdump(data: bytes, *, base_addr: int = 0, width: int = 16) -> List[str]:
"""Return a list of hexdump lines like ``addr: hh hh ... |ascii|``."""
lines: List[str] = []
Expand Down Expand Up @@ -1787,6 +1892,42 @@ def _add_comment_addr_action(name, help_text, with_text=False):
_add_output_args(p_gc)
p_gc.set_defaults(func=cmd_get_callers)

# read (typed)
p_read = sub.add_parser(
"read",
help="Typed reads: decode memory as an integer, C string, or struct.",
)
read_sub = p_read.add_subparsers(dest="read_action", required=True)

p_rint = read_sub.add_parser("int", help="Read and decode an integer.")
p_rint.add_argument("addr", help="Address (hex 0x.., decimal, lifted or absolute).")
p_rint.add_argument("--size", type=lambda x: int(x, 0), default=None,
help="Byte width (default: pointer size).")
p_rint.add_argument("--signed", action="store_true", help="Decode as signed.")
p_rint.add_argument("--endian", choices=("little", "big"), default="little",
help="Byte order (default: little).")
_add_server_filter_args(p_rint)
_add_output_args(p_rint)
p_rint.set_defaults(func=cmd_read)

p_rstr = read_sub.add_parser("string", help="Read a NUL-terminated C string.")
p_rstr.add_argument("addr", help="Address of the string.")
p_rstr.add_argument("--max-len", dest="max_len", type=lambda x: int(x, 0), default=256,
help="Maximum bytes to read (default: 256).")
p_rstr.add_argument("--encoding", default="utf-8", help="Text encoding (default: utf-8).")
_add_server_filter_args(p_rstr)
_add_output_args(p_rstr)
p_rstr.set_defaults(func=cmd_read)

p_rstruct = read_sub.add_parser("struct", help="Read and decode a defined struct.")
p_rstruct.add_argument("addr", help="Address of the struct instance.")
p_rstruct.add_argument("name", help="Name of a defined struct (see `create-type`).")
p_rstruct.add_argument("--endian", choices=("little", "big"), default="little",
help="Byte order for member decoding (default: little).")
_add_server_filter_args(p_rstruct)
_add_output_args(p_rstruct)
p_rstruct.set_defaults(func=cmd_read)

# read_memory
p_rm = sub.add_parser(
"read_memory",
Expand Down
30 changes: 30 additions & 0 deletions declib/skills/decompiler/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ same binary.
| `sync <func> --from-id <src>` | Copy a function's work (names, return/arg types, stack-var names+types, referenced user types) from one running server into another for the same binary. | dest: `--id`/`--binary`/`--backend`; `--json` |
| `list_strings` | Strings the decompiler found (may be incomplete — see below). | `--filter`, `--min-length N`, same |
| `get_callers <target>` | Call-sites only — subset of `xref_to`. | same |
| `read int/string/struct <addr> [...]` | Typed reads: decode memory as an integer, C string, or defined struct. | `--size`, `--signed`, `--endian`, `--max-len`, `--encoding`, same + `--json` |
| `read_memory <addr> <size>` | Read raw bytes from the binary at `<addr>`. Default output is a hexdump. | `--format {hexdump,hex,raw}`, same + `--json` (base64-encoded bytes) |
| `install-skill` | Install this file for Claude Code or Codex. | `--agent`, `--dest`, `--force`, `--json` |

Expand Down Expand Up @@ -234,6 +235,35 @@ purely in-memory: `save` exits `2` (`not implemented`) and there is nothing to
reload. IDA reopens the saved `.i64` directly (no re-analysis), so a reload after
`save` is fast and lossless.

### `read` — typed reads (int / string / struct)

`read_memory` gives you raw bytes; `read` decodes them so you don't have to
byte-twiddle. All three subcommands work on every backend (decoding happens
client-side over `read_memory`).

```bash
decompiler read int 0x4040 # pointer-sized int (little-endian)
decompiler read int 0x4040 --size 4 --signed # 4-byte signed
decompiler read int 0x4040 --endian big
decompiler read string 0x8e0 # NUL-terminated C string
decompiler read string 0x8e0 --max-len 512 --encoding latin-1
decompiler create-type "struct Point { int x; int y; }"
decompiler read struct 0x4050 Point # decode each member
# struct Point @ 0x4050 (size 8):
# +0x0 x int = 5 (05000000)
# +0x4 y int = 10 (0a000000)
```

### Address semantics — absolute and lifted both work

Every address argument accepts **lifted** (relative to the image base, e.g.
`0x2320`), **absolute/loaded** (`0x402320`), or **decimal**. The CLI normalizes
absolute addresses to the backend's lifted form, so `read_memory 0x402320` and
`read_memory 0x2320` return the same byte — no more "Ghidra rejects the absolute
address" surprises. The heuristic: an address `>=` the image base is treated as
absolute and rebased; a smaller one is already lifted. JSON output always echoes
the resolved `addr`/`addr_hex` (lifted).

### `read_memory` — raw bytes at an address

`read_memory <addr> <size>` reads `<size>` bytes from the loaded binary's
Expand Down
28 changes: 28 additions & 0 deletions docs/decompiler_cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,34 @@ decompiler get_callers <target> [--id ID] [--binary PATH] [--backend BACKEND] [-
Unlike `xref_to`, this never returns globals or other data refs. Rows are
always of kind `Function`.

### `read` (typed) and address semantics

`read_memory` returns raw bytes; `read` decodes them. Decoding happens
client-side over `read_memory`, so all three subcommands work on every backend.

```bash
decompiler read int <addr> [--size N] [--signed] [--endian little|big] [--json]
decompiler read string <addr> [--max-len N] [--encoding ENC] [--json]
decompiler read struct <addr> <struct-name> [--endian little|big] [--json]
```

```bash
decompiler read int 0x4040 --size 4 --endian big # -> {"value": 2130640198, ...}
decompiler read string 0x8e0 # -> "Welcome to ..."
decompiler create-type "struct Point { int x; int y; }"
decompiler read struct 0x4050 Point
# struct Point @ 0x4050 (size 8):
# +0x0 x int = 5 (05000000)
# +0x4 y int = 10 (0a000000)
```

**Address forms.** Every address argument (here and in `read_memory`, `comment`,
`global`, `xref_*`, ...) accepts a **lifted** offset (`0x2320`), an **absolute**
loaded address (`0x402320`), or **decimal**. The CLI rebases absolute addresses
to the backend's lifted form, so both refer to the same byte instead of the
backend double-adding the image base. An address at or above the image base is
treated as absolute; a smaller one is already lifted.

### `install-skill`

Copy the bundled Agent Skill into a supported agent skill directory so Claude
Expand Down
64 changes: 64 additions & 0 deletions tests/test_decompiler_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,70 @@ def test_read_memory_invalid_address(self):
combined = result.stdout + result.stderr
self.assertNotIn("|.ELF", combined)

# -------------------------------------------------------------------
# typed reads + address semantics
# -------------------------------------------------------------------

def test_read_int_elf_magic(self):
"""`read int` decodes the ELF magic word at the image base (lifted 0x0)."""
self._load_fauxware_isolated()
# Big-endian 4 bytes at 0x0 == 0x7f454c46 ("\x7fELF").
result = _run_cli("read", "int", "0x0", "--size", "4", "--endian", "big", "--json")
payload = json.loads(result.stdout)
self.assertEqual(payload["value"], 0x7f454c46,
f"{self.backend}: read int gave {payload['value']:#x}")
# Little-endian reversal.
le = json.loads(_run_cli("read", "int", "0x0", "--size", "4", "--json").stdout)
self.assertEqual(le["value"], 0x464c457f)

def test_read_string(self):
self._load_fauxware_isolated()
strings = json.loads(_run_cli("list_strings", "--filter", "Welcome", "--json").stdout)
self.assertTrue(strings, f"{self.backend}: 'Welcome' string not surfaced")
addr = strings[0]["addr"]
result = _run_cli("read", "string", _format_hex(addr), "--json")
payload = json.loads(result.stdout)
self.assertTrue(payload["string"].startswith("Welcome"),
f"{self.backend}: read string gave {payload['string']!r}")

def test_read_struct(self):
"""Define a struct and decode the ELF header bytes through it."""
self._load_fauxware_isolated()
rc = _run_cli("create-type", "struct ElfMagic { int magic; short type; }",
"--json", check=False)
if rc.returncode != 0 or not json.loads(rc.stdout).get("success"):
self.skipTest(f"{self.backend}: could not define struct: {rc.stdout + rc.stderr}")
result = _run_cli("read", "struct", "0x0", "ElfMagic", "--json", check=False)
if result.returncode != 0:
self.skipTest(f"{self.backend}: read struct unsupported: {result.stdout + result.stderr}")
payload = json.loads(result.stdout)
members = {m["name"]: m for m in payload["members"]}
self.assertIn("magic", members)
# Little-endian int of "\x7fELF" == 0x464c457f.
self.assertEqual(members["magic"]["value"], 0x464c457f,
f"{self.backend}: struct member decode wrong: {members['magic']}")

def test_read_memory_absolute_and_lifted_agree(self):
"""Regression for double-base-add: absolute and lifted addrs read the same byte."""
import base64
self._load_fauxware_isolated()
client = self._direct_client()
try:
base = client.binary_base_addr
finally:
client.shutdown()
# Lifted 0x0 -> ELF magic.
lifted = json.loads(_run_cli("read_memory", "0x0", "4", "--json").stdout)
self.assertEqual(base64.b64decode(lifted["bytes_b64"]), b"\x7fELF")
if not base:
self.skipTest(f"{self.backend}: image base is 0; absolute == lifted")
# Absolute (base + 0) must resolve to the same bytes, not double-add base.
absolute = _run_cli("read_memory", _format_hex(base), "4", "--json", check=False)
self.assertEqual(absolute.returncode, 0,
f"{self.backend}: absolute read failed: {absolute.stdout + absolute.stderr}")
self.assertEqual(base64.b64decode(json.loads(absolute.stdout)["bytes_b64"]), b"\x7fELF",
f"{self.backend}: absolute address didn't normalize to lifted")

#: Subclasses set this to True if their backend actually persists files
#: (Ghidra project, IDA database, etc). For in-memory backends like angr
#: it stays False and we only assert "nothing wound up next to the binary".
Expand Down