From b71e1fea3eb75b115f593f839a66024161c8b450 Mon Sep 17 00:00:00 2001 From: mahaloz Date: Fri, 17 Jul 2026 02:55:18 +0000 Subject: [PATCH] Add byte patching to the CLI (patch set/get/list/delete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DecLib already had a Patch artifact; this exposes it via the CLI and fills backend gaps. - New `decompiler patch set|get|list|delete`. - IDA: set via patch_bytes (existing), enumerate via visit_patched_bytes (existing), plus new `_del_patch` that reverts to original bytes (get_original_byte). Full get/list/delete/revert support. - Ghidra: implemented `_set_patch` via Memory.setBytes — flips the block writable and clears the conflicting code unit first (setBytes refuses to overwrite bytes under a defined instruction), then re-disassembles. - Binary Ninja: implemented `_set_patch` via bv.write (was a warn-stub). - angr: no user-patch store (patch set reports rejected). - Base public get_patch/delete_patch (direct reads) + client proxies. - Tests: patch set reflects in read_memory + revert restores original bytes (IDA); get/list enumeration (IDA); Ghidra set verified; angr set rejected. SKILL.md / docs updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HfZHdprXg38re1XyKxzGg5 --- declib/api/decompiler_client.py | 8 +++ declib/api/decompiler_interface.py | 11 +++ declib/cli/decompiler_cli.py | 93 ++++++++++++++++++++++++++ declib/decompilers/binja/interface.py | 10 ++- declib/decompilers/ghidra/interface.py | 42 +++++++++++- declib/decompilers/ida/compat.py | 13 ++++ declib/decompilers/ida/interface.py | 6 ++ declib/skills/decompiler/SKILL.md | 17 +++++ docs/decompiler_cli.md | 21 ++++++ tests/test_decompiler_cli.py | 57 ++++++++++++++++ 10 files changed, 275 insertions(+), 3 deletions(-) diff --git a/declib/api/decompiler_client.py b/declib/api/decompiler_client.py index 756abdb..97aa203 100644 --- a/declib/api/decompiler_client.py +++ b/declib/api/decompiler_client.py @@ -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]}) diff --git a/declib/api/decompiler_interface.py b/declib/api/decompiler_interface.py index 694497e..a7e676d 100644 --- a/declib/api/decompiler_interface.py +++ b/declib/api/decompiler_interface.py @@ -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 diff --git a/declib/cli/decompiler_cli.py b/declib/cli/decompiler_cli.py index ac18357..b4a1d9d 100644 --- a/declib/cli/decompiler_cli.py +++ b/declib/cli/decompiler_cli.py @@ -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 @@ -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) # --------------------------------------------------------------------------- @@ -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", diff --git a/declib/decompilers/binja/interface.py b/declib/decompilers/binja/interface.py index 95d0df4..f0822cd 100644 --- a/declib/decompilers/binja/interface.py +++ b/declib/decompilers/binja/interface.py @@ -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") diff --git a/declib/decompilers/ghidra/interface.py b/declib/decompilers/ghidra/interface.py index e8b5a37..b22453e 100644 --- a/declib/decompilers/ghidra/interface.py +++ b/declib/decompilers/ghidra/interface.py @@ -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 @@ -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() diff --git a/declib/decompilers/ida/compat.py b/declib/decompilers/ida/compat.py index f34c73a..53120d1 100644 --- a/declib/decompilers/ida/compat.py +++ b/declib/decompilers/ida/compat.py @@ -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).""" diff --git a/declib/decompilers/ida/interface.py b/declib/decompilers/ida/interface.py index f092b9b..42bf384 100755 --- a/declib/decompilers/ida/interface.py +++ b/declib/decompilers/ida/interface.py @@ -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. diff --git a/declib/skills/decompiler/SKILL.md b/declib/skills/decompiler/SKILL.md index 4c93028..fbe1f79 100644 --- a/declib/skills/decompiler/SKILL.md +++ b/declib/skills/decompiler/SKILL.md @@ -156,6 +156,9 @@ same binary. | `imports` | List imported symbols (external functions/data). | `--filter REGEX`, same | | `define function/code/data ` | Repair analysis: create a function, disassemble bytes, or define data. | `--type`, `--size` (data), same | | `undefine ` | Clear code/data at an address (removes a function if one starts there). | `--size`, same | +| `patch set ` | Patch bytes at an address. | same | +| `patch get/delete ` | Show or revert the patch at an address (IDA). | same | +| `patch list` | List all byte patches (IDA). | same | | `read int/string/struct [...]` | Typed reads: decode memory as an integer, C string, or defined struct. | `--size`, `--signed`, `--endian`, `--max-len`, `--encoding`, same + `--json` | | `read_memory ` | Read raw bytes from the binary at ``. 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` | @@ -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 diff --git a/docs/decompiler_cli.md b/docs/decompiler_cli.md index 800ab05..2cb21e7 100644 --- a/docs/decompiler_cli.md +++ b/docs/decompiler_cli.md @@ -439,6 +439,27 @@ decompiler get_callers [--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 [--id ID] [--json] +decompiler patch get [--id ID] [--json] +decompiler patch delete [--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 diff --git a/tests/test_decompiler_cli.py b/tests/test_decompiler_cli.py index ce3b5c9..708bb48 100644 --- a/tests/test_decompiler_cli.py +++ b/tests/test_decompiler_cli.py @@ -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) # ------------------------------------------------------------------- @@ -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. @@ -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 # ---------------------------------------------------------------------------