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
8 changes: 8 additions & 0 deletions declib/api/decompiler_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,14 @@ def get_global_var(self, addr: int) -> Optional[GlobalVariable]:
"""Return the GlobalVariable at ``addr`` (lifted), or None. Direct read, no cache."""
return self._send_request({"type": "method_call", "method_name": "get_global_var", "args": [addr]})

def get_patch(self, addr: int) -> Optional[Patch]:
"""Return the Patch at ``addr`` (lifted), or None. Direct read, no cache."""
return self._send_request({"type": "method_call", "method_name": "get_patch", "args": [addr]})

def delete_patch(self, addr: int) -> bool:
"""Revert the patch at ``addr`` (lifted)."""
return self._send_request({"type": "method_call", "method_name": "delete_patch", "args": [addr]})

def get_function_signature(self, func_addr: int) -> Optional[str]:
"""Return the C prototype string for the function at ``func_addr`` (lifted)."""
return self._send_request({"type": "method_call", "method_name": "get_function_signature", "args": [func_addr]})
Expand Down
11 changes: 11 additions & 0 deletions declib/api/decompiler_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,17 @@ def get_global_var(self, addr) -> Optional[GlobalVariable]:
gvar = self._get_global_var(lowered)
return self.art_lifter.lift(gvar) if gvar is not None else None

def get_patch(self, addr) -> Optional[Patch]:
"""Return the lifted Patch at ``addr`` (lifted), or None (direct read)."""
lowered = self.art_lifter.lower_addr(addr)
patch = self._get_patch(lowered)
return self.art_lifter.lift(patch) if patch is not None else None

def delete_patch(self, addr) -> bool:
"""Revert the patch at ``addr`` (lifted). True if bytes were reverted."""
lowered = self.art_lifter.lower_addr(addr)
return self._del_patch(lowered)

def get_function_signature(self, func_addr) -> Optional[str]:
"""Return the C prototype string for the function at ``func_addr`` (lifted)."""
from declib.api.prototype import format_prototype
Expand Down
93 changes: 93 additions & 0 deletions declib/cli/decompiler_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- imports list imported symbols
- define define a function/code/data at an address
- undefine clear code/data at an address
- patch set/get/list/delete byte patches
- global list/get/rename/retype global variables
- signature get/set a function's full signature (prototype)
- rename rename a function or local variable
Expand Down Expand Up @@ -1318,6 +1319,70 @@ def cmd_read_memory(args) -> int:
return 0


# ---------------------------------------------------------------------------
# patch (set / get / list / delete)
# ---------------------------------------------------------------------------

def cmd_patch(args) -> int:
"""Apply, inspect, list, or revert byte patches."""
from declib.artifacts import Patch

action = args.patch_action
with _with_client(args) as client:
if action == "list":
entries: List[Dict] = []
for addr, p in sorted(client.patches.items(), key=lambda kv: kv[0]):
data = getattr(p, "bytes", None) or b""
entries.append({"addr": addr, "size": len(data), "bytes": data.hex()})
if args.json:
_emit_list(args, entries)
else:
if not entries:
print("No patches.")
return EXIT_OK
for e in entries:
print(f"{_format_addr_hex(e['addr'])}\t{e['size']}\t{e['bytes']}")
return EXIT_OK

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)

if action == "get":
p = client.get_patch(lifted)
if p is None:
raise SystemExit(f"No patch at {_format_addr_hex(lifted)}")
data = getattr(p, "bytes", None) or b""
_emit(args, {"addr": lifted, "size": len(data), "bytes": data.hex()})
return EXIT_OK

if action == "delete":
ok = bool(client.delete_patch(lifted))
if not ok:
raise SystemExit(f"Nothing to revert at {_format_addr_hex(lifted)}.")
_emit(args, {"addr": lifted, "reverted": ok})
return EXIT_OK

# set
cleaned = args.bytes.replace(" ", "").replace("0x", "")
try:
data = bytes.fromhex(cleaned)
except ValueError:
raise SystemExit(f"Invalid hex bytes: {args.bytes!r}")
if not data:
raise SystemExit("Empty patch.")
patch = Patch(addr=lifted, bytes_=data)
ok = bool(client.set_artifact(patch))
if not ok:
raise SystemExit(
f"Backend rejected the patch at {_format_addr_hex(lifted)} "
"(the backend may not support patching, or the region isn't writable)."
)
_emit(args, {"addr": lifted, "size": len(data), "bytes": data.hex(), "success": ok})
return EXIT_OK


# ---------------------------------------------------------------------------
# define / undefine (code & data repair)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2048,6 +2113,34 @@ def _add_comment_addr_action(name, help_text, with_text=False):
_add_output_args(p_gc)
p_gc.set_defaults(func=cmd_get_callers)

# patch
p_patch = sub.add_parser("patch", help="Apply, inspect, list, or revert byte patches.")
patch_sub = p_patch.add_subparsers(dest="patch_action", required=True)

p_pset = patch_sub.add_parser("set", help="Patch bytes at an address (hex).")
p_pset.add_argument("addr", help="Address to patch (hex 0x.., decimal, lifted or absolute).")
p_pset.add_argument("bytes", help='Hex bytes, e.g. "90909090" or "90 90 90 90".')
_add_server_filter_args(p_pset)
_add_output_args(p_pset)
p_pset.set_defaults(func=cmd_patch)

p_pget = patch_sub.add_parser("get", help="Show the patch at an address.")
p_pget.add_argument("addr", help="Address of the patch.")
_add_server_filter_args(p_pget)
_add_output_args(p_pget)
p_pget.set_defaults(func=cmd_patch)

p_pdel = patch_sub.add_parser("delete", help="Revert the patch at an address.")
p_pdel.add_argument("addr", help="Address of the patch to revert.")
_add_server_filter_args(p_pdel)
_add_output_args(p_pdel)
p_pdel.set_defaults(func=cmd_patch)

p_plist = patch_sub.add_parser("list", help="List all byte patches in the binary.")
_add_server_filter_args(p_plist)
_add_output_args(p_plist)
p_plist.set_defaults(func=cmd_patch)

# define
p_def = sub.add_parser(
"define",
Expand Down
10 changes: 8 additions & 2 deletions declib/decompilers/binja/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -693,8 +693,14 @@ def _typedefs(self) -> Dict[str, Typedef]:

# patches
def _set_patch(self, patch: Patch, **kwargs) -> bool:
l.warning("Patch setting is unimplemented in Binja")
return False
if not patch.bytes:
return False
try:
written = self.bv.write(patch.addr, patch.bytes)
except Exception as e:
l.warning("Binary Ninja patch write failed: %s", e)
return False
return written == len(patch.bytes)

def _get_patch(self, addr) -> Optional[Patch]:
l.warning("Patch getting is unimplemented in Binja")
Expand Down
42 changes: 41 additions & 1 deletion declib/decompilers/ghidra/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from declib.api.decompiler_interface import requires_decompilation
from declib.artifacts import (
Function, FunctionHeader, StackVariable, Comment, FunctionArgument, GlobalVariable, Struct, StructMember, Enum,
Decompilation, Context, Artifact, Typedef
Decompilation, Context, Artifact, Typedef, Patch
)

from .artifact_lifter import GhidraArtifactLifter
Expand Down Expand Up @@ -1076,6 +1076,46 @@ def _get_typedef(self, name) -> Optional[Typedef]:
norm_name, scope = self._gscoped_type_to_bs(g_typedef.getPathName())
return Typedef(name=norm_name, type_=str(base_type.getPathName()), scope=scope)

# patches
@ghidra_transaction
def _set_patch(self, patch: Patch, **kwargs) -> bool:
import jpype
if not patch.bytes:
return False
memory = self.currentProgram.getMemory()
gaddr = self._to_gaddr(patch.addr)
# Java bytes are signed.
data = jpype.JArray(jpype.JByte)([b - 256 if b >= 128 else b for b in patch.bytes])
# Code blocks (.text) are mapped read-only, and setBytes honors the
# block's write flag. Flip it on for the write, then restore it so the
# program's permission metadata is unchanged.
block = memory.getBlock(gaddr)
toggled = False
try:
if block is not None and not block.isWrite():
block.setWrite(True)
toggled = True
# setBytes refuses to overwrite bytes under a defined instruction/
# data unit ("Memory change conflicts with instruction"), so clear
# the span first, then re-disassemble so patched code shows as code.
end = gaddr.add(len(patch.bytes) - 1)
self.flat_api.clearListing(gaddr, end)
memory.setBytes(gaddr, data)
try:
self.flat_api.disassemble(gaddr)
except Exception:
pass
return True
except Exception as exc:
self.warning(f"Ghidra setBytes patch failed: {exc}")
return False
finally:
if toggled and block is not None:
try:
block.setWrite(False)
except Exception:
pass

def _typedefs(self) -> Dict[str, Typedef]:
typedefs = {}
typedefs_by_name = self.__gtypedefs()
Expand Down
13 changes: 13 additions & 0 deletions declib/decompilers/ida/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -1976,6 +1976,19 @@ def wait_for_idc_initialization():
idc.auto_wait()


@execute_write
def revert_patch(addr, size):
"""Revert ``size`` patched bytes at ``addr`` back to their original values."""
reverted = False
for i in range(max(1, size)):
ea = addr + i
orig = ida_bytes.get_original_byte(ea)
if ida_bytes.get_byte(ea) != orig:
ida_bytes.patch_byte(ea, orig)
reverted = True
return reverted


@execute_write
def define_function(addr):
"""Create a function at ``addr`` (repairs missed auto-analysis)."""
Expand Down
6 changes: 6 additions & 0 deletions declib/decompilers/ida/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,12 @@ def _get_patch(self, addr) -> Optional[Patch]:
patches = self._collect_continuous_patches(min_addr=addr-1, max_addr=addr+self._max_patch_size, stop_after_first=True)
return patches.get(addr, None)

def _del_patch(self, addr) -> bool:
patch = self._get_patch(addr)
if patch is None or not patch.bytes:
return False
return compat.revert_patch(addr, len(patch.bytes))

def _patches(self) -> Dict[int, Patch]:
"""
Returns a dict of declib.Patch that contain the addr of each Patch and the bytes.
Expand Down
17 changes: 17 additions & 0 deletions declib/skills/decompiler/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ same binary.
| `imports` | List imported symbols (external functions/data). | `--filter REGEX`, same |
| `define function/code/data <addr>` | Repair analysis: create a function, disassemble bytes, or define data. | `--type`, `--size` (data), same |
| `undefine <addr>` | Clear code/data at an address (removes a function if one starts there). | `--size`, same |
| `patch set <addr> <hex>` | Patch bytes at an address. | same |
| `patch get/delete <addr>` | Show or revert the patch at an address (IDA). | same |
| `patch list` | List all byte patches (IDA). | 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 @@ -241,6 +244,20 @@ 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.

### `patch` — modify bytes

```bash
decompiler patch set 0x401200 "9090" # NOP out two bytes
decompiler patch get 0x401200 # show the patch (IDA)
decompiler patch list # every patch (IDA)
decompiler patch delete 0x401200 # revert to original bytes (IDA)
```

`patch set` works on IDA, Ghidra, and Binary Ninja; `angr` has no user-patch
store (exit non-zero). Patch **tracking** — `get`/`list`/`delete` (revert) —
is IDA-only today; on other backends those return no results. Pair with `save`
to persist patches.

### `define` / `undefine` — repair analysis

When auto-analysis misses a function or mislabels code as data (common on
Expand Down
21 changes: 21 additions & 0 deletions docs/decompiler_cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,27 @@ 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`.

### `patch`

Apply, inspect, list, or revert byte patches.

```bash
decompiler patch set <addr> <hex> [--id ID] [--json]
decompiler patch get <addr> [--id ID] [--json]
decompiler patch delete <addr> [--id ID] [--json]
decompiler patch list [--id ID] [--json]
```

```bash
decompiler patch set 0x401200 "9090" # NOP two bytes
decompiler patch list
decompiler patch delete 0x401200 # revert (IDA)
```

`patch set` is implemented on IDA, Ghidra, and Binary Ninja; `angr` has no
user-patch store. Patch tracking (`get`/`list`/`delete`/revert) is IDA-only for
now — other backends apply the patch but don't record it. Use `save` to persist.

### `define` and `undefine`

Repair analysis: create functions, disassemble code, define data, or clear a
Expand Down
57 changes: 57 additions & 0 deletions tests/test_decompiler_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,60 @@ def test_read_memory_invalid_address(self):
combined = result.stdout + result.stderr
self.assertNotIn("|.ELF", combined)

# -------------------------------------------------------------------
# patching
# -------------------------------------------------------------------

#: Backends that can apply byte patches.
_patches_bytes: bool = True
#: Backends that track patches (get/list/revert). IDA is the reference.
_tracks_patches: bool = False

def test_patch_set_reflects_in_memory(self):
import base64
self._load_fauxware_isolated()
addr = self._main_start_addr()
orig = base64.b64decode(json.loads(
_run_cli("read_memory", _format_hex(addr), "4", "--json").stdout)["bytes_b64"])

r = _run_cli("patch", "set", _format_hex(addr), "90909090", "--json", check=False)
if not self._patches_bytes:
self.assertNotEqual(r.returncode, 0,
f"{self.backend}: patch set unexpectedly succeeded")
return
if r.returncode != 0:
self.skipTest(f"{self.backend}: patch set unsupported: {r.stdout + r.stderr}")
self.assertTrue(json.loads(r.stdout)["success"])

now = base64.b64decode(json.loads(
_run_cli("read_memory", _format_hex(addr), "4", "--json").stdout)["bytes_b64"])
self.assertEqual(now, b"\x90\x90\x90\x90",
f"{self.backend}: patched bytes not reflected in memory")

# Revert where supported and confirm the original bytes come back.
d = _run_cli("patch", "delete", _format_hex(addr), "--json", check=False)
if self._tracks_patches:
self.assertEqual(d.returncode, 0, f"{self.backend}: patch delete failed")
reverted = base64.b64decode(json.loads(
_run_cli("read_memory", _format_hex(addr), "4", "--json").stdout)["bytes_b64"])
self.assertEqual(reverted, orig,
f"{self.backend}: patch delete did not restore original bytes")

def test_patch_get_and_list(self):
if not self._tracks_patches:
self.skipTest(f"{self.backend} does not track patches")
self._load_fauxware_isolated()
addr = self._main_start_addr()
setr = _run_cli("patch", "set", _format_hex(addr), "cccc", "--json", check=False)
if setr.returncode != 0:
self.skipTest(f"{self.backend}: patch set unsupported")
got = json.loads(_run_cli("patch", "get", _format_hex(addr), "--json").stdout)
self.assertTrue(got["bytes"].startswith("cccc"),
f"{self.backend}: patch get wrong bytes: {got}")
listing = json.loads(_run_cli("patch", "list", "--json").stdout)
self.assertTrue(any("cccc" in e["bytes"] for e in listing),
f"{self.backend}: patch not enumerated by list")

# -------------------------------------------------------------------
# define / undefine (code & data repair)
# -------------------------------------------------------------------
Expand Down Expand Up @@ -894,6 +948,8 @@ class TestDecompilerCLIAngr(_CLIBackendTestBase):
_searches_bytes = False
# angr's CFG-based model has no define/undefine primitives.
_repairs_analysis = False
# angr has no user-patch store.
_patches_bytes = False

# angr-specific sanity checks that don't map cleanly to the other
# backends live here.
Expand Down Expand Up @@ -1055,6 +1111,7 @@ class TestDecompilerCLIIDA(_CLIBackendTestBase):
"""
backend = "ida"
_persists_project_files = True # .id0/.id1/.id2/.nam/.til
_tracks_patches = True # IDA records patched bytes and can revert them


# ---------------------------------------------------------------------------
Expand Down