diff --git a/.github/workflows/package-plugin.yaml b/.github/workflows/package-plugin.yaml new file mode 100644 index 0000000..07bfd96 --- /dev/null +++ b/.github/workflows/package-plugin.yaml @@ -0,0 +1,36 @@ +name: Package IDA Plugin 📦 + +on: push + +jobs: + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Download workflow artifact + uses: dawidd6/action-download-artifact@v2.16.0 + with: + + # the target repo for external artifacts (built libs) + repo: gaasedelen/keystone + branch: master + + # token to fetch artifacts from the repo + github_token: ${{secrets.KEYSTONE_PATCHING_TOKEN}} + + # which workflow to search for artifacts + workflow: python-publish.yml + workflow_conclusion: success + + - name: Package distributions + shell: bash + run: | + mkdir dist && cd dist + mkdir win32 && cp -r ../plugins/* ./win32/ && cp -r ../artifact/keystone_win32/* ./win32/patching/keystone && cd ./win32 && zip -r ../patching_win32.zip ./* && cd .. + mkdir linux && cp -r ../plugins/* ./linux/ && cp -r ../artifact/keystone_linux/* ./linux/patching/keystone && cd ./linux && zip -r ../patching_linux.zip ./* && cd .. + mkdir darwin && cp -r ../plugins/* ./darwin/ && cp -r ../artifact/keystone_darwin/* ./darwin/patching/keystone && cd ./darwin && zip -r ../patching_macos.zip ./* && cd .. + + - uses: actions/upload-artifact@v2 + with: + path: ${{ github.workspace }}/dist/*.zip \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef006e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,142 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# ida dev +*.id0 +*.id1 +*.id2 +*.nam +*.til +*.idb +*.i64 + +# ida test suite +samples/* +plugins/patching/keystone/* \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1ff3f89 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Markus Gaasedelen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d65e0c --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# Patching - Interactive Binary Patching for IDA Pro + +

Patching Plugin

+ +## Overview + +Patching assembly code to change the behavior of an existing program is not uncommon in malware analysis, software reverse engineering, and broader domains of security research. This project extends the popular [IDA Pro](https://www.hex-rays.com/products/ida/) disassembler to create a more robust interactive binary patching workflow designed for rapid iteration. + +This project is currently powered by a minor [fork](https://github.com/gaasedelen/keystone) of the ubiquitous [Keystone Engine](https://github.com/keystone-engine/keystone), supporting x86/x64 and Arm/Arm64 patching with plans to enable the remaining Keystone architectures in a future release. + +Special thanks to [Hex-Rays](https://hex-rays.com/) for supporting the development of this plugin. + +## Releases + +* v0.1 -- Initial release + +# Installation + +This plugin requires IDA 7.6 and Python 3. It supports Windows, Linux, and macOS. + +## Easy Install + +Run the following line in the IDA console to automatically install the plugin: + +### Windows / Linux + +```python +import urllib.request as r; exec(r.urlopen('https://github.com/gaasedelen/patching/raw/main/install.py').read()) +``` + +### macOS + +```python +import urllib.request as r; exec(r.urlopen('https://github.com/gaasedelen/patching/raw/main/install.py', cafile='/etc/ssl/cert.pem').read()) +``` + +## Manual Install + +Alternatively, the plugin can be manually installed by downloading the distributable plugin package for your respective platform from the [releases](https://github.com/gaasedelen/patching/releases) page and unzipping it to your plugins folder. + +It is __*strongly*__ recommended you install this plugin into IDA's user plugin directory: + +```python +import ida_diskio, os; print(os.path.join(ida_diskio.get_user_idadir(), "plugins")) +``` + +# Usage + +The patching plugin will automatically load for supported architectures (x86/x64/Arm/Arm64) and inject relevant patching actions into the right click context menu of the IDA disassembly views: + +

Patching plugin right click context menu

+ +A complete listing of the contextual patching actions are described in the following sections. + +## Assemble + +The main patching dialog can be launched via the Assemble action in the right click context menu. It simulates a basic IDA disassembly view that can be used to edit one or several instructions in rapid succession. + +

The interactive patching dialog

+ +The assembly line is an editable field that can be used to modify instructions in real-time. Pressing enter will commit (patch) the entered instruction into the database. + +Your current location (a.k.a your cursor) will always be highlighted in green. Instructions that will be clobbered as a result of your patch / edit will be highlighted in red prior to committing the patch. + +

Additional instructions that will be clobbered by a patch show up as red

+ +Finally, the `UP` and `DOWN` arrow keys can be used while still focused on the editable assembly text field to quickly move the cursor up and down the disassembly view without using the mouse. + +## NOP + +The most common patching action is to NOP out one or more instructions. For this reason, the NOP action will always be visible in the right click menu for quick access. + +

Right click NOP instruction

+ +Individual instructions can be NOP'ed, as well as a selected range of instructions. + +## Force Conditional Jump + +Forcing a conditional jump to always execute a 'good' path is another common patching action. The plugin will only show this action when right clicking a conditional jump instruction. + +

Forcing a conditional jump

+ +If you *never* want a conditional jump to be taken, you can just NOP it instead! + +## Save & Quick Apply + +Patches can be saved (applied) to a selected executable via the patching submenu at any time. The quick-apply action makes it even faster to save subsequent patches using the same settings. + +

Applying patches to the original executable

+ +The plugin will also make an active effort to retain a backup (`.bak`) of the original executable which it uses to 'cleanly' apply the current set of database patches during each save. + +## Revert Patch + +Finally, if you are ever unhappy with a patch you can simply right click patched (yellow) blocks of instructions to revert them to their original value. + +

Reverting patches

+ +While it is 'easy' to revert bytes back to their original value, it can be 'hard' to restore analysis to its previous state. Reverting a patch may *occasionally* require additional human fixups. + +# Known Bugs + +* Further improve ARM / ARM64 / THUMB correctness +* Define 'better' behavior for cpp::like::symbols(...) / IDBs (very sketchy right now) +* Adding / Updating / Modifying / Showing / Warning about Relocation Entries?? +* Handle renamed registers (like against dwarf annotated idb)? +* A number of new instructions (circa 2017 and later) are not supported by Keystone +* A few problematic instruction encodings by Keystone + +# Future Work + +Time and motivation permitting, future work may include: + +* Enable the remaining major architectures supported by Keystone: + * PPC32 / PPC64 / MIPS32 / MIPS64 / SPARC / SystemZ +* Multi instruction assembly (eg. `xor eax, eax; ret;`) +* Multi line assembly (eg. shellcode / asm labels) +* Interactive byte / data / string editing +* Symbol hinting / auto-complete / fuzzy-matching +* Syntax highlighting the editable assembly line +* Better hinting of errors, syntax issues, etc +* NOP / Force Jump from Hex-Rays view (sounds easy, but probably pretty hard!) +* radio button toggle between 'pretty print' mode vs 'raw' mode? or display both? + ``` + Pretty: mov [rsp+48h+dwCreationDisposition], 3 + Raw: mov [rsp+20h], 3 + ``` + +I welcome external contributions, issues, and feature requests. Please make any pull requests to the `develop` branch of this repository if you would like them to be considered for a future release. + +# Authors + +* Markus Gaasedelen ([@gaasedelen](https://twitter.com/gaasedelen)) diff --git a/install.py b/install.py new file mode 100644 index 0000000..3c1861c --- /dev/null +++ b/install.py @@ -0,0 +1,249 @@ +#------------------------------------------------------------------------------ +# Script Preflight +#------------------------------------------------------------------------------ + +# this plugin requires Python 3 +try: + import os + import sys + import glob + import json + import shutil + import zipfile + import urllib.request + from pathlib import Path + SUPPORTED_PYTHON = sys.version_info[0] == 3 +except: + SUPPORTED_PYTHON = False + +# this plugin requires IDA 7.6 or newer +try: + import ida_pro + import ida_diskio + import ida_loader + IDA_GLOBAL_SCOPE = sys.modules['__main__'] + SUPPORTED_IDA = ida_pro.IDA_SDK_VERSION >= 760 +except: + SUPPORTED_IDA = False + +# is this deemed to be a compatible environment for the plugin to load? +SUPPORTED_ENVIRONMENT = bool(SUPPORTED_IDA and SUPPORTED_PYTHON) + +#------------------------------------------------------------------------------ +# IDA Plugin Installer +#------------------------------------------------------------------------------ + +PLUGIN_NAME = 'Patching' +PLUGIN_URL = 'https://api.github.com/repos/gaasedelen/patching/releases/latest' + +def install_plugin(): + """ + Auto-install plugin (or update it). + """ + print("[*] Starting auto installer for '%s' plugin..." % PLUGIN_NAME) + + # ensure the user plugin directory exists + plugins_directory = os.path.join(ida_diskio.get_user_idadir(), 'plugins') + Path(plugins_directory).mkdir(parents=True, exist_ok=True) + + # special handling to rename 'darwin' to macos (a bit more friendly) + platform_name = sys.platform + if platform_name == 'darwin': + platform_name = 'macos' + + # compute the full filename of the plugin package to download from git + package_name = 'patching_%s.zip' % platform_name + + # fetch the plugin download info from the latest github releases + print("[*] Fetching info from GitHub...") + try: + release_json = urllib.request.urlopen(PLUGIN_URL).read() + release_info = json.loads(release_json) + release_tag = release_info['tag_name'] + except: + print("[-] Failed to fetch info from GitHub") + return False + + # locate the git asset info that matches our desired plugin package + for asset in release_info['assets']: + if asset['name'] == package_name: + break + else: + print("[-] Failed to locate asset '%s' in latest GitHub release" % package_name) + return False + + print("[*] Downloading %s..." % package_name) + + try: + package_url = asset['browser_download_url'] + package_data = urllib.request.urlopen(package_url).read() + package_path = os.path.join(plugins_directory, package_name) + except Exception as e: + print("[-] Failed to download %s\nError: %s" % (package_url, e)) + return False + + print("[*] Saving %s to disk..." % package_name) + try: + with open(package_path, 'wb') as f: + f.write(package_data) + except: + print("[-] Failed to write to %s" % package_path) + return False + + patching_directory = os.path.join(plugins_directory, 'patching') + keystone_directory = os.path.join(patching_directory, 'keystone') + + # + # if the plugin is already installed into this environment, a few more + # steps are required to ensure we can replace the existing version + # + + if os.path.exists(patching_directory): + + # + # contrary to what this sort of looks like, load_and_run_plugin() + # will execute and UNLOAD our plugin (if it is in-use) because + # our plugin has been marked with the PLUGIN_UNL flag + # + # NOTE: this is basically just us asking IDA nicely to unload our + # plugin in a best effort to keep things clean + # + + if ida_loader.find_plugin(PLUGIN_NAME, False): + print("[*] Unloading plugin core...") + ida_loader.load_and_run_plugin(PLUGIN_NAME, 0) + + # + # pay special attention when trying to remove Keystone. this is the + # most likely point in failure for the entire plugin update/install + # + # even if the plugin is not in use, the Keystone DLL / lib will be + # loaded into memory by nature of Python imports. we are going to + # try and AGGRESSIVELY unload it such that we can ovewrite it + # + # because this is pretty dangerous, we set this flag to ensure the + # patching plugin is completeley neutured and cannot be used in any + # form until IDA is restarted + # + + IDA_GLOBAL_SCOPE.RESTART_REQUIRED = True + + print("[*] Removing existing plugin...") + if not remove_keystone(keystone_directory): + print("[-] Could not remove Keystone (file locked?)") + print("[!] Please ensure no other instance of IDA are running and try again...") + return False + + # remove the rest of the plugin only IF removing Keystone succedded + shutil.rmtree(patching_directory) + + # + # now we can resume with the actual plugin update / installation + # + + print("[*] Unzipping %s..." % package_name) + try: + with zipfile.ZipFile(package_path, "r") as zip_ref: + zip_ref.extractall(plugins_directory) + except: + print("[-] Failed to unzip %s to %s" % (package_name, plugins_directory)) + return False + + print("[+] %s %s installed successfully!" % (PLUGIN_NAME, release_tag)) + + # try and remove the downloaded zip (cleanup) + try: + os.remove(package_path) + except: + pass + + # do not attempt to load the newly installed plugin if we just updated + if getattr(IDA_GLOBAL_SCOPE, 'RESTART_REQUIRED', False): + print("[!] Restart IDA to use the updated plugin") + return True + + # load the plugin if this was a fresh install + plugin_path = os.path.join(plugins_directory, 'patching.py') + ida_loader.load_plugin(plugin_path) + return True + +def remove_keystone(keystone_directory): + """ + Delete the Keystone directory at the given path and return True on success. + """ + if sys.platform == 'win32': + lib_paths = [os.path.join(keystone_directory, 'keystone.dll')] + else: + lib_paths = glob.glob(os.path.join(keystone_directory, 'libkeystone*')) + + # + # it is critical we try and delete the Keystone library first as it can + # be locked by IDA / Python. if we cannot delete the Keystone library + # on-disk, then there is no point in proceeding with the update. + # + # in a rather aggressive approach to force the Keystone library to unlock, + # we forcefully unload the backing library from python. this is obviously + # dangerous, but the plugin should be completely deactivated by this point + # + + try: + + # + # attempt to get the handle of the loaded Keystone library and + # forcefully unload it + # + + import _ctypes + + keystone = sys.modules['patching.keystone'] + lib_file = keystone.keystone._ks._name + _ctypes.FreeLibrary(keystone.keystone._ks._handle) + + # + # failing to delete the library from disk here means that another + # instance of IDA is is probably still running, keeping it locked + # + + os.remove(lib_file) + + except: + pass + + # + # for good measure, go over all the expected Keystone library files on + # disk and attempt to remove them + # + + lib_still_exists = [] + for lib_file in lib_paths: + try: + os.remove(lib_file) + except: + pass + lib_still_exists.append(os.path.exists(lib_file)) + + # if the library still exist after all this, the update will be canceled + if any(lib_still_exists): + return False + + # + # deleting the library appears to have been successful, now delete the + # rest of the Keystone directory. + # + + try: + shutil.rmtree(keystone_directory) + except: + pass + + # return True if Keystone was successfully deleted + return not(os.path.exists(keystone_directory)) + +#------------------------------------------------------------------------------ +# IDA Plugin Installer +#------------------------------------------------------------------------------ + +if SUPPORTED_ENVIRONMENT: + install_plugin() +else: + print("[-] Plugin is not compatible with this IDA/Python version") \ No newline at end of file diff --git a/plugins/patching.py b/plugins/patching.py new file mode 100644 index 0000000..abbe262 --- /dev/null +++ b/plugins/patching.py @@ -0,0 +1,119 @@ +#------------------------------------------------------------------------------ +# Plugin Preflight +#------------------------------------------------------------------------------ +# +# the purpose of this 'preflight' is to test if the plugin is compatible +# with the environment it is being loaded in. specifically, these preflight +# checks are designed to be compatible with IDA 7.0+ and Python 2/3 +# +# if the environment does not meet the specifications required by the +# plugin, this file will gracefully decline to load the plugin without +# throwing noisy errors (besides a simple print to the IDA console) +# +# this makes it easy to install the plugin on machines with numerous +# versions of IDA / Python / virtualenvs which employ a shared plugin +# directory such as the 'preferred' IDAUSR plugin directory... +# + +import sys + +# this plugin requires Python 3 +SUPPORTED_PYTHON = sys.version_info[0] == 3 + +# this plugin requires IDA 7.6 or newer +try: + import ida_pro + import ida_idaapi + IDA_GLOBAL_SCOPE = sys.modules['__main__'] + SUPPORTED_IDA = ida_pro.IDA_SDK_VERSION >= 760 +except: + SUPPORTED_IDA = False + +# is this deemed to be a compatible environment for the plugin to load? +SUPPORTED_ENVIRONMENT = bool(SUPPORTED_IDA and SUPPORTED_PYTHON) +if not SUPPORTED_ENVIRONMENT: + print("Patching plugin is not compatible with this IDA/Python version") + +#------------------------------------------------------------------------------ +# IDA Plugin Stub +#------------------------------------------------------------------------------ + +if SUPPORTED_ENVIRONMENT: + import patching + from patching.util.python import reload_package + +def PLUGIN_ENTRY(): + """ + Required plugin entry point for IDAPython plugins. + """ + return PatchingPlugin() + +class PatchingPlugin(ida_idaapi.plugin_t): + """ + The IDA Patching plugin stub. + """ + + # + # Plugin flags: + # - PLUGIN_PROC: Load / unload this plugin when an IDB opens / closes + # - PLUGIN_HIDE: Hide this plugin from the IDA plugin menu + # - PLUGIN_UNL: Unload the plugin after calling run() + # + + flags = ida_idaapi.PLUGIN_PROC | ida_idaapi.PLUGIN_HIDE | ida_idaapi.PLUGIN_UNL + comment = "A plugin to enable binary patching in IDA" + help = "" + wanted_name = "Patching" + wanted_hotkey = "" + + def __init__(self): + self.__updated = getattr(IDA_GLOBAL_SCOPE, 'RESTART_REQUIRED', False) + + #-------------------------------------------------------------------------- + # IDA Plugin Overloads + #-------------------------------------------------------------------------- + + def init(self): + """ + This is called by IDA when it is loading the plugin. + """ + if not SUPPORTED_ENVIRONMENT or self.__updated: + return ida_idaapi.PLUGIN_SKIP + + # load the plugin core + self.core = patching.PatchingCore(defer_load=True) + + # inject a reference to the plugin context into the IDA console scope + IDA_GLOBAL_SCOPE.patching = self + + # mark the plugin as loaded + return ida_idaapi.PLUGIN_KEEP + + def run(self, arg): + """ + This is called by IDA when this file is loaded as a script. + """ + pass + + def term(self): + """ + This is called by IDA when it is unloading the plugin. + """ + try: + self.core.unload() + except Exception as e: + pass + self.core = None + + #-------------------------------------------------------------------------- + # Development Helpers + #-------------------------------------------------------------------------- + + def reload(self): + """ + Hot-reload the plugin. + """ + if self.core: + self.core.unload() + reload_package(patching) + self.core = patching.PatchingCore() diff --git a/plugins/patching/__init__.py b/plugins/patching/__init__.py new file mode 100644 index 0000000..36ed346 --- /dev/null +++ b/plugins/patching/__init__.py @@ -0,0 +1 @@ +from patching.core import PatchingCore \ No newline at end of file diff --git a/plugins/patching/actions.py b/plugins/patching/actions.py new file mode 100644 index 0000000..a0e5018 --- /dev/null +++ b/plugins/patching/actions.py @@ -0,0 +1,211 @@ +import ida_idaapi +import ida_kernwin + +from patching.ui.save import SaveController +from patching.ui.preview import PatchingController +from patching.util.ida import get_current_ea, read_range_selection + +#----------------------------------------------------------------------------- +# IDA Plugin Actions +#----------------------------------------------------------------------------- + +class NopAction(ida_kernwin.action_handler_t): + NAME = 'patching:nop' + ICON = 'nop.png' + TEXT = "NOP" + TOOLTIP = "NOP the selected instructions (or bytes)" + HOTKEY = 'CTRL-N' + + def __init__(self, core): + ida_kernwin.action_handler_t.__init__(self) + self.core = core + + def activate(self, ctx): + + # fetch the address range selected by the user + valid_selection, start_ea, end_ea = read_range_selection(ctx) + + # do a range-based NOP if the selection is valid + if valid_selection: + print("%08X --> %08X: NOP'd range" % (start_ea, end_ea)) + self.core.nop_range(start_ea, end_ea) + return 1 + + # NOP a single instruction / item + cur_ea = get_current_ea(ctx) + if cur_ea == ida_idaapi.BADADDR: + print("Cannot use NOP here... (Invalid Address)") + return 0 + + print("%08X: NOP'd item" % cur_ea) + self.core.nop_item(cur_ea) + + # return 1 to refresh the IDA views + return 1 + + def update(self, ctx): + + # the NOP action should only be allowed to execute in the following views + if ida_kernwin.get_widget_type(ctx.widget) == ida_kernwin.BWN_DISASM: + return ida_kernwin.AST_ENABLE_FOR_WIDGET + elif ida_kernwin.get_widget_title(ctx.widget) == 'PatchingCodeViewer': + return ida_kernwin.AST_ENABLE_FOR_WIDGET + + # unknown context / widget, do NOT allow the NOP action to be used here + return ida_kernwin.AST_DISABLE_FOR_WIDGET + +class RevertAction(ida_kernwin.action_handler_t): + NAME = 'patching:revert' + ICON = 'revert.png' + TEXT = "Revert patch" + TOOLTIP = "Revert patched bytes at the selected address" + HOTKEY = None + + def __init__(self, core): + ida_kernwin.action_handler_t.__init__(self) + self.core = core + + def activate(self, ctx): + + # fetch the address range selected by the user + valid_selection, start_ea, end_ea = read_range_selection(ctx) + + if valid_selection: + print("%08X --> %08X: Reverted range" % (start_ea, end_ea)) + self.core.revert_range(start_ea, end_ea) + else: + cur_ea = get_current_ea(ctx) + print("%08X: Reverted patch" % cur_ea) + self.core.revert_patch(cur_ea) + + # return 1 to refresh the IDA views + return 1 + + def update(self, ctx): + return ida_kernwin.AST_ENABLE_ALWAYS + +class ForceJumpAction(ida_kernwin.action_handler_t): + NAME = 'patching:forcejump' + ICON = 'forcejump.png' + TEXT = "Force jump" + TOOLTIP = "Patch the selected jump into an unconditional jump" + HOTKEY = None + + def __init__(self, core): + ida_kernwin.action_handler_t.__init__(self) + self.core = core + + def activate(self, ctx): + cur_ea = get_current_ea(ctx) + + print("%08X: Forced conditional jump" % cur_ea) + self.core.force_jump(cur_ea) + + # return 1 to refresh the IDA views + return 1 + + def update(self, ctx): + return ida_kernwin.AST_ENABLE_ALWAYS + +class AssembleAction(ida_kernwin.action_handler_t): + NAME = 'patching:assemble' + ICON = 'assemble.png' + TEXT = "Assemble..." + TOOLTIP = "Assemble new instructions at the selected address" + HOTKEY = None + + def __init__(self, core): + ida_kernwin.action_handler_t.__init__(self) + self.core = core + + def activate(self, ctx): + + # do not create a new patching dialog if one is already active + if ida_kernwin.find_widget(PatchingController.WINDOW_TITLE): + return 1 + + wid = PatchingController(self.core, get_current_ea(ctx)) + + # return 1 to refresh the IDA views + return 1 + + def update(self, ctx): + return ida_kernwin.AST_ENABLE_ALWAYS + +class ApplyAction(ida_kernwin.action_handler_t): + NAME = 'patching:apply' + ICON = 'save.png' + TEXT = "Apply patches to..." + TOOLTIP = "Select where to save the patched binary" + HOTKEY = None + + def __init__(self, core): + ida_kernwin.action_handler_t.__init__(self) + self.core = core + + def activate(self, ctx): + + controller = SaveController(self.core) + + if controller.interactive(): + print("Patch successful: %s" % self.core.patched_filepath) + else: + print("Patching cancelled...") + + # return 1 to refresh the IDA views + return 1 + + def update(self, ctx): + return ida_kernwin.AST_ENABLE_ALWAYS + +class QuickApplyAction(ida_kernwin.action_handler_t): + NAME = 'patching:quickapply' + ICON = 'save.png' + TEXT = "Quick apply patches" + TOOLTIP = "Apply patches using the previously selected patch settings" + HOTKEY = None + + def __init__(self, core): + ida_kernwin.action_handler_t.__init__(self) + self.core = core + + def activate(self, ctx): + + # attempt to perform a quick patch (save), per the user's request + success, error = self.core.quick_apply() + if success: + print("Quick patch successful: %s" % self.core.patched_filepath) + return 1 + + # + # since the quickpatch FAILED, fallback to popping the interactive + # patch saving dialog to let the user sort out the issue + # + + print("Quick patch failed...") + controller = SaveController(self.core, error) + + if controller.interactive(): + print("Patch successful: %s" % self.core.patched_filepath) + else: + print("Patching cancelled...") + + # return 1 to refresh the IDA views + return 1 + + def update(self, ctx): + return ida_kernwin.AST_ENABLE_ALWAYS + +#----------------------------------------------------------------------------- +# All Actions +#----------------------------------------------------------------------------- + +PLUGIN_ACTIONS = \ +[ + NopAction, + RevertAction, + ForceJumpAction, + AssembleAction, + ApplyAction, + QuickApplyAction +] diff --git a/plugins/patching/asm.py b/plugins/patching/asm.py new file mode 100644 index 0000000..126624b --- /dev/null +++ b/plugins/patching/asm.py @@ -0,0 +1,879 @@ +import ida_ua +import ida_idp +import ida_nalt +import ida_lines +import ida_segregs + +from patching.util.ida import * +import patching.keystone as keystone + +TEST_KS_RESOLVER = False + +class KeystoneAssembler(object): + """ + An abstraction of a CPU-specific fixup layer to wrap Keystone. + """ + + # the mnemonic for an unconditional jump + UNCONDITIONAL_JUMP = NotImplementedError + + # the list of known conditional jump mnemonics + CONDITIONAL_JUMPS = [] + + # a list of mnemonics that we KNOW are currently unsupported + UNSUPPORTED_MNEMONICS = [] + + # the number of instruction bytes to show in the patch preview pane + MAX_PREVIEW_BYTES = 4 + + # + # NOTE: for now, we explicitly try to print operands using 'blank' type + # info because it can produce simpler output for the assembler engine + # + # we initialize just one instance of this blank printop for performance + # reasons, so we do not have to initialize a new one for *every* print. + # + # it is particularly useful when using the assemble_all(...) DEV / test + # function to round-trip assemble an entire IDB + # + + _NO_OP_TYPE = ida_nalt.printop_t() + + def __init__(self, arch, mode): + + # a super low-effort TODO assert to ensure we're not using incomplete code + assert self.UNCONDITIONAL_JUMP != NotImplementedError, "Incomplete Assembler Implementation" + + # initialize a backing keystone assembler + self._arch = arch + self._mode = mode | (keystone.KS_OPT_SYM_RESOLVER if TEST_KS_RESOLVER else 0) + self._ks = keystone.Ks(arch, mode) + + # TODO/XXX: the keystone sym resolver callback is only for DEV / testing + if TEST_KS_RESOLVER: + self._ks.sym_resolver = self._ks_sym_resolver + + def _ks_sym_resolver(self, symbol, value): + """ + TODO: the keystone symbol resolver can be a bit goofy, so we opt not + to use it (keypatch doesn't, either!) for now. it has been left here + for future testing or further bugfixing of keystone + + NOTE: this *CAN* be beneficial to use for MULTI INSTRUCTION assembly, + such as assembling a block of instructions (eg. shellcode, or a + more complex patch) which makes use of labels within said block. + """ + symbol = symbol.decode('utf-8') + + # + # some symbols in IDA names / chars cannot pass cleanly through + # keystone. for that reason, we try to replace some 'problematic' + # characters that may appear in IDA symbols (and then disas text) + # + # when they pop back up here, in keystone's symbol resolver, we + # try to subsitute the 'problematic' characters back in so that + # we can look up the original symbol value in IDA + # + + if 'AT_SPECIAL_AT' in symbol: + symbol = symbol.replace('AT_SPECIAL_AT', '@') + if 'QU_SPECIAL_QU' in symbol: + symbol = symbol.replace('QU_SPECIAL_QU', '?') + + # + # XXX: pretty messy, sorry. no way to resolve 'symbol collisions' + # that could technically manifest from IDA + # + + for sym_value, sym_real_name in resolve_symbol(self._ks_address, symbol): + value[0] = sym_value + return True + + # symbol resolution failed + return False + + def rewrite_symbols(self, assembly, ea): + """ + Rewrite the symbols in the given assembly text to their concrete values. + """ + + # + # TODO: is there a reason i'm not using parse_disassembly_components() + # here? I forget, this code probably predates that. + # + + mnem, sep, ops = assembly.partition(' ') + + # 'mnem' appears to be an instruction prefix actually, so keep parsing + if mnem in KNOWN_PREFIXES: + real_mnem, sep, ops = ops.partition(' ') + mnem += ' ' + real_mnem + + # + # scrape symbols from *just* the operands text, as that's the only + # place we would expect to see them in assembly code anyway! + # + + symbols = scrape_symbols(ops) + + # + # if the symbol count is too high, it might take 'too long' to try + # and resolve them all in a big database. At 10+ symbols, it is + # probably just an invalid input to the assembler as is (at least, + # for a single instruction ...) + # + # TODO: really, we should be throwing a set of more descriptive + # errors from the assembler that the dialog can render rather + # than trying to catch issues in preview.py (UI land) + # + + if len(symbols) > 10: + print("Aborting symbol re-writing, too (%u) many potential symbols..." % (len(symbols))) + return assembly + + # + # with a list of believed symbols and their text location, we will + # try to resolve a value for each text symbol and swap a raw hex + # number in to replace the symbol text + # + # eg. 'mov eax, [foo]' --> 'mov eax, [0x410800]' + # + # where 'foo' was a symbol name entered by the user, but we can + # query IDA to try and resolve (func address, data address, etc) + # + + prev_index = 0 + new_ops = '' + + for name, location in symbols: + sym_start, sym_end = location + + for sym_value, sym_real_name in resolve_symbol(ea, name): + sym_value_text = '0x%X' % sym_value + + # + # we are carefully carving around the original symbol text + # to build out a new 'string' for the full operand text + # + + new_ops += ops[prev_index:sym_start] + sym_value_text + prev_index = sym_end + + # + # TODO: the case where resolve_symbol can return 'multiple' + # results (eg, a symbol 'collision') is currently unhandled + # but could happen in very rare cases + # + # by always breaking on the first iteration of this loop, + # we're effectively always selecting the first symbol value + # without any consideration of others (TODO how?) + # + # lol, this symbol resolution / rewriting is ugly enough as + # is. it will probably have to get re-written an simplified + # at a later time, if possible :S + # + + break + + else: + #print("%08X: Failed to resolve possible symbol '%s'" % (ea, name)) + continue + + new_ops += ops[prev_index:] + raw_assembly = mnem + sep + new_ops + + # + # return assembly text that has (ideally) had possible symbols + # replaced with unambiguous values that are easy for the assembler + # to consume + # + + return raw_assembly + + def asm(self, assembly, ea=0, resolve=True): + """ + Assemble the given instruction with an optional base address. + + TODO/v0.2.0: support 'simple' one-line but multi-instruction assembly? + """ + unaliased_assembly = self.unalias(assembly) + + if TEST_KS_RESOLVER: + raw_assembly = unaliased_assembly + raw_assembly = raw_assembly.replace('@', 'AT_SPECIAL_AT') + raw_assembly = raw_assembly.replace('?', 'QU_SPECIAL_QU') + self._ks_address = ea + elif resolve: + raw_assembly = self.rewrite_symbols(unaliased_assembly, ea) + else: + raw_assembly = unaliased_assembly + + #print(" Assembling: '%s' @ ea 0x%08X" % (raw_assembly, ea)) + + # + # TODO: this whole function is kind of gross, and it would be good if + # we could surface at least 'some' of the error information that + # keystone can produce of failures + # + + # try assemble + try: + asm_bytes, count = self._ks.asm(raw_assembly, ea, True) + if asm_bytes == None: + return bytes() + except Exception as e: + #print("FAIL", e) + return bytes() + + # return the generatied instruction bytes if keystone succeeded + return asm_bytes + + def is_conditional_jump(self, mnem): + """ + Return True if the given mnemonic is a conditional jump. + + TODO: 'technically' I think IDA might actually have some CPU + agnostic API's to tell if an instruction is a conditional jump. + + so maybe the need to manually define CONDITIONAL_JUMPS mnemonics + for CPU's can be removed in a future version of this plugin... + """ + return bool(mnem.upper() in self.CONDITIONAL_JUMPS) + + def nop_buffer(self, start_ea, end_ea): + """ + Generate a NOP buffer for the given address range. + """ + range_size = end_ea - start_ea + if range_size < 0: + return bytes() + + # fetch the bytes for a NOP instruction (and its size) + nop_data = self.asm('nop', start_ea) + nop_size = len(nop_data) + + # generate a buffer of NOP's equal to the range we are filling in + nop_buffer = nop_data * (range_size // nop_size) + + return nop_buffer + + #-------------------------------------------------------------------------- + # Assembly Normalization + #-------------------------------------------------------------------------- + + def format_prefix(self, insn, prefix): + """ + Return an assembler compatible version of the given prefix. + """ + return prefix + + def format_mnemonic(self, insn, mnemonic): + """ + Return an assembler compatible version of the given mnemonic. + """ + return mnemonic + + def format_memory_op(self, insn, n): + """ + Return an assembler compatible version of the given memory op. + """ + op_text = ida_ua.print_operand(insn.ea, n, 0, self._NO_OP_TYPE) + return op_text + + def format_imm_op(self, insn, n): + """ + Return an assembler compatible version of the given imm val op. + """ + return ida_ua.print_operand(insn.ea, n) + + def format_assembly(self, ea): + """ + Return assembler compatible disassembly for the given address. + + This function sort re-implements the general instruction printing + pipeline of the loaded processor module, but just way more shady. + """ + prefix, mnem, _ = get_disassembly_components(ea) + + # + # TODO: this 'used' to be used to handle a failure from the above + # function, but I don't think it is needed anymore. as the above func + # has been dramatically simplified to parse 'dumber' than it used to + # + # it had to do with something with trying to parse/format addresses + # that would return stuff like 'align 10h' (not real instructions) + # + + if mnem is None: + return '' + + # + # decode the instruction just once so the CPU-specific layers can + # read and use it to apply specific fixups when needed + # + + insn = ida_ua.insn_t() + ida_ua.decode_insn(insn, ea) + + # this will accumulate the final fixed up text for all ops + ops = [] + + # this will hold the fixed up operand text for the current op + op_text = '' + + # + # generate the operand text for each op, with callbacks into the + # processor specific fixups as necessary for each op type + # + + for op in insn.ops: + + # + # NOTE/PERF: these if/elif statements have been arranged based on + # frequency (at least in x86/x64) for performance reasons + # + # be careful re-ordering them, as it may make assemble_all(...) + # run twice as slow!! + # + + if op.type in [ida_ua.o_reg, ida_ua.o_far, ida_ua.o_near]: + op_text = ida_ua.print_operand(ea, op.n) + + # reached final operand in this instruction + elif op.type == ida_ua.o_void: + break + + # + # TODO: ideally we should allow users to toggle between 'pretty' + # and 'raw' displacement / phrase ops, but I think there's keystone / + # LLVM weirdness that is causing some bad assembly to be generated? + # + # IDA: 'mov [esp+6Ch+dest], esi' + # RAW: 'mov [esp+6Ch+0xFFFFFF94], esi' + # WHICH IS: 'mov [esp], esi' + # + # but this is what keystone 'evaluates' and generates 'bad' asm for + # + # IDA: 'mov [esp], esi' -- 89 34 24 + # keystone: 'mov [esp+0x100000000], esi' -- 89 74 24 (? invalid asm) + # + # this will have to be investigated later. so for now we generate asm + # without IDA's special offsetting... + # + + elif op.type in [ida_ua.o_displ, ida_ua.o_phrase]: + op_text = ida_ua.print_operand(ea, op.n, 0, self._NO_OP_TYPE) + + elif op.type == ida_ua.o_imm: + op_text = self.format_imm_op(insn, op.n) + + elif op.type == ida_ua.o_mem: + op_text = self.format_memory_op(insn, op.n) + + else: + op_text = ida_ua.print_operand(ea, op.n) + + # + # the operand is marked as invisible according to IDA, + # so we shouldn't be showing / generating text for it anyway + # (eg. Op4 for UMULH in ARM64) + # + + if not(op.flags & ida_ua.OF_SHOW): + continue + + ops.append(op_text) + + ops = list(map(ida_lines.tag_remove, filter(None, ops))) + prefix = self.format_prefix(insn, prefix) + mnem = self.format_mnemonic(insn, mnem) + + if prefix: + mnem = prefix + ' ' + mnem + + # generate the fully disassembled instruction / text + text = '%s %s' % (mnem.ljust(7, ' '), ', '.join(ops)) + + # TODO/XXX: ehh this should probably be cleaned up / moved in v0.2.0 + for banned in ['[offset ', '(offset ', ' offset ', ' short ', ' near ptr ', ' far ptr ', ' large ']: + text = text.replace(banned, banned[0]) + + return text.strip() + + def unalias(self, assembly): + """ + Translate an instruction alias / shorthand to its full version. + """ + return assembly + +#------------------------------------------------------------------------------ +# x86 / x86_64 +#------------------------------------------------------------------------------ + +class AsmX86(KeystoneAssembler): + """ + Intel x86 & x64 specific wrapper for Keystone. + """ + + UNCONDITIONAL_JUMP = 'JMP' + CONDITIONAL_JUMPS = \ + [ + 'JZ', 'JE', 'JNZ', 'JNE', 'JC', 'JNC', + 'JO', 'JNO', 'JS', 'JNS', 'JP', 'JPE', + 'JNP', 'JPO', 'JCXZ', 'JECXZ', 'JRCXZ', + 'JG', 'JNLE', 'JGE', 'JNL', 'JL', 'JNGE', + 'JLE', 'JNG', 'JA', 'JNBE', 'JAE', 'JNB', + 'JB', 'JNAE', 'JBE', 'JNA' + ] + + UNSUPPORTED_MNEMONICS = \ + [ + # intel CET + 'ENDBR32', 'ENDBR64', + 'RDSSPD', 'RDSSPQ', + 'INCSSPD', 'INCSSPQ', + 'SAVEPREVSSP', 'RSTORSSP', + 'WRSSD', 'WRSSQ', 'WRUSSD', 'WRUSSQ', + 'SETSSBSY', 'CLRSSBSY', + + # misc + 'MONITOR', 'MWAIT', 'MONITORX', 'MWAITX', + 'INVPCID', + + # bugged? + 'REPE CMPSW', + ] + + def __init__(self, inf): + arch = keystone.KS_ARCH_X86 + + if inf.is_64bit(): + mode = keystone.KS_MODE_64 + self.MAX_PREVIEW_BYTES = 7 + elif inf.is_32bit(): + mode = keystone.KS_MODE_32 + self.MAX_PREVIEW_BYTES = 6 + else: + mode = keystone.KS_MODE_16 + + # initialize keystone-based assembler + super(AsmX86, self).__init__(arch, mode) + + #-------------------------------------------------------------------------- + # Intel Assembly Formatting / Fixups + #-------------------------------------------------------------------------- + + def format_mnemonic(self, insn, mnemonic): + original = mnemonic.strip() + + # normalize the mnemonic case for fixup checking + mnemonic = original.upper() + + if mnemonic == 'RETN': + return 'ret' + if mnemonic == 'XLAT': + return 'xlatb' + + # no mnemonic fixups, return the original + return original + + def format_memory_op(self, insn, n): + + # + # because IDA generates some 'non-standard' syntax in favor of human + # readability, we have to fixup / re-print most memory operands to + # reconcile them with what the assembler expects. + # + # (i'll go through later and document examples of each 'case' below) + # + + op_text = super(AsmX86, self).format_memory_op(insn, n) + op_text = ida_lines.tag_remove(op_text) + + # + # since this is a memory operation, we expect there to be a '[...]' + # present in the operand text. if there isn't we should try to wrap + # the appropriate parts of operand with square brackets + # + + if '[' not in op_text: + + # + # this case is to wrap segment:offset kind of prints: + # + # eg. + # - .text:00000001400AD89A 65 48 8B 04 25 58 00+ mov rax, gs:58h + # + # NOTE: the secondary remaining[0] != ':' check is to avoid 'cpp' + # cases, basically ensuring we are not modifying a '::' + # + # eg. + # - .text:000000014000A4F2 48 8D 05 EF 14 25 00 lea rax, const QT::QSplitter::'vftable' + # + + start, sep, remaining = op_text.partition(':') + if sep and remaining[0] != ':': + op_text = start + sep + '[' + remaining + ']' + + # + # eg. + # - .text:08049F52 F6 05 A4 40 0F 08 02 test byte ptr dword_80F40A4, 2 + # + + elif ' ptr ' in op_text: + start, sep, remaining = op_text.partition(' ptr ') + op_text = start + sep + '[' + remaining + ']' + + # + # eg. + # - .text:000000014002F0C6 48 8D 0D 53 B9 E2 00 lea rcx, unk_140E5AA20 + # + + else: + op_text = '[' + op_text + ']' + + if ' ptr ' in op_text and self._mode is keystone.KS_MODE_32: + return op_text + + # + # TODO: document these cases + # + + op = insn.ops[n] + seg_reg = (op.specval & 0xFFFF0000) >> 16 + + if seg_reg: + #print("SEG REG: 0x%X 0x%X" % (op.specval & 0xFFFF, ((op.specval & 0xFFFF0000) >> 16))) + seg_reg_name = ida_idp.ph.regnames[seg_reg] + if seg_reg_name == 'cs': + op_text = op_text.replace('cs:', '') + elif seg_reg_name not in op_text: + op_text = '%s:%s' % (seg_reg_name, op_text) + + if ' ptr ' in op_text: + return op_text + + t_name = get_dtype_name(op.dtype, ida_ua.get_dtype_size(op.dtype)) + op_text = '%s ptr %s' % (t_name, op_text) + + return op_text + + def format_imm_op(self, insn, n): + op_text = super(AsmX86, self).format_imm_op(insn, n) + if '$+' in op_text: + op_text = ida_ua.print_operand(insn.ea, n, 0, self._NO_OP_TYPE) + return op_text + + def unalias(self, assembly): + + # normalize spacing / capitalization + parts = list(filter(None, assembly.lower().split(' '))) + full = ' '.join(parts) + if not full: + return assembly + + # + # IDA64 likes to print 'int 3' for 'CC', but keystone assembles this + # to 'CD 03'... so we alias 'int 3' to 'int3' here instead which will + # emit the preferred form 'CC' + # + + if full == 'int 3': + return 'int3' + + # + # TODO/XXX: keystone doesn't know about 'movsd' ? so we correct it + # here for now ... this will handle 'movsd' / 'rep* movsd' + # + + if parts[-1] == 'movsd': + + if self._mode & keystone.KS_MODE_64: + regs = ('rdi', 'rsi') + else: + regs = ('edi', 'esi') + + # preserves prefix ... if there was one + return assembly + ' dword ptr [%s], dword ptr [%s]' % regs + + # no special aliasing / fixups + return assembly + +#------------------------------------------------------------------------------ +# ARM / ARM64 +#------------------------------------------------------------------------------ + +class AsmARM(KeystoneAssembler): + """ + ARM specific wrapper for Keystone. + """ + + UNCONDITIONAL_JUMP = 'B' + CONDITIONAL_JUMPS = \ + [ + # ARM + 'BEQ', 'BNE', 'BCC', 'BCS', 'BVC', 'BVS', + 'BMI', 'BPL', 'BHS', 'BLO', 'BHI', 'BLS', + 'BGE', 'BLT', 'BGT', 'BLE' + + # ARM64 + 'B.EQ', 'B.NE', 'B.CS', 'B.CC', 'B.MI', 'B.PL', + 'B.VS', 'B.VC', 'B.HI', 'B.LS', 'B.GE', 'B.LT', + 'B.GT', 'B.LE', 'CBNZ', 'CBZ', 'TBZ', 'TBNZ' + ] + + UNSUPPORTED_MNEMONICS = \ + [ + 'ADR', 'ADRL', + + # Pointer Authentication + 'AUTDA', 'AUTDZA', 'AUTDB', 'AUTDZB', + 'AUTIA', 'AUTIA1716', 'AUTIASP', 'AUTIAZ', 'AUTIZA', + 'AUTIB', 'AUTIB1716', 'AUTIBSP', 'AUTIBZ', 'AUTIZB', + + 'BLRAA', 'BLRAAZ', 'BLRAB', 'BLRABZ', + 'BRAA', 'BRAAZ', 'BRAB', 'BRABZ', + + 'PACDA', 'PACDZA', 'PACDB', 'PACDZB', 'PACGA', + 'PACIA', 'PACIA1716', 'PACIASP', 'PACIAZ', 'PACIZA', + 'PACIB', 'PACIB1716', 'PACIBSP', 'PACIBZ', 'PACIZB', + 'RETAA', 'RETAB', + + 'XPACD', 'XPACI', 'XPACLRI' + + # TODO: MRS and MOV (32/64 bit) are semi-supported too + ] + + def __init__(self, inf): + + # ARM64 + if inf.is_64bit(): + arch = keystone.KS_ARCH_ARM64 + + if inf.is_be(): + mode = keystone.KS_MODE_BIG_ENDIAN + else: + mode = keystone.KS_MODE_LITTLE_ENDIAN + + # AArch64 does not use THUMB + self._ks_thumb = None + + # ARM + else: + arch = keystone.KS_ARCH_ARM + + if inf.is_be(): + mode = keystone.KS_MODE_ARM | keystone.KS_MODE_BIG_ENDIAN + self._ks_thumb = keystone.Ks(arch, keystone.KS_MODE_THUMB | keystone.KS_MODE_BIG_ENDIAN) + else: + mode = keystone.KS_MODE_ARM | keystone.KS_MODE_LITTLE_ENDIAN + self._ks_thumb = keystone.Ks(arch, keystone.KS_MODE_THUMB | keystone.KS_MODE_LITTLE_ENDIAN) + + # initialize keystone-based assembler + super(AsmARM, self).__init__(arch, mode) + + # pre-assemble for later, repeated use + self.__ARM_NOP_4, _ = self._ks.asm('NOP', as_bytes=True) + if self._ks_thumb: + self.__THUMB_NOP_2, _ = self._ks_thumb.asm('NOP', as_bytes=True) + self.__THUMB_NOP_4, _ = self._ks_thumb.asm('NOP.W', as_bytes=True) + + def asm(self, assembly, ea=0, resolve=True): + + # swap engines when trying to assemble to a THUMB region + if self.is_thumb(ea): + ks = self._ks + self._ks = self._ks_thumb + data = super(AsmARM, self).asm(assembly, ea, resolve) + self._ks = ks + return data + + # assemble as ARM + return super(AsmARM, self).asm(assembly, ea, resolve) + + @staticmethod + def is_thumb(ea): + """ + Return True if the given address is marked as THUMB. + """ + return bool(ida_segregs.get_sreg(ea, ida_idp.str2reg('T')) == 1) + + def nop_buffer(self, start_ea, end_ea): + """ + Generate a NOP buffer for the given address range. + """ + range_size = end_ea - start_ea + if range_size < 0: + return bytes() + + # + # TODO/XXX: how should we handle 'mis-aligned' NOP actions? or + # truncated range? (eg, not enough bytes to fill as complete NOPs... + # + # Should we just reject them here? or attempt to NOP some? Need to + # ensure UI fails gracefully, etc. + # + + # the crafted buffer on NOP instructions to return + nop_list = [] + + # + # with ARM, it is imperative we attempt to retain the size of the + # instruction being NOP'd. this is to help account for cases such as + # the ITTT blocks in THUMB: + # + # __text:000021A2 1E BF ITTT NE + # __text:000021A4 D4 F8 C4 30 LDRNE.W R3, [R4,#0xC4] + # __text:000021A8 43 F0 04 03 ORRNE.W R3, R3, #4 + # __text:000021AC C4 F8 C4 30 STRNE.W R3, [R4,#0xC4] + # __text:000021B0 94 F8 58 30 LDRB.W R3, [R4,#0x58] + # + # replacing these 4-byte THUMB instructions with 2-byte THUMB NOP's + # breaks the intrinsics of the conditional block. therefore, we + # will attempt to replace THUMB instructions with a NOP of the same + # size as the original instruction + # + + cur_ea = ida_bytes.get_item_head(start_ea) + while cur_ea < end_ea: + item_size = ida_bytes.get_item_size(cur_ea) + + # special handling to pick THUMB 2 / 4 byte NOP as applicable + if self.is_thumb(cur_ea): + if item_size == 2: + nop_list.append(self.__THUMB_NOP_2) + else: + nop_list.append(self.__THUMB_NOP_4) + + # NOP'ing a normal 4-byte ARM instruction + else: + nop_list.append(self.__ARM_NOP_4) + + # continue to next instruction + cur_ea += item_size + + # return a buffer of (NOP) instruction bytes + return b''.join(nop_list) + + #-------------------------------------------------------------------------- + # ARM Assembly Formatting / Fixups + #-------------------------------------------------------------------------- + + def format_memory_op(self, insn, n): + op = insn.ops[n] + + # ARM / ARM64 + if ida_idp.ph.regnames[op.reg] == 'PC': + offset = (op.addr - insn.ea) - 8 + op_text = '[PC, #%s0x%X]' % ('-' if offset < 0 else '', abs(offset)) + return op_text + + # + # TODO: THUMB-ish... note this is kind of groess and should + # probably be cleaned up / documented better. I don't think it's a + # fair assumption that all THUMB memory references are PC rel? but + # maybe that's true. (I'm not an ARM expert) + # + + elif self.is_thumb(insn.ea): + offset = (op.addr - insn.ea) - 4 + (insn.ea % 4) + op_text = '[PC, #%s0x%X]' % ('-' if offset < 0 else '', abs(offset)) + return op_text + + op_text = ida_lines.tag_remove(super(AsmARM, self).format_memory_op(insn, n)) + + if op_text[0] == '=': + op_text = '#0x%X' % op.addr + + return op_text + + def format_imm_op(self, insn, n): + """ + TODO: this is temporary, until we do work on formatting IDA's + ARM memory ref 'symbols' (which are often imms on ARM) + """ + op_text = ida_ua.print_operand(insn.ea, n, 0, self._NO_OP_TYPE) + return op_text + + def unalias(self, assembly): + prefix, mnemonic, ops = parse_disassembly_components(assembly) + + # IDA seems to prefer showing 'STMFA', but keystone expects 'STMIB' + if mnemonic.upper() == 'STMFA': + return ' '.join([prefix, 'STMIB', ops]) + + return assembly + +#------------------------------------------------------------------------------ +# PPC / PPC64 TODO +#------------------------------------------------------------------------------ + +class AsmPPC(KeystoneAssembler): + + def __init__(self, inf): + arch = keystone.KS_ARCH_PPC + + if inf.is_64bit(): + mode = keystone.KS_MODE_PPC64 + else: + mode = keystone.KS_MODE_PPC32 + + # TODO: keystone does not support Little Endian mode for PPC? + #if arch_name == 'ppc': + # mode += keystone.KS_MODE_BIG_ENDIAN + + # initialize keystone-based assembler + super(AsmPPC, self).__init__(arch, mode) + +#------------------------------------------------------------------------------ +# MIPS / MIPS64 TODO +#------------------------------------------------------------------------------ + +class AsmMIPS(KeystoneAssembler): + + def __init__(self, inf): + arch = keystone.KS_ARCH_MIPS + + if inf.is_64bit(): + mode = keystone.KS_MODE_MIPS64 + else: + mode = keystone.KS_MODE_MIPS32 + + if inf.is_be(): + mode |= keystone.KS_MODE_BIG_ENDIAN + else: + mode |= keystone.KS_MODE_LITTLE_ENDIAN + + # initialize keystone-based assembler + super(AsmMIPS, self).__init__(arch, mode) + +#------------------------------------------------------------------------------ +# SPARC TODO +#------------------------------------------------------------------------------ + +class AsmSPARC(KeystoneAssembler): + + def __init__(self, inf): + arch = keystone.KS_ARCH_SPARC + + if inf.is_64bit(): + mode = keystone.KS_MODE_SPARC64 + else: + mode = keystone.KS_MODE_SPARC32 + + if inf.is_be(): + mode |= keystone.KS_MODE_BIG_ENDIAN + else: + mode |= keystone.KS_MODE_LITTLE_ENDIAN + + # initialize keystone-based assembler + super(AsmSPARC, self).__init__(arch, mode) + +#------------------------------------------------------------------------------ +# System-Z +#------------------------------------------------------------------------------ + +class AsmSystemZ(KeystoneAssembler): + + def __init__(self, inf): + super(AsmSystemZ, self).__init__(keystone.KS_ARCH_SYSTEMZ, keystone.KS_MODE_BIG_ENDIAN) diff --git a/plugins/patching/core.py b/plugins/patching/core.py new file mode 100644 index 0000000..3cbdd3d --- /dev/null +++ b/plugins/patching/core.py @@ -0,0 +1,1233 @@ +import shutil +import hashlib +import collections + +import ida_ua +import ida_auto +import ida_bytes +import ida_lines +import ida_idaapi +import ida_loader +import ida_kernwin +import ida_segment +import idautils + +from patching.asm import * +from patching.actions import * +from patching.exceptions import * + +from patching.util.ida import * +from patching.util.misc import plugin_resource +from patching.util.python import register_callback, notify_callback + +#------------------------------------------------------------------------------ +# Plugin Core +#------------------------------------------------------------------------------ +# +# The plugin core constitutes the traditional 'main' plugin class. It +# will host all of the plugin's objects and integrations, taking +# responsibility for their initialization/teardown/lifetime. +# +# This pattern of splitting out the plugin core from the IDA plugin_t stub +# is primarily to help separate the plugin functionality from IDA's and +# make it easier to 'reload' for development / testing purposes. +# + +class PatchingCore(object): + + PLUGIN_NAME = 'Patching' + PLUGIN_VERSION = '0.1.0' + PLUGIN_AUTHORS = 'Markus Gaasedelen' + PLUGIN_DATE = '2022' + + def __init__(self, defer_load=False): + + # IDA UI Hooks + self._ui_hooks = UIHooks() + self._ui_hooks.ready_to_run = self.load + self._ui_hooks.populating_widget_popup = self._populating_widget_popup + self._ui_hooks.get_lines_rendering_info = self._highlight_lines + self._ui_hooks.hook() + + # IDA 'Processor' Hooks + self._idp_hooks = IDPHooks() + self._idp_hooks.ev_ending_undo = self._ida_undo_occurred + + # IDA 'Database' Hooks + self._idb_hooks = IDBHooks() + self._idb_hooks.auto_empty_finally = self.load + + # + # the plugin only uses IDB hooks for IDA Batch mode. specifically, it + # will load the plugin when the initial auto analysis has finished + # + # TODO: does auto_empty_finally trigger if you are loading a + # pre-existing IDB in IDA batch mode? (probably not, hence TODO) + # + + if ida_kernwin.cvar.batch: + self._idb_hooks.hook() + + # the backing engine to assemble instructions for the plugin + self.assembler = None + + # a set of all addresses patched by the user + self.patched_addresses = set() + + # the executable filepath that patches were applied to + self.patched_filepath = None + + # the executable filepath used to apply patches from (the clean file) + self.backup_filepath = None + + # apply saved patches from a known-good (clean) executable by default + self.prefer_patch_cleanly = True + + # enable quick save after a successful patch application occurs + self.prefer_quick_apply = True + self.__saved_successfully = False + + # plugin events / callbacks + self._patches_changed_callbacks = [] + + # + # defer fully loading the plugin core until the IDB and UI itself + # is settled. in this case, self.load() will be called later on + # by IDA's UI ready_to_run event (or auto_empty_finally in batch) + # + + if defer_load: + return + + # + # if loading is not being deferred, we have to load the plugin core + # now. this is only used for development purposes such as 'hot + # reloading' the plugin via the IDA console (DEV) + # + + self.load() + + #-------------------------------------------------------------------------- + # Initialization / Teardown + #-------------------------------------------------------------------------- + + def load(self): + """ + Load the plugin core. + """ + + # + # IDB hooks are *only* ever used to load the patching plugin after + # initial auto-analysis completes in batch mode. so we should always + # unhook them here as they will not be used for anything else + # + + if ida_kernwin.cvar.batch: + self._idb_hooks.unhook() + + # attempt to initialize an assembler engine matching the database + self._init_assembler() + + # deactivate the plugin if this is an unsupported architecture + if not self.assembler: + self._ui_hooks.unhook() + return + + # finish loading the plugin and integrating its UI elements / actions + self._init_actions() + self._idp_hooks.hook() + self._refresh_patches() + ida_kernwin.refresh_idaview_anyway() + + print("[%s] Loaded v%s - (c) %s - %s" % (self.PLUGIN_NAME, self.PLUGIN_VERSION, self.PLUGIN_AUTHORS, self.PLUGIN_DATE)) + + # parse / handle command line options for this plugin (DEV) + self._run_cli_options() + + def unload(self): + """ + Unload the plugin core. + """ + self._idb_hooks.unhook() + + if not self.assembler: + return + + print("[%s] Unloading v%s..." % (self.PLUGIN_NAME, self.PLUGIN_VERSION)) + + self._idp_hooks.unhook() + self._ui_hooks.unhook() + self._unregister_actions() + self._unload_assembler() + + def _init_assembler(self): + """ + Initialize the assembly engine to be used for patching. + """ + inf = ida_idaapi.get_inf_structure() + arch_name = inf.procname.lower() + + if arch_name == 'metapc': + assembler = AsmX86(inf) + elif arch_name.startswith('arm'): + assembler = AsmARM(inf) + + # + # TODO: disabled until v0.2.0 + # + #elif arch_name.startswith("ppc"): + # assembler = AsmPPC(inf) + #elif arch_name.startswith("mips"): + # assembler = AsmMIPS(inf) + #elif arch_name.startswith("sparc"): + # assembler = AsmSPARC(inf) + #elif arch_name.startswith("systemz") or arch_name.startswith("s390x"): + # assembler = AsmSystemZ(inf) + # + + else: + assembler = None + print(" - Unsupported CPU: '%s' (%s)" % (arch_name, ida_nalt.get_input_file_path())) + + self.assembler = assembler + + def _unload_assembler(self): + """ + Unload the assembly engine. + """ + + # + # NOTE: this is kind of aggressive attempt at deleting the assembler + # and Keystone components in an effort to keep things safe if the user + # is trying to do an easy install (updating) over the existing plugin + # + # read the install.py script (easy install) for a bit more context of + # why we're trying to minimize exposure to Keystone on unload + # + + del self.assembler._ks + del self.assembler + self.assembler = None + + def _init_actions(self): + """ + Initialize all IDA plugin actions. + """ + + # initialize new actions provided by this plugin + for action in PLUGIN_ACTIONS: + + # load and register an icon for our action if one is defined + if action.ICON: + icon_path = plugin_resource(action.ICON) + icon_id = ida_kernwin.load_custom_icon(icon_path) + else: + icon_id = -1 + + # instantiate an action description to register with IDA + desc = ida_kernwin.action_desc_t( + action.NAME, + action.TEXT, + action(self), + action.HOTKEY, + action.TOOLTIP, + icon_id + ) + + if not ida_kernwin.register_action(desc): + print("Failed to register action '%s'" % action.NAME) + + # inject plugin's NOP action into IDA's edit submenu + ida_kernwin.attach_action_to_menu("Edit/Patch program/Change byte...", "patching:nop", ida_kernwin.SETMENU_INS) + + # supersede IDA's default "Assemble" action with our own + ida_kernwin.update_action_state("Assemble", ida_kernwin.AST_DISABLE_ALWAYS) + ida_kernwin.update_action_visibility("Assemble", False) + ida_kernwin.attach_action_to_menu("Edit/Patch program/Change word...", "patching:assemble", ida_kernwin.SETMENU_APP) + + # supersede IDA's default "Apply patches" action with our own + ida_kernwin.update_action_state("ApplyPatches", ida_kernwin.AST_DISABLE_ALWAYS) + ida_kernwin.update_action_visibility("ApplyPatches", False) + ida_kernwin.attach_action_to_menu("Edit/Patch program/Patched bytes...", "patching:apply", ida_kernwin.SETMENU_APP) + + def _unregister_actions(self): + """ + Remove all plugin actions registered with IDA. + """ + for action in PLUGIN_ACTIONS: + + # fetch icon ID before we unregister the current action + valid_id, icon_id = ida_kernwin.get_action_icon(action.NAME) + + # unregister the action from IDA + if not ida_kernwin.unregister_action(action.NAME): + print("Failed to unregister action '%s'" % action.NAME) + + # delete the icon now that the action should no longer be using it + if valid_id: + ida_kernwin.free_custom_icon(icon_id) + + # restore IDA actions that we had overridden + ida_kernwin.update_action_state("Assemble", ida_kernwin.AST_ENABLE) + ida_kernwin.update_action_visibility("Assemble", True) + ida_kernwin.update_action_state("ApplyPatches", ida_kernwin.AST_ENABLE) + ida_kernwin.update_action_visibility("ApplyPatches", True) + + def _run_cli_options(self): + """ + Run plugin actions based on command line flags (DEV). + """ + options = ida_loader.get_plugin_options('Patching') + if not options: + return + + # run the 'assemble_all' test with CLI flag -OPatching:assemble + for option in options.split(':'): + if option == 'assemble': + self.assemble_all() + + #-------------------------------------------------------------------------- + # Plugin API + #-------------------------------------------------------------------------- + + def is_byte_patched(self, ea): + """ + Return True if the byte at the given address has been patched. + """ + return self.is_range_patched(ea, ea+1) + + def is_item_patched(self, ea): + """ + Return True if a patch exists within the item at the given address. + """ + item_size = ida_bytes.get_item_size(ea) + return self.is_range_patched(ea, ea+item_size) + + def is_range_patched(self, start_ea, end_ea): + """ + Return True if a patch exists within the given address range. + """ + if start_ea == (end_ea + 1): + return start_ea in self.patched_addresses + return bool(self.patched_addresses & set(range(start_ea, end_ea))) + + def get_patch_at(self, ea): + """ + Return information about a patch at the given address. + + On success, returns (True, start_ea, patch_size) for the patch. + """ + if not self.is_item_patched(ea): + return (False, ida_idaapi.BADADDR, 0) + + # + # NOTE: this code seems 'overly complicated' because it tries to group + # visually contiguous items that appear as 'one' patched region in + # IDA, even if not all of the bytes within each item were changed. + # + # TODO/Hex-Rays: this kind of logic/API is probably something that + # should be moved in-box as part of a 'patch metadata' overhaul + # + + if ida_bytes.is_unknown(ida_bytes.get_flags(ea)): + forward_ea = ea + reverse_ea = ea - 1 + else: + forward_ea = ida_bytes.get_item_head(ea) + reverse_ea = ida_bytes.prev_head(forward_ea, 0) + + # scan forwards for the 'end' of the patched region + while forward_ea != ida_idaapi.BADADDR: + item_size = ida_bytes.get_item_size(forward_ea) + item_addresses = set(range(forward_ea, forward_ea + item_size)) + forward_ea = forward_ea + item_size + if not (item_addresses & self.patched_addresses): + forward_ea -= item_size + break + + # scan backwards for the 'start' of the patched region + while reverse_ea != ida_idaapi.BADADDR: + item_size = ida_bytes.get_item_size(reverse_ea) + item_addresses = set(range(reverse_ea, reverse_ea + item_size)) + if not (item_addresses & self.patched_addresses): + reverse_ea += item_size # revert to last 'hit' item + break + reverse_ea -= item_size + + # info about the discovered patch + start_ea = reverse_ea + end_ea = forward_ea + length = forward_ea - reverse_ea + #print("Found patch! 0x%08X --> 0x%08X (%u bytes)" % (start_ea, end_ea, length)) + + return (True, start_ea, length) + + def assemble(self, assembly, ea): + """ + Assemble and return bytes for the given assembly text. + """ + return self.assembler.asm(assembly, ea) + + def nop_item(self, ea): + """ + NOP the item at the given address. + """ + nop_size = ida_bytes.get_item_size(ea) + return self.nop_range(ea, ea+nop_size) + + def nop_range(self, start_ea, end_ea): + """ + NOP all of the bytes within the given address range. + """ + if start_ea == end_ea: + return False + + # generate a buffer of NOP data hinted at by the existing database / instructions + nop_buffer = self.assembler.nop_buffer(start_ea, end_ea) + + # patch the specified region with NOP bytes + self.patch(start_ea, nop_buffer, fill_nop=False) + return True + + def revert_patch(self, ea): + """ + Revert all the modified bytes within a patch at the given address. + """ + found, start_ea, length = self.get_patch_at(ea) + if not found: + return False + self.revert_range(start_ea, start_ea+length) + return True + + def revert_range(self, start_ea, end_ea): + """ + Revert all the modified bytes within the given address range. + """ + + # revert bytes to their original value within the target region + for ea in range(start_ea, end_ea): + ida_bytes.revert_byte(ea) + + # 'undefine' the reverted bytes (helps with re-analysis) + length = end_ea - start_ea + ida_bytes.del_items(start_ea, ida_bytes.DELIT_KEEPFUNC, length) + + # + # if the reverted patch seems to be in a code-ish area, we tell the + # auto-analyzer to try and analyze it as code + # + + if ida_bytes.is_code(ida_bytes.get_flags(ida_bytes.prev_head(start_ea, 0))): + ida_auto.auto_mark_range(start_ea, end_ea, ida_auto.AU_CODE) + + # attempt to re-analyze the reverted region + ida_auto.plan_and_wait(start_ea, end_ea, True) + + # + # having just reverted the bytes to their original values on the IDA + # side of things, we now have to ensure these addresses are no longer + # tracked by our plugin as 'patched' + # + + self.patched_addresses -= set(range(start_ea, end_ea)) + ida_kernwin.execute_sync(self._notify_patches_changed, ida_kernwin.MFF_NOWAIT|ida_kernwin.MFF_WRITE) + return True + + def force_jump(self, ea): + """ + Force a conditional jump to be unconditional at the given address. + """ + mnemonic = ida_ua.print_insn_mnem(ea) + + # if the given address is not a conditional jump, ignore the request + if not self.assembler.is_conditional_jump(mnemonic): + return False + + # fetch the target address + target = next(idautils.CodeRefsFrom(ea, False)) + + # assemble an unconditional jump with the same jump target + patch_code = "%s 0x%X" % (self.assembler.UNCONDITIONAL_JUMP, target) + patch_data = self.assembler.asm(patch_code, ea) + + # write the unconditional jump patch to the database + self.patch(ea, patch_data) + return True + + def patch(self, ea, patch_data, fill_nop=True): + """ + Write patch data / bytes to a given address. + """ + patch_size = len(patch_data) + + # incoming patch matches existing data, nothing to do + original_data = ida_bytes.get_bytes(ea, patch_size) + if original_data == patch_data: + return + + next_address = ea + patch_size + inst_start = ida_bytes.get_item_head(next_address) + if ida_bytes.is_code(ida_bytes.get_flags(inst_start)): + + # if the patch clobbers part of an instruction, fill it with NOP + if inst_start < next_address: + inst_size = ida_bytes.get_item_size(inst_start) + fill_size = (inst_start + inst_size) - next_address + self.nop_range(next_address, next_address+fill_size) + ida_auto.auto_make_code(next_address) + + # write the actual patch data to the database + ida_bytes.patch_bytes(ea, patch_data) + + # + # record the region of patched addresses + # + + addresses = set(range(ea, ea+patch_size)) + if is_range_patched(ea, ea+patch_size): + self.patched_addresses |= addresses + + # + # according to IDA, none of the 'patched' addresses in the database + # actually have a different value... so they technically were not + # patched (eg. maybe they were patched back to their ORIGINAL value!) + # + # in this case it means the patching plugin shouldn't see these + # addresses as patched, either... + # + + else: + self.patched_addresses -= addresses + + # request re-analysis of the patched range + ida_auto.auto_mark_range(ea, ea+patch_size, ida_auto.AU_USED) + ida_kernwin.execute_sync(self._notify_patches_changed, ida_kernwin.MFF_NOWAIT|ida_kernwin.MFF_WRITE) + + def apply_patches(self, target_filepath, clean=False): + """ + Apply the current patches to the given filepath. + """ + self.__saved_successfully = False + + # + # ensure that a 'clean' source executable exists for this operation, + # and then write (or overwrite) the target filepath with the clean + # file so that we can apply patches to it from a known-good state + # + + if clean: + self.backup_filepath = self._ensure_clean_backup(target_filepath) + + # + # due to the variety of errors that may occur from trying to copy + # a file, we simply trap them all to a more descriptive issue for + # what action failed in the context of our patching attempt + # + + try: + shutil.copyfile(self.backup_filepath, target_filepath) + except Exception: + raise PatchTargetError("Failed to overwrite patch target with a clean executable", target_filepath) + + # + # attempt to apply the patches to the target filepath + # + # NOTE: this 'Exception' catch-all is probably a bit too liberal, + # instead we should probably have apply_patches(...) raise a generic + # error if opening the target file for writing fails, leaving any + # other (unexpected!) patching exceptions uncaught + # + + try: + apply_patches(target_filepath) + except Exception: + raise PatchApplicationError("Failed to write patches into the target file", target_filepath) + + # patching seems successful? update the stored filepath to the patched binary + self.patched_filepath = target_filepath + + # + # if we made it this far, we assume the file on disk was patched + # setting __saved_successfully ensures that we start showing the + # 'quick apply' right click context menu going forward + # + # this is to help cut down on crowding the right click menu only + # until the user explicitly starts using the patching plugin, but + # also applying their patches to a a binary + # + + if self.prefer_quick_apply: + self.__saved_successfully = True + + def quick_apply(self): + """ + Apply the current patches using the last-known settings. + """ + + try: + self.apply_patches(self.patched_filepath, self.prefer_patch_cleanly) + except Exception as e: + return (False, e) + + return (True, None) + + #-------------------------------------------------------------------------- + # Plugin Internals + #-------------------------------------------------------------------------- + + def _ensure_clean_backup(self, target_filepath): + """ + Return True if a clean executable matching the open IDB is available on disk. + """ + + # + # TODO: what do we do if one/both of these are invalid or blank? + # such as a blank or tmp IDB? what do they return in this case? + # + + input_md5 = ida_nalt.retrieve_input_file_md5() + input_filepath = ida_nalt.get_input_file_path() + + # + # we will search this list of filepaths for an executable / source + # file that matches the reported hash of the file used to generate + # this IDA database + # + + filepaths = [target_filepath, self.backup_filepath, input_filepath] + filepaths = list(filter(None, filepaths)) + + # search the list of filepaths for a clean file + while filepaths: + + # get the next filepath to evaluate + filepath = filepaths.pop(0) + + # + # if the given filepath does not end with a '.bak', push a version + # of the current filepath with that extension to make for a more + # comprehensive search of a clean backup file + # + # we insert this at the front of the list because it should be + # searched next (the list is kind of ordered by relevance already) + # + + if not filepath.endswith('.bak'): + filepaths.insert(0, filepath + '.bak') + + # + # attempt to read (and then hash) each file that is being + # considered as a possible source for our clean backup + # + + try: + disk_data = open(filepath, 'rb').read() + except Exception as e: + #print(" - Failed to read '%s' -- Reason: %s" % (filepath, str(e))) + continue + + disk_md5 = hashlib.md5(disk_data).digest() + + # + # MD5 of the tested file does not match the ORIGINAL (clean) file + # so we simply ignore it cuz it is useless for our purposes + # + + if disk_md5 != input_md5: + #print(" - MD5: '%s' -- does not match IDB (probably previously patched)" % filepath) + continue + + # + # the MD5 matches between the original executable hash provided by + # IDA and a hashed file on disk. use this as the source filepath + # for our dialog + # + + clean_filepath = filepath + #print(" - Found unpatched binary! '%s'" % filepath) + break + + # + # if we did not break from the loop above, that means we could not + # find an executable with a hash that is deemed valid to cleanly + # patch from, so there is nothing else we can do + # + + else: + raise PatchBackupError("Failed to locate a clean executable") + + # + # we have verified that a clean version of the executable matching + # this database exists on-disk. + # + # in the case below, the clean file (presumably a '.bak' file that + # was previously created) is not at risk of getting overwritten as + # target_filepath is where the resulting / patched binary is going + # to be written by the ongoing save action + # + # nothing else to do but return success + # + + if clean_filepath != target_filepath: + return clean_filepath + + # + # if the clean filepath does not match the target (output) path, we + # make a copy of the file and add a '.bak' extension to it as we don't + # want to overwrite potentially the only clean copy of the file + # + # in this case, the user is probably patching foo.exe for the first + # time, so we are going to be creating foo.exe.bak here + # + + clean_filepath += '.bak' + + # + # before attempting to make a clean file backup, we can try checking + # the hash of the existing file (if there is one) ... + # + # if the hash matches what we expect of the clean backup, then the + # file appears to be readable and sufficient to use as a backup as-is + # + + try: + clean_md5 = hashlib.md5(open(clean_filepath, 'rb').read()).digest() + if clean_md5 == input_md5: + return clean_filepath + + # + # failed to read/hash file? maybe it doesn't exist... or it's not + # readable/writable (locked?) in which case the next action will + # fail and throw the necessary exception for us instead + # + + except: + pass + + # + # finally, attempt to make the backup of our patch target, as it + # doesn't seem to exist yet (... or we can't seem to read the file, + # in which case we're trying a last ditch attempt at overwriting it) + # + + try: + shutil.copyfile(target_filepath, clean_filepath) + + # + # if we failed to write (overwrite?) the desired file for our clean + # backup, then we cannot ensure that a clean backup exists + # + + except Exception as e: + raise PatchBackupError("Failed to write backup executable", clean_filepath) + + # all done + return clean_filepath + + def _refresh_patches(self): + """ + Refresh the list of patched addresses directly from the database. + """ + addresses = set() + + def visitor(ea, file_offset, original_value, patched_value): + addresses.add(ea) + return 0 + + ida_bytes.visit_patched_bytes(0, ida_idaapi.BADADDR, visitor) + self.patched_addresses = addresses + ida_kernwin.execute_sync(self._notify_patches_changed, ida_kernwin.MFF_NOWAIT|ida_kernwin.MFF_WRITE) + + #-------------------------------------------------------------------------- + # Plugin Events + #-------------------------------------------------------------------------- + + def patches_changed(self, callback): + """ + Subscribe a callback for patch change events. + """ + register_callback(self._patches_changed_callbacks, callback) + + def _notify_patches_changed(self): + """ + Notify listeners that the patches changed. + """ + + # + # this function is supposed to notify the plugin components (such as + # UI) that they should refresh because their data may be stale. + # + # currently, the plugin calls this function via async (MFF_FAST) + # callbacks queued with execute_sync(). + # + # the reason we do this is because we need to give IDA some time to + # process pending actions/events/analysis/ui (etc.) after patching + # or reverting bytes. + # + # if we don't execute 'later' (MFF_FAST), some things like generating + # disassembly text for a patched instruction may be ... wrong or + # incomplete (eg ) + # + + notify_callback(self._patches_changed_callbacks) + + # for execute_sync(...) + return 1 + + #-------------------------------------------------------------------------- + # IDA Events + #-------------------------------------------------------------------------- + + def _populating_widget_popup(self, widget, popup, ctx): + """ + IDA is populating the context menu for a widget. + """ + is_idaview = False + + # IDA disassembly view + if ida_kernwin.get_widget_type(widget) == ida_kernwin.BWN_DISASM: + is_idaview = True + + # custom / interactive patching view + elif ida_kernwin.get_widget_title(widget) == 'PatchingCodeViewer': + pass + + # other IDA views that we don't care to inject actions into + else: + return + + # fetch the 'right clicked' instruction address + clicked_ea = get_current_ea(ctx) + + # + # check if the user has 'selected' any amount of text in the widget. + # + # it is important we use this method/API so that we can best position + # our patching actions within the right click context menu (by + # predicting what else will be visible in the menu). + # + + p0, p1 = ida_kernwin.twinpos_t(), ida_kernwin.twinpos_t() + range_selected = ida_kernwin.read_selection(widget, p0, p1) + + valid_ea, start_ea, end_ea = read_range_selection(ctx) + if not valid_ea: + start_ea = clicked_ea + + # determine if the user selection or right click covers a patch + if (range_selected and valid_ea): + #print("User range: 0x%08X --> 0x%08X" % (start_ea, end_ea)) + show_revert = self.is_range_patched(start_ea, end_ea) + else: + #print("User click: 0x%08X" % clicked_ea) + show_revert = self.is_item_patched(clicked_ea) + + # determine if the user right clicked code + is_code = ida_bytes.is_code(ida_bytes.get_flags(clicked_ea)) + + # + # attempt to 'pin' the patching actions towards the top of the right + # click context menu. we do this by 'appending' our 'NOP' action after + # a built-in action that we expect to be near the top of the menu. + # + # NOTE: IDA shows 'different' commands based on the context and state + # during the right click. that is why we try to aggressively identify + # what will be in the right click menu so that we can consistently + # pin our actions in the desired location + # + + if range_selected: + + if ida_segment.segtype(start_ea) == ida_segment.SEG_CODE: + ida_kernwin.attach_action_to_popup(widget, popup, NopAction.NAME, "Analyze selected area", ida_kernwin.SETMENU_APP) + else: + ida_kernwin.attach_action_to_popup(widget, popup, NopAction.NAME, "Abort selection", ida_kernwin.SETMENU_APP) + + # + # TODO: lol there's probably a better way to do this, but I'm + # writing this fix a little bit late. we basically are trying to + # check if the user has a visual selection spanning multiple lines + # + # if multiple lines are selected, we don't want to show the + # 'Assemble' command. as it is unlikely that the user right + # right clicking a selected range to explicitly assemble + # + # that said, if the user only selected a few chars on the SAME + # line it may have been an unintentional 'range selection' in + # in which case we DO want to show 'Assemble' + # + + p0s = p0.place_as_simpleline_place_t() + p1s = p1.place_as_simpleline_place_t() + multi_line_selection = p0s.n != p1s.n + + else: + ida_kernwin.attach_action_to_popup(widget, popup, NopAction.NAME, "Rename", ida_kernwin.SETMENU_APP) + multi_line_selection = False + + # + # PREV_ACTION will hold the 'most recent' action we appended to the + # menu. this is done to simplify the remaining code while appending + # our subsequent patching actions. + # + + PREV_ACTION = NopAction.TEXT + + # if the user right clicked a single instruction... + if is_code and not (range_selected and multi_line_selection): + + # inject the 'assemble' action (but not in the patching dialog) + if is_idaview: + ida_kernwin.attach_action_to_popup(widget, popup, AssembleAction.NAME, PREV_ACTION, ida_kernwin.SETMENU_APP) + PREV_ACTION = AssembleAction.TEXT + + # inject the 'force jump' action if a conditional jump was right clicked + mnemonic = ida_ua.print_insn_mnem(clicked_ea) + if self.assembler.is_conditional_jump(mnemonic): + ida_kernwin.attach_action_to_popup(widget, popup, ForceJumpAction.NAME, PREV_ACTION, ida_kernwin.SETMENU_APP) + PREV_ACTION = ForceJumpAction.TEXT + + # if the user selected some patched bytes, show the 'revert' action + if show_revert: + ida_kernwin.attach_action_to_popup(widget, popup, RevertAction.NAME, PREV_ACTION, ida_kernwin.SETMENU_APP) + PREV_ACTION = RevertAction.TEXT + + # + # if the user has 'saved' patches at any point this session, we should + # show them the quick save option as they are likely going to save + # patches again at some point... + # + + if self.__saved_successfully: + ida_kernwin.attach_action_to_popup(widget, popup, QuickApplyAction.NAME, PREV_ACTION, ida_kernwin.SETMENU_APP) + PREV_ACTION = QuickApplyAction.TEXT + + # + # TODO/Hex-Rays: is there no way to define/append a submenu with my + # action group??? I want to put 'Patching --> ...' after my last action + # and not at the *very end* of the right click menu... + # + # e.g. +---------------------+ + # | Rename... | + # |---------------------+ + # | NOP | + # | Assemble... | + # | Patching --------------->-+-----------------+ + # +---------------------+ | Change bytes... | + # | Jump to operand | | ... | + # | Jump in a new ... | ' ' + # | ... | + # + # for now, we use the following 'HACK' API to create a submenu at the + # preferred location in the right click context menu + # + + self._patching_submenu = attach_submenu_to_popup(popup, "Patching", PREV_ACTION) + + # extended list of 'less common' actions saved under a patching submenu + ida_kernwin.attach_action_to_popup(widget, popup, "PatchByte", "Patching/") + ida_kernwin.attach_action_to_popup(widget, popup, "PatchedBytes", "Patching/") + ida_kernwin.attach_action_to_popup(widget, popup, ApplyAction.NAME, "Patching/") + + # insert start spacer before / after our action group + ida_kernwin.attach_action_to_popup(widget, popup, "-", NopAction.TEXT, ida_kernwin.SETMENU_INS) + ida_kernwin.attach_action_to_popup(widget, popup, "-", "Patching/", ida_kernwin.SETMENU_APP) + + def _highlight_lines(self, out, widget, rin): + """ + IDA is drawing disassembly lines and requesting highlighting info. + """ + + # ignore line highlight events that are not for a disassembly view + if ida_kernwin.get_widget_type(widget) != ida_kernwin.BWN_DISASM: + return + + # highlight lines/addresses that have been patched by the user + for section_lines in rin.sections_lines: + for line in section_lines: + line_ea = line.at.toea() + item_len = ida_bytes.get_item_size(line_ea) + + # explode a line / instruction into individual addresses + line_addresses = set(range(line_ea, line_ea+item_len)) + + # if no patched bytes correspond to this line / instruction + if not(line_addresses & self.patched_addresses): + continue + + # highlight the line if it is patched in some way + e = ida_kernwin.line_rendering_output_entry_t(line) + e.bg_color = ida_kernwin.CK_EXTRA2 + e.flags = ida_kernwin.LROEF_FULL_LINE + + # save the highlight to the output line highlight list + out.entries.push_back(e) + + def _ida_undo_occurred(self, action_name, is_undo): + """ + IDA completed an Undo / Redo action. + """ + + # + # if the user happens to use IDA's native UNDO or REDO functionality + # we will completely discard our tracked set of patched addresses and + # query IDA for the true, current set of patches + # + + self._refresh_patches() + return 0 + + #-------------------------------------------------------------------------- + # Temp / DEV / Tests + #-------------------------------------------------------------------------- + + # + # HACKER'S SECRET + # + # this section is purely for testing / development / profiling. it may be + # messy, out of place, transient, incomplete, broken, unsupported etc. + # + # if you want to hack on this plugin or are trying to edit / dev on the + # codebase, you can quickly 'reload' the plugin without actually having + # to restart IDA to test your changes in *most* cases. + # + # in the IDA console, you can use: + # + # patching.reload() + # + # additionally, you can call into parts of the loaded plugin instance + # from the IDA console for testing certain parts: + # + # patching.core.nop_item(here()) + # + # finally, to 'test' assembling all of the instructions in your IDB (to + # try and identify assembly issues or unsupported instructions) you can + # run the following command: + # + # patching.core.assemble_all() + # + # this may be slow and take several minutes (sometimes much longer) to + # run depending on the size of the IDB + # + + def profile(self): + """ + Profile assemble_all(...) to + + NOTE: you should probably only call this in 'small' databases. + """ + import pprofile + prof = pprofile.Profile() + with prof(): + self.assemble_all() + prof.print_stats() + + def parse_all(self): + for ea in all_instruction_addresses(0): + ida_auto.show_addr(ea) + comps = get_disassembly_components(ea) + if comps[0]: + print("%08X: %s" % (ea, str(comps))) + + def assemble_all(self): + """ + Attempt to re-assemble every instruction in the IDB, byte-for-byte. + + TODO: build out some actual dedicated tests + """ + import time, datetime + start_time = time.time() + start = 0 + + headless = ida_kernwin.cvar.batch + + # the number of correctly re-assembled instructions + good = 0 + total = 0 + fallback = 0 + unsupported = 0 + unsupported_map = collections.defaultdict(int) + + slow_limit = -1 + asm_threshold = 0.1 + + # track failures + fail_addrs = collections.defaultdict(list) + fail_bytes = collections.defaultdict(set) + alternates = set() + + for ea in all_instruction_addresses(start): + + # update the navbar cursor based on progress (only when in UI) + if not headless: + ida_auto.show_addr(ea) + + # + # skip some instructions to cut down on noise (lots of noise / + # false positives with NOP) + # + + mnemonic = ida_ua.print_insn_mnem(ea) + + # probably undefined data in code / can't be disas / bad instructions + if not mnemonic: + continue + + mnemonic = mnemonic.upper() + + # ignore instructions that can decode a wild number of ways + if mnemonic in ['NOP', 'XCHG']: + continue + + # keep track of how many instructions we care to 'assemble' + total += 1 + + # ignore instructions that simply aren't supported yet + if mnemonic in self.assembler.UNSUPPORTED_MNEMONICS: + unsupported += 1 + unsupported_map[mnemonic] += 1 + continue + + # fetch raw info about the instruction + disas_raw = self.assembler.format_assembly(ea) + disas_size = ida_bytes.get_item_size(ea) + disas_bytes = ida_bytes.get_bytes(ea, disas_size) + + #print("0x%08X: ASSEMBLING '%s'" % (ea, disas_raw)) + start_asm = time.time() + asm_bytes = self.assembler.asm(disas_raw, ea) + end_asm = time.time() + asm_time = end_asm - start_asm + + if asm_time > asm_threshold: + print("%08X: SLOW %0.2fs - %s" % (ea, asm_time, disas_raw)) + slow_limit -= 1 + if slow_limit == 0: + break + + # assembled vs expected + byte_tuple = (asm_bytes, disas_bytes) + + # assembled bytes match what is in the database + if asm_bytes == disas_bytes or byte_tuple in alternates: + good += 1 + continue + + asm_bytes = self.assembler.asm(disas_raw, ea) + + byte_tuple = (asm_bytes, disas_bytes) + + # assembled bytes match what is in the database + if asm_bytes == disas_bytes or byte_tuple in alternates: + good += 1 + fallback += 1 + continue + + known_text = disas_raw in fail_addrs + known_bytes = byte_tuple in fail_bytes[disas_raw] + + if not known_bytes and len(asm_bytes): + + # the assembled patch is the same size, or smaller than the og + if len(asm_bytes) <= len(disas_bytes): + ida_before = ida_lines.tag_remove(ida_lines.generate_disasm_line(ea)) + ida_after = disassemble_bytes(asm_bytes, ea) + + ida_after = ida_after.split(';')[0] + ida_after = ida_after.replace(' short ', ' ') + ida_before = ida_before.split(';')[0] + + okay = False + if ida_after == ida_before: + okay = True + + # + # BEFORE: 'add [rax+rax+0], ch' + # AFTER: 'add [rax+rax], ch + # 0x18004830B: NEW FAILURE 'add [rax+rax+0], ch' + # - IDA: 00 6C 00 00 + # - ASM: 00 2C 00 + # + + elif ida_before.replace('+0]', ']') == ida_after: + okay = True + + elif '$+5' in ida_before: + okay = True + + if okay: + alternates.add(byte_tuple) + good += 1 + continue + + print("BEFORE: '%s'\n AFTER: '%s" % (ida_before, ida_after)) + + fail_addrs[disas_raw].append(ea) + fail_bytes[disas_raw].add(byte_tuple) + + if known_text and known_bytes: + continue + + if not known_text: + print("0x%08X: NEW FAILURE '%s'" % (ea, disas_raw)) + else: + print("0x%08X: NEW BYTES '%s'" % (ea, disas_raw)) + + disas_hex = ' '.join(['%02X' % x for x in disas_bytes]) + asm_hex = ' '.join(['%02X' % x for x in asm_bytes]) + print(" - IDA: %s\n - ASM: %s" % (disas_hex, asm_hex)) + #break + + print("-"*50) + print("RESULTS") + print("-"*50) + + for disas_raw in sorted(fail_addrs, key=lambda k: len(fail_addrs[k]), reverse=True): + print("%-5u Fails -- %-40s -- (%u unique patterns)" % (len(fail_addrs[disas_raw]), disas_raw, len(fail_bytes[disas_raw]))) + + if False: + + print("-"*50) + print("ALTERNATE MAPPINGS") + print("-"*50) + + for x, y in alternates: + print('%-20s\t%s' % (' '.join(['%02X' % z for z in x]), ' '.join(['%02X' % z for z in y]))) + + if unsupported_map: + + print("-"*50) + print("(KNOWN) Unsupported Mnemonics") + print("-"*50) + + for mnem, hits in unsupported_map.items(): + print(" - %s - hits %u" % (mnem.ljust(10), hits)) + + if total: + percent = str((good/total)*100) + else: + percent = "100.0" + + percent_truncated = percent[:percent.index('.')+3] # truncate! don't round this float... + + inf = ida_idaapi.get_inf_structure() + arch_name = inf.procname.lower() + + total_failed = total - good + unknown_fails = total_failed - unsupported + print("-"*50) + print(" - Success Rate {percent}% -- {good:,} / {total:,} ({fallback:,} fallbacks, {total_failed:,} failed ({unsupported:,} were unsupported mnem, {unknown_fails:,} were unknown)) -- arch '{arch_name}' -- file '{input_path}'".format( + percent=percent_truncated.rjust(6, ' '), + good=good, + total=total, + fallback=fallback, + total_failed=total_failed, + unsupported=unsupported, + unknown_fails=unknown_fails, + arch_name=arch_name, + input_path=ida_nalt.get_input_file_path() + ) + ) + + total_time = int(time.time() - start_time) + print(" - Took %s %s..." % (datetime.timedelta(seconds=total_time), 'minutes' if total_time >= 60 else 'seconds')) diff --git a/plugins/patching/exceptions.py b/plugins/patching/exceptions.py new file mode 100644 index 0000000..065b384 --- /dev/null +++ b/plugins/patching/exceptions.py @@ -0,0 +1,23 @@ + +#------------------------------------------------------------------------------ +# Exception Definitions +#------------------------------------------------------------------------------ + +class PatchingError(Exception): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + +class PatchBackupError(PatchingError): + def __init__(self, message, filepath=''): + super().__init__(message) + self.filepath = filepath + +class PatchTargetError(PatchingError): + def __init__(self, message, filepath): + super().__init__(message) + self.filepath = filepath + +class PatchApplicationError(PatchingError): + def __init__(self, message, filepath): + super().__init__(message) + self.filepath = filepath \ No newline at end of file diff --git a/plugins/patching/keystone/README.md b/plugins/patching/keystone/README.md new file mode 100644 index 0000000..1dba3e8 --- /dev/null +++ b/plugins/patching/keystone/README.md @@ -0,0 +1,11 @@ +# Keystone Engine (Patching) + +This IDA plugin is currently self-shipping a [fork](https://github.com/gaasedelen/keystone) of the ubiquitous [Keystone Engine](https://github.com/keystone-engine/keystone) rather than using the PyPI version. + +This is simply out of convenience for distributing fixes or making breaking changes for the betterment of the plugin. + +# Why is this folder empty? + +The directory that you're reading this in will be populated by a GitHub [Workflow](https://github.com/gaasedelen/patching/blob/main/.github/workflows/package-plugin.yaml) that packages the plugin for distribution. + +You should always download the final, distributable version of the plugin from the [releases](https://github.com/gaasedelen/patching/releases) page of the plugin repo. If you cloned the repo and tried to manually install the plugin, that's probably why it's not working and you're here reading this ;-) \ No newline at end of file diff --git a/plugins/patching/ui/preview.py b/plugins/patching/ui/preview.py new file mode 100644 index 0000000..d84eff8 --- /dev/null +++ b/plugins/patching/ui/preview.py @@ -0,0 +1,364 @@ +import ida_nalt +import ida_name +import ida_bytes +import ida_lines +import ida_idaapi +import ida_kernwin + +from patching.util.qt import QT_AVAILABLE +from patching.util.ida import parse_disassembly_components, scrape_symbols +from patching.util.python import hexdump + +if QT_AVAILABLE: + from patching.ui.preview_ui import PatchingDockable + +LAST_LINE_IDX = -1 + +class PatchingController(object): + """ + The backing logic & model (data) for the patch editing UI. + """ + WINDOW_TITLE = "Patching" + + def __init__(self, core, ea=ida_idaapi.BADADDR): + self.core = core + self.view = None + + # + # if no context (an address to patch at) was provided, use IDA's + # current cursor position instead as the origin for the dialog + # + + if ea == ida_idaapi.BADADDR: + ea = ida_kernwin.get_screen_ea() + + self._address_origin = ida_bytes.get_item_head(ea) + + # public properties + self.address = self._address_origin + self.address_idx = LAST_LINE_IDX + self.assembly_text = '' + self.assembly_bytes = b'' + + # for error text or other dynamic information to convey to the user + self.status_message = '' + + # do an initial 'refresh' to populate data for the patching dialog + self.refresh() + + # connect signals from the plugin core to the patching dialog + self.core.patches_changed(self.refresh) + + # only create the UI for the patching dialog as needed + if QT_AVAILABLE: + self.view = PatchingDockable(self) + self.view.Show() + + #------------------------------------------------------------------------- + # Actions + #------------------------------------------------------------------------- + + def select_address(self, ea, idx=LAST_LINE_IDX): + """ + Select the given address. + """ + insn, lineno = self.get_insn_lineno(ea) + + # if the target instruction does not exist + if insn.address != ea: + idx = LAST_LINE_IDX + + # + # clear all clobber highlights if the cursor is moving to a new line + # + # TODO/NOTE: this feels a bit dirty / out of place. there is probably + # a place for it that is more appropriate + # + + if insn.address != self.address or self.address_idx != idx: + for insn_cur in self.instructions: + insn_cur.clobbered = False + + self.address = insn.address + self.address_idx = idx + + self._update_assembly_text(self.core.assembler.format_assembly(insn.address)) + + if self.view: + self.view.refresh_fields() + self.view.refresh_cursor() + + def edit_assembly(self, assembly_text): + """ + Edit the assembly text. + """ + self._update_assembly_text(assembly_text) + + # refresh visible fields, as the assembled bytes may have changed + if self.view: + self.view.refresh_fields() + + # fetch the displayed instruction that the user is 'editing' + current_insn = self.get_insn(self.address) + + # + # if the newly assembled instruction is smaller than the existing + # instruction, there is no need to highlight clobbers + # + + edit_index = self.instructions.index(current_insn) + clobber_end = self.address + len(self.assembly_bytes) + will_clobber = clobber_end > (current_insn.address + current_insn.size) + + # loop through the next N instructions + for next_insn in self.instructions[edit_index+1:]: + next_insn.clobbered = (next_insn.address < clobber_end) and will_clobber + + # done marking clobbered instructions, nothing else to do + if self.view: + self.view.refresh_code() + + def commit_assembly(self): + """ + Commit the current assembly. + """ + if not self.assembly_bytes: + return + + # patch the instruction at the current address + self.core.patch(self.address, self.assembly_bytes) + + # refresh lines + self._refresh_lines() + + def _update_assembly_text(self, assembly_text): + """ + Update the assembly text (and attempt to assemble it). + """ + self.assembly_text = assembly_text + self.assembly_bytes = bytes() + self.status_message = '' + + # + # before trying to assemble the user input, we'll try to check for a + # few problematic and unsupported cases before even attempting to + # assemble the given text + # + # TODO/NOTE: we should probably move this into the 'assembler' + # class and expose an error reason message/text for failures + # + + _, mnemonic, operands = parse_disassembly_components(assembly_text) + + # + # if it looks like the user is trying to assemble an instruction that + # we KNOW Keystone does not support for whatever reason, we should + # give them a heads up instead of an 'unspecified error' (...) + # + + if mnemonic.upper() in self.core.assembler.UNSUPPORTED_MNEMONICS: + self.status_message = "Keystone does not support this instruction (%s)" % mnemonic + return + + # + # in the odd event that a user pastes a massive blob of random text + # into the the assembly field by accident, the plugin could 'hang' + # IDA in an attempt to resolve a bunch of words as 'symbols' while + # assembling the 'text' -- which is not what wen want + # + + if len(scrape_symbols(operands)) > 10: + self.status_message = "Too many potential symbols in the assembly text" + return + + # + # TODO/XXX/KEYSTONE: 11th hour hack, but Keystone will HANG if the + # user tries to assemble the following inputs: + # + # .string ' + # .string " + # + # so we're just going to try and block those until we can fix it + # in Keystone proper :-X + # + + assembly_normalized = assembly_text.strip().lower() + + if assembly_normalized.startswith('.string'): + self.status_message = "Unsupported declaration (.string can hang Keystone)" + return + + # + # TODO: in v0.2.0 we should try to to re-enable multi-instruction + # inputs. the only reason it is 'disabled' for now is that I need more + # time to better define its behavior in the context of the plugin + # + # NOTE: Keystone supports 'xor eax, eax; ret;' just fine, it's purely + # ensuring the rest of this plugin / wrapping layers are going to + # handle it okay + # + + if ';' in assembly_normalized: + self.status_message = "Multi-instruction input not yet supported (';' not allowed)" + return + + # + # we didn't catch any 'early' issues with the user input, go ahead + # and try to assemble it to see what happens + # + + self.assembly_bytes = self.core.assemble(self.assembly_text, self.address) + if not self.assembly_bytes: + self.status_message = '...' # error assembling + + #------------------------------------------------------------------------- + # Misc + #------------------------------------------------------------------------- + + def refresh(self): + """ + Refresh the controller state based on the current IDA state. + """ + self._refresh_lines() + self.select_address(self.address) + + def _refresh_lines(self): + """ + Refresh the disassembly for the dialog based on the current IDA state. + """ + instructions, current_address = [], self._address_origin + + IMAGEBASE = ida_nalt.get_imagebase() + PREV_INSTRUCTIONS = 50 + NEXT_INSTRUCTIONS = 50 + MAX_PREVIEW_BYTES = self.core.assembler.MAX_PREVIEW_BYTES + + # rewind a little bit from the target address to create a buffer + for i in range(PREV_INSTRUCTIONS): + current_address -= ida_bytes.get_item_size(current_address) + + # generate lines for the region of instructions around the target address + for i in range(PREV_INSTRUCTIONS + NEXT_INSTRUCTIONS): + try: + line = InstructionLine(current_address, MAX_PREVIEW_BYTES) + except ValueError: + current_address += 1 + continue + current_address += line.size + instructions.append(line) + + self.instructions = instructions + + if self.view: + self.view.refresh_code() + + def get_insn(self, ea): + """ + Return the instruction text object for the given address. + """ + insn, _ = self.get_insn_lineno(ea) + return insn + + def get_insn_lineno(self, ea): + """ + Return the instruction text object and its line number for the given address. + """ + lineno = 0 + for insn in self.instructions: + if insn.address <= ea < insn.address + insn.size: + return (insn, lineno) + lineno += insn.num_lines + return (None, 0) + +#----------------------------------------------------------------------------- +# +#----------------------------------------------------------------------------- + +COLORED_SEP = ida_lines.COLSTR('|', ida_lines.SCOLOR_SYMBOL) + +class InstructionLine(object): + """ + A helper for drawing an instruction in a simple IDA viewer. + """ + def __init__(self, ea, max_preview=4): + + # + # NOTE/XXX: this kind of needs to be called first, otherwise + # 'get_item_size(ea)' may fetch a stale size for the instruction + # if it was *just* patched + # + + self.colored_instruction = ida_lines.generate_disasm_line(ea) + if not self.colored_instruction: + raise ValueError("Bad address... 0x%08X" % ea) + + # a label / jump target if this instruction has one + self.name = ida_name.get_short_name(ea) + + # the number of lines this instruction object will render as + self.num_lines = 1 + (2 if self.name else 0) + + # info about the instruction + self.size = ida_bytes.get_item_size(ea) + self.bytes = ida_bytes.get_bytes(ea, self.size) + self.address = ea + + # flag to tell code view to highlight line as clobbered + self.clobbered = False + + # how many instruction bytes to show before eliding + self._max_preview = max_preview + + @property + def colored_address(self): + """ + Return an IDA-colored string for the instruction address. + """ + pretty_address = ida_lines.COLSTR('%08X' % self.address, ida_lines.SCOLOR_PREFIX) + return pretty_address + + @property + def colored_bytes(self): + """ + Return an IDA-colored string for the instruction bytes. + """ + MAX_BYTES = self._max_preview + + if self.size > MAX_BYTES: + text_bytes = hexdump(self.bytes[:MAX_BYTES-1]).ljust(3*MAX_BYTES-1, '.') + else: + text_bytes = hexdump(self.bytes).ljust(3*MAX_BYTES-1, ' ') + + pretty_bytes = ida_lines.COLSTR(text_bytes, ida_lines.SCOLOR_BINPREF) + return pretty_bytes + + @property + def line_blank(self): + """ + Return an IDA-colored string for a blank line at this address. + """ + byte_padding = ' ' * ((self._max_preview*3) - 1) + self._line_blank = ' '.join(['', self.colored_address, COLORED_SEP, byte_padding , COLORED_SEP]) + return self._line_blank + + @property + def line_name(self): + """ + Return an IDA-colored string for the name text line (if a named address). + """ + if not self.name: + return None + + pretty_name = ida_lines.COLSTR(self.name, ida_lines.SCOLOR_CNAME) + ':' + byte_padding = ' ' * ((self._max_preview*3) - 1) + + self._line_name = ' '.join(['', self.colored_address, COLORED_SEP, byte_padding , COLORED_SEP, pretty_name]) + return self._line_name + + @property + def line_instruction(self): + """ + Return an IDA-colored string for the instruction text line. + """ + self._line_text = ' '.join(['', self.colored_address, COLORED_SEP, self.colored_bytes, COLORED_SEP + ' ', self.colored_instruction]) + return self._line_text \ No newline at end of file diff --git a/plugins/patching/ui/preview_ui.py b/plugins/patching/ui/preview_ui.py new file mode 100644 index 0000000..bfd982b --- /dev/null +++ b/plugins/patching/ui/preview_ui.py @@ -0,0 +1,567 @@ +import ida_name +import ida_kernwin + +from patching.util.qt import * +from patching.util.ida import * +from patching.util.python import hexdump + +LAST_LINE_IDX = -1 + +class PatchingDockable(ida_kernwin.PluginForm): + """ + The UI components of the Patching dialog. + """ + + def __init__(self, controller): + super().__init__() + self.controller = controller + self.count = 0 + + #-------------------------------------------------------------------------- + # IDA PluginForm Overloads + #-------------------------------------------------------------------------- + + def Show(self): + + # TODO/Hex-Rays/XXX: can't make window Floating? using plgform_show(...) instead + flags = ida_kernwin.PluginForm.WOPN_DP_FLOATING | ida_kernwin.PluginForm.WOPN_CENTERED + #super(PatchingDockable, self).Show(self.controller.WINDOW_TITLE, flags) + ida_kernwin.plgform_show(self.__clink__, self, self.controller.WINDOW_TITLE, flags) + self._center_dialog() + + # + # set the initial cursor position to focus on the target address + # + # we bump the focus location down a few lines from the top of the + # window to center the cursor a bit. + # + + self.set_cursor_pos(self.controller.address, self.controller.address_idx, 0, 6) + + # set the initial keyboard focus the editable assembly line + self._line_assembly.setFocus(QtCore.Qt.FocusReason.ActiveWindowFocusReason) + + def OnCreate(self, form): + self._twidget = form + self.widget = ida_kernwin.PluginForm.TWidgetToPyQtWidget(self._twidget) + self._ui_init() + + def OnClose(self, form): + self._edit_timer.stop() + self._edit_timer = None + self._code_view = None + self.controller.view = None + return super().OnClose(form) + + #-------------------------------------------------------------------------- + # Initialization - UI + #-------------------------------------------------------------------------- + + def _ui_init(self): + """ + Initialize UI elements. + """ + self.widget.setMinimumSize(350, 350) + + # setup a monospace font for code / text printing + self._font = QtGui.QFont("Courier New") + self._font.setStyleHint(QtGui.QFont.Monospace) + + # initialize our ui elements + self._ui_init_code() + self._ui_init_fields() + + # populate the dialog/fields with initial contents from the database + self.refresh() + + # set the code view to focus on an initial line + self._code_view.Jump(self._code_view.GetLineNo(), y=5) + + # layout the populated ui just before showing it + self._ui_layout() + + # + # NOTE: we 'defer' real-time instruction assembly (while typing) in + # the patching dialog if we think the database is 'big enough' to + # make the text input lag due to slow symbol resolution (eg. having + # to search the entire IDA 'name list' for an invalid symbol) + # + + self._edit_timer = QtCore.QTimer(self.widget) + self._edit_timer.setSingleShot(True) + self._edit_timer.timeout.connect(self._edit_stopped) + + if ida_name.get_nlist_size() > 20_000: + self._line_assembly.textEdited.connect(self._edit_started) + else: + self._line_assembly.textEdited.connect(self.controller.edit_assembly) + + # connect signals + self._line_assembly.returnPressed.connect(self._enter_pressed) + + def _ui_init_fields(self): + """ + Initialize the interactive text fields for this UI control. + """ + self._line_address = QtWidgets.QLineEdit() + self._line_address.setFont(self._font) + self._line_address.setReadOnly(True) + self._label_address = QtWidgets.QLabel("Address:") + self._label_address.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) + + # configure the line that displays assembly text + self._line_assembly = AsmLineEdit(self._code_view) + self._line_assembly.setFont(self._font) + self._line_assembly.setMinimumWidth(350) + self._label_assembly = QtWidgets.QLabel("Assembly:") + self._label_assembly.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) + + # configure the line that displays assembled bytes + self._line_bytes = QtWidgets.QLineEdit() + self._line_bytes.setFont(self._font) + self._line_bytes.setReadOnly(True) + self._label_bytes = QtWidgets.QLabel("Bytes:") + self._label_bytes.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) + + def _ui_init_code(self): + """ + Initialize the interactive code view for this UI control. + """ + self._code_view = PatchingCodeViewer(self.controller) + + def _ui_layout(self): + """ + Layout the major UI elements of the widget. + """ + layout = QtWidgets.QGridLayout(self.widget) + + # arrange the widgets in a 'grid' row col row span col span + layout.addWidget(self._label_address, 0, 0, 1, 1) + layout.addWidget(self._line_address, 0, 1, 1, 1) + layout.addWidget(self._label_assembly, 1, 0, 1, 1) + layout.addWidget(self._line_assembly, 1, 1, 1, 1) + layout.addWidget(self._label_bytes, 2, 0, 1, 1) + layout.addWidget(self._line_bytes, 2, 1, 1, 1) + layout.addWidget(self._code_view.widget, 3, 0, 1, 2) + + # apply the layout to the widget + self.widget.setLayout(layout) + + def _center_dialog(self): + """ + Center the current dialog to the IDA MainWindow. + + TODO/Hex-Rays: WOPN_CENTERED flag?! does it not work? or how do I use it? + + XXX: I have no idea why the get_main_window(...) + center_widget(...) + code I wrote in qt.py does not work for wid_dialog / IDA dockables even + though it is effectively identical to this lol + + NOTE: this hack will cause a 'widget flicker' as we are moving the widget + shortly after it is made visible... + """ + wid_main, wid_dialog = None, None + + # + # search upwards through the current dialog/widget's parent widgets + # until the IDA main window is located + # + + parent = self.widget.parent() + while parent: + + if isinstance(parent, QtWidgets.QMainWindow): + wid_main = parent + break + + elif isinstance(parent, QtWidgets.QWidget): + if parent.windowTitle() == self.controller.WINDOW_TITLE: + wid_dialog = parent + + parent = parent.parent() + + # + # fail, could not find the IDA main window and the parent container + # for this widget (unlikely) + # + + if not (wid_main and wid_dialog): + return False + + rect_main = wid_main.geometry() + rect_dialog = wid_dialog.rect() + + # + # compute a new position for the dialog such that it will center + # to the IDA main window + # + + pos_dialog = rect_main.center() - rect_dialog.center() + wid_dialog.move(pos_dialog) + + #-------------------------------------------------------------------------- + # Refresh + #-------------------------------------------------------------------------- + + def refresh(self): + """ + Refresh the entire patching dialog. + """ + self.refresh_fields() + self.refresh_code() + + def refresh_fields(self): + """ + Refresh the patching fields. + """ + + # update the address field to show the currently selected address + self._line_address.setText('0x%08X' % self.controller.address) + + # update the assembly text to show the currently selected instruction + if self._line_assembly.text() != self.controller.assembly_text: + self._line_assembly.setText(self.controller.assembly_text) + + # update the assembly bytes field... which can also show an error message + if self.controller.status_message: + self._line_bytes.setText(self.controller.status_message) + else: + self._line_bytes.setText(hexdump(self.controller.assembly_bytes)) + + def refresh_code(self): + """ + Refresh the patching code view. + """ + self._code_view.ClearLines() + + # regenerate the view from the current set of lines in the backing model + for line in self.controller.instructions: + + # + # instructions with an 'assembly label' (eg. loc_140004200) + # attached to their address should have these extra lines visible + # to better simulate a true IDA disassembly listing + # + + if line.name: + self._code_view.AddLine(line.line_blank) + self._code_view.AddLine(line.line_name) + + # emit the actual instruction text + self._code_view.AddLine(line.line_instruction) + + self._code_view.Refresh() + + def refresh_cursor(self): + """ + Refresh the user cursor in the patching code view. + """ + + # get the text based co-ordinates within the IDA code view + ida_pos = self._code_view.GetPos() + lineno_sel, x, y = ida_pos if ida_pos else (0, 0, 0) + + # fetch the instruction 'selected' by the controller/model + insn, lineno_insn = self.controller.get_insn_lineno(self.controller.address) + + if self.controller.address_idx == LAST_LINE_IDX: + lineno_new = lineno_insn + (insn.num_lines - 1) + else: + lineno_new = lineno_insn + self.controller.address_idx + + self._code_view.Jump(lineno_new, x, y) + + #------------------------------------------------------------------------- + # Events + #------------------------------------------------------------------------- + + def _edit_started(self): + """ + The assembly text was changed by the user. + """ + self._edit_timer.stop() + + assembly_text = self._line_assembly.text() + _, _, ops = parse_disassembly_components(assembly_text) + + # + # if there's no symbols that would have to be resolved for the + # the current input, we should attempt assembly immediately as it + # should be in-expensive (won't lag the text input) + # + + if not scrape_symbols(ops): + self.controller.edit_assembly(assembly_text) + return + + # + # in 500ms if the user hasn't typed anything else into the assembly + # field, we will consider their editing as 'stopped' and attempt + # to evaluate (assemble) their current input + # + + self._edit_timer.start(500) + + def _edit_stopped(self): + """ + Some amount of time has passed since the user last edited the assembly text. + """ + assembly_text = self._line_assembly.text() + self.controller.edit_assembly(assembly_text) + + def _enter_pressed(self): + """ + The user pressed enter while the assembly text line was focused. + """ + if self._edit_timer.isActive(): + self._edit_timer.stop() + self.controller.edit_assembly(self._line_assembly.text()) + self.controller.commit_assembly() + + #-------------------------------------------------------------------------- + # Misc + #-------------------------------------------------------------------------- + + def get_cursor(self): + """ + Return the current view cursor information. + """ + + # the line the view is currently focused on + view_line = self._code_view.GetCurrentLine() + view_address = parse_line_ea(view_line) + + # get the text based co-ordinates within the IDA code view + view_pos = self._code_view.GetPos() + lineno, x, y = view_pos if view_pos else (0, 0, 0) + + # + # compute the relative line number within the focused address + # + + global_idx, relative_idx = 0, -1 + while True: + + # fetch a line from the code view + line = self._code_view.GetLine(global_idx) + if not line: + break + + # unpack the returned code viewer line tuple + colored_line, _, _ = line + line_address = parse_line_ea(colored_line) + + if line_address == view_address: + + # + # found the first instruction line matching our cursor + # address, start the relative line index counter + # + + if relative_idx == -1: + relative_idx = 0 + + # next line + else: + relative_idx += 1 + + # + # we have reached the first line with an address GREATER than the + # lines with an address matching the view's current selection + # + + elif line_address > view_address: + break + + global_idx += 1 + + # + # return a position (like, our own place_t) that can be used to jump + # the patching view to this exact position again, even if the lines + # or formatting changes around 'a bit' + # + + return (view_address, relative_idx, x, y) + + def set_cursor_pos(self, address, idx=0, x=0, y=0): + """ + TODO + """ + insn, lineno = self.controller.get_insn_lineno(address) + if not insn: + raise ValueError("Failed to jump to given address 0x%08X" % address) + + # + # idx as -1 is a special case to focus on the *last* line of the + # instruction at the matching address. for example, this is used to + # focus on the *ACTUAL* instruction text / line for an address that + # contains multiple lines (blank line + label line + instruction line) + # + + if idx == -1: + idx = insn.num_lines - 1 + elif address != insn.address: + idx = 0 + + final_lineno = lineno + idx + self._code_view.Jump(final_lineno, x, y) + +class AsmLineEdit(QtWidgets.QLineEdit): + """ + A Qt LineEdit with a few extra tweaks. + """ + + def __init__(self, code_view, parent=None): + super().__init__() + self.code_view = code_view + + def keyPressEvent(self, event): + """ + Key press received. + """ + + # navigate DOWN one line in the asm view if the 'down arrow' key + if event.key() == QtCore.Qt.Key_Down: + lineno, x, y = self.code_view.GetPos() + + # clamp to the last line, and jump to it + lineno = min(lineno+1, self.code_view.Count()-1) + self.code_view.Jump(lineno, x, y) + + # manually trigger the 'Cursor Position Changed' handler + self.code_view.OnCursorPosChanged() + + # mark the event as handled + event.accept() + return + + # navigate UP one line in the code view if the 'up arrow' key + elif event.key() == QtCore.Qt.Key_Up: + lineno, x, y = self.code_view.GetPos() + + # clamp to the first line + lineno = max(lineno-1, 0) + self.code_view.Jump(lineno, x, y) + + # manually trigger the 'Cursor Position Changed' handler + self.code_view.OnCursorPosChanged() + + # mark the event as handled + event.accept() + return + + # let the key press be handled normally + super().keyPressEvent(event) + +#------------------------------------------------------------------------------ +# IDA Code Viewer +#------------------------------------------------------------------------------ + +class PatchingCodeViewer(ida_kernwin.simplecustviewer_t): + """ + An IDA controlled 'code viewer' to simulate a disassembly view. + """ + + def __init__(self, controller): + super().__init__() + self.controller = controller + self._ui_hooks = UIHooks() + self._ui_hooks.get_lines_rendering_info = self._highlight_lines + self.Create() + + #-------------------------------------------------------------------------- + # IDA Code Viewer Overloads + #-------------------------------------------------------------------------- + + def Create(self): + if not super().Create('PatchingCodeViewer'): + return False + self._twidget = self.GetWidget() + self.widget = ida_kernwin.PluginForm.TWidgetToPyQtWidget(self._twidget) + self._ui_hooks.hook() + return True + + def OnClose(self): + self._ui_hooks.unhook() + self._filter = None + + def OnCursorPosChanged(self): + + # get the currently selected line in the code view + view_line = self.GetCurrentLine() + view_lineno = self.GetLineNo() + view_address = parse_line_ea(view_line) + + # + # get the info about the currently selected instruction from the + # underlying view controller / model + # + + insn, insn_lineno = self.controller.get_insn_lineno(view_address) + + # compute the cursor's relative index into lines with the same address + relative_idx = view_lineno - insn_lineno + + # notify the controller of the updated cursor / selection + self.controller.select_address(view_address, relative_idx) + + def OnPopup(self, form, popup_handle): + self._filter = remove_ida_actions(popup_handle) + return False + + #-------------------------------------------------------------------------- + # Events + #-------------------------------------------------------------------------- + + def _highlight_lines(self, out, widget, rin): + """ + IDA is drawing disassembly lines and requesting highlighting info. + """ + + # ignore line highlight events that are not for the current code view + if widget != self._twidget: + return + + selected_lnnum, x, y = self.GetPos() + + # highlight lines/addresses that have been patched by the user + assert len(rin.sections_lines) == 1 + for i, line in enumerate(rin.sections_lines[0]): + splace = ida_kernwin.place_t_as_simpleline_place_t(line.at) + line_info = self.GetLine(splace.n) + if not line_info: + continue + + colored_text, _, _ = line_info + address = parse_line_ea(colored_text) + + current_insn = self.controller.get_insn(address) + if not current_insn: + continue + + # convert (ea, size) to represent the full address of each byte in an instruction + insn_addresses = set(range(current_insn.address, current_insn.address + current_insn.size)) + + # green: selected line + if splace.n == selected_lnnum: + color = ida_kernwin.CK_EXTRA1 + + # red: clobbered line + elif current_insn.clobbered: + color = ida_kernwin.CK_EXTRA11 + + # yellow: patched line + elif insn_addresses & self.controller.core.patched_addresses: + color = ida_kernwin.CK_EXTRA2 + + # no highlighting needed + else: + continue + + # highlight the line if it is patched in some way + e = ida_kernwin.line_rendering_output_entry_t(line) + e.bg_color = color + e.flags = ida_kernwin.LROEF_FULL_LINE + + # save the highlight to the output line highlight list + out.entries.push_back(e) diff --git a/plugins/patching/ui/resources/assemble.png b/plugins/patching/ui/resources/assemble.png new file mode 100644 index 0000000..6efdaf2 Binary files /dev/null and b/plugins/patching/ui/resources/assemble.png differ diff --git a/plugins/patching/ui/resources/forcejump.png b/plugins/patching/ui/resources/forcejump.png new file mode 100644 index 0000000..2de4dce Binary files /dev/null and b/plugins/patching/ui/resources/forcejump.png differ diff --git a/plugins/patching/ui/resources/nop.png b/plugins/patching/ui/resources/nop.png new file mode 100644 index 0000000..c28675b Binary files /dev/null and b/plugins/patching/ui/resources/nop.png differ diff --git a/plugins/patching/ui/resources/revert.png b/plugins/patching/ui/resources/revert.png new file mode 100644 index 0000000..9c334de Binary files /dev/null and b/plugins/patching/ui/resources/revert.png differ diff --git a/plugins/patching/ui/resources/save.png b/plugins/patching/ui/resources/save.png new file mode 100644 index 0000000..5a5f408 Binary files /dev/null and b/plugins/patching/ui/resources/save.png differ diff --git a/plugins/patching/ui/save.py b/plugins/patching/ui/save.py new file mode 100644 index 0000000..db50ea9 --- /dev/null +++ b/plugins/patching/ui/save.py @@ -0,0 +1,176 @@ +import hashlib + +import ida_nalt + +from patching.util.qt import QT_AVAILABLE +from patching.exceptions import * + +if QT_AVAILABLE: + from patching.ui.save_ui import SaveDialog + +class SaveController(object): + """ + The backing logic & model (data) for the patch saving UI. + """ + WINDOW_TITLE = "Apply patches to..." + + def __init__(self, core, error=None): + self.core = core + self.view = None + + # init fields + self._init_settings() + + # init error (if there was one that caused the dialog to pop) + self.attempts = 1 if error else 0 + self._set_error(error) + + # only create the UI for the save dialog as needed + if QT_AVAILABLE: + self.view = SaveDialog(self) + + def _init_settings(self): + """ + Initialize dialog settings from the plugin core / IDA state. + """ + + # inherit certain settings from the plugin core + self.patch_cleanly = self.core.prefer_patch_cleanly + self.quick_apply = self.core.prefer_quick_apply + + # the target file to patch / apply patches to + self.target_filepath = self.core.patched_filepath + if not self.target_filepath: + self.target_filepath = ida_nalt.get_input_file_path() + + def _set_error(self, exception): + """ + Set the save dialog error text based on the given exception. + """ + + # no error given, reset message text / color fields + if exception is None: + self.status_message = '' + self.status_color = '' + return + + # + # something went wrong trying to ensure a usable backup / clean + # executable was available for the patching operation. this should + # only ever occur when the user is attempting to 'patch cleanly' + # + # this is most likely because the plugin could not locate a clean + # version of the executable on disk. if the user would like to try + # yolo-patching the target file, they can un-check 'Patch cleanly' + # + + if isinstance(exception, PatchBackupError): + self.status_message = str(exception) + "\nDisable 'Patch cleanly' to try patching anyway (att #%u)" % self.attempts + self.status_color = 'red' + + # + # something went wrong explicitly trying to modify the target / output + # file for the patching operation. + # + # this is most likely because the file is locked, but the target file + # could also be missing (among other reasons) + # + + elif isinstance(exception, PatchTargetError) or isinstance(exception, PatchApplicationError): + self.status_message = str(exception) + "\nIs the filepath above locked? or missing? (att #%u)" % self.attempts + self.status_color = 'red' + + # unknown / unhandled error? + else: + self.status_message = "Unknown error? (att #%u)\n%s" % (self.attempts, str(exception)) + self.status_color = 'red' + + #-------------------------------------------------------------------------- + # Actions + #-------------------------------------------------------------------------- + + def interactive(self): + """ + Spawn an interactive user dialog and wait for it to close. + """ + if not self.view: + return False + return self.view.exec_() + + def attempt_patch(self, target_filepath, clean): + """ + Attempt to patch the target binary. + """ + + # + # increment the 'patch attempt' count over the lifetime of this + # dialog. the purpose of this counter is simple: it is a visual + # cue to users who will continue to mash the 'Apply Patches' + # button even in the face of a big red error message. + # + # the idea is that (hopefully) they will see this 'attempt count' + # updating in the otherwise static error message text to indicate + # that 'yes, the file is still locked/unavailabe/missing' until + # they go rectify the issue + # + + self.attempts += 1 + + # + # attempt to apply patches to the target file on behalf of the + # interactive dialog / user request + # + + try: + self.core.apply_patches(target_filepath, clean) + except Exception as e: + self._set_error(e) + return False + + # + # if we made it this far, patching must have succeeded, save patch + # settings to the core plugin + # + + self.status_message = '' + self.core.prefer_patch_cleanly = self.patch_cleanly + self.core.prefer_quick_apply = self.quick_apply + + # return success + return True + + def update_target(self, target_filepath): + """ + Update the targeted filepath. + """ + self.target_filepath = target_filepath + if self.patch_cleanly: + return + + # + # if the UI setting for 'Patch cleanly' is explicitly unchecked but + # the user *just* updated the target filepath via file dialog, we + # will quickly try to check if the selected file appears to be + # a good candidate for making a copy (backup) of during the likely + # imminent patch save / application operation + # + + try: + disk_md5 = hashlib.md5(open(target_filepath, 'rb').read()).digest() + except Exception: + return + + # the MD5 hash of the file (executable) used to generate this IDB + input_md5 = ida_nalt.retrieve_input_file_md5() + if input_md5 != disk_md5: + return + + # + # at this point, the user has explicitly selected a patch target that + # appears to be clean, yet they have 'Patch cleanly' disabled, so we + # should provide them with a 'soft' hint / warning that it would be + # best for them to turn 'Patch cleanly' back on... + # + + self.status_message = "The patch target appears to be a clean executable,\nit is recommended you turn on 'Patch cleanly'" + self.status_color = 'orange' diff --git a/plugins/patching/ui/save_ui.py b/plugins/patching/ui/save_ui.py new file mode 100644 index 0000000..2503429 --- /dev/null +++ b/plugins/patching/ui/save_ui.py @@ -0,0 +1,181 @@ +import os + +from patching.util.qt import * + +class SaveDialog(QtWidgets.QDialog): + """ + The UI components of the Patch Saving dialog. + """ + + def __init__(self, controller): + super().__init__() + self.controller = controller + self._ui_init() + + #-------------------------------------------------------------------------- + # Initialization - UI + #-------------------------------------------------------------------------- + + def _ui_init(self): + """ + Initialize UI elements. + """ + self.setWindowTitle(self.controller.WINDOW_TITLE) + + # remove auxillary buttons (such as '?') from window title bar + remove_flags = ~( + QtCore.Qt.WindowSystemMenuHint | + QtCore.Qt.WindowContextHelpButtonHint + ) + self.setWindowFlags(self.windowFlags() & remove_flags) + self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + + # make dialog fixed size (no size grip, etc) + #self.setWindowFlags(self.windowFlags() | QtCore.Qt.MSWindowsFixedSizeDialogHint) + #self.setSizeGripEnabled(False) + + # make dialog modal, so users can't click around IDA / change more stuff + #self.setModal(True) + + # initialize our ui elements + self._ui_init_fields() + self._ui_init_options() + + # layout the populated ui just before showing it + self._ui_layout() + + # connect signals + self._btn_target.clicked.connect(self.select_target_file) + self._btn_apply.clicked.connect(self._attempt_patch) + self._chk_clean.stateChanged.connect(self._checkboxes_changed) + self._chk_quick.stateChanged.connect(self._checkboxes_changed) + + def _ui_init_fields(self): + """ + Initialize the interactive text fields for this UI control. + """ + self._label_target = QtWidgets.QLabel("Patch Target:") + self._label_target.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) + self._line_target = QtWidgets.QLineEdit() + self._line_target.setText(self.controller.target_filepath) + self._line_target.setMinimumWidth(360) + self._btn_target = QtWidgets.QPushButton(" ... ") + + # warning / status message + self._label_status = QtWidgets.QLabel() + self._label_status.setWordWrap(True) + self._label_status.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter) + self._refresh_status_message() + + # apply patches button + self._btn_apply = QtWidgets.QPushButton("Apply patches") + + def _ui_init_options(self): + """ + Initialize the interactive options for this UI control. + """ + self._group_options = QtWidgets.QGroupBox("Options") + + # checkbox options + self._chk_clean = QtWidgets.QCheckBox("Patch cleanly") + self._chk_clean.setChecked(self.controller.patch_cleanly) + self._chk_clean.setToolTip("Maintain a clean (.bak) input file to clone and apply patches to each time") + self._chk_quick = QtWidgets.QCheckBox("Show quick save") + self._chk_quick.setChecked(self.controller.quick_apply) + self._chk_quick.setToolTip("Use the current target filepath for future patch applications") + + # layout the groupbox + layout = QtWidgets.QVBoxLayout(self._group_options) + layout.addWidget(self._chk_clean) + layout.addWidget(self._chk_quick) + self._group_options.setLayout(layout) + + def _ui_layout(self): + """ + Layout the major UI elements of the widget. + """ + layout = QtWidgets.QGridLayout(self) + + # arrange the widgets in a 'grid' row col row span col span + layout.addWidget(self._line_target, 0, 1, 1, 1) + layout.addWidget(self._btn_target, 0, 2, 1, 1) + layout.addWidget(self._group_options, 0, 0, 2, 1) + layout.addWidget(self._label_status, 1, 1, 2, 1) + layout.addWidget(self._btn_apply, 1, 2, 1, 1) + #layout.setSizeConstraint(QtWidgets.QLayout.SetFixedSize) + + # apply the layout to the widget + self.setLayout(layout) + + #-------------------------------------------------------------------------- + # Events + #-------------------------------------------------------------------------- + + def showEvent(self, e): + """ + Overload the showEvent to center the save dialog over the IDA main window. + """ + center_widget(self) + return super().showEvent(e) + + def select_target_file(self): + """ + The user pressed the '...' button to select a file to patch. + """ + starting_directory = os.path.dirname(self.controller.target_filepath) + + # prompt the user to select a patch target / output file + dialog = QtWidgets.QFileDialog() + filepath, _ = dialog.getSaveFileName(caption="Select patch target...", directory=starting_directory) + + # user did not select a file or closed the file dialog + if not filepath: + return + + # save the selected patch target + self.controller.update_target(filepath) + self._line_target.setText(filepath) + + # + # update the status text, in-case the controller has something + # important to tell the user (eg, hinting them to turn clean + # patching on, if it thinks it will succeed) + # + + self._refresh_status_message() + + def _attempt_patch(self): + """ + The user clicked the Apply Patches button. + """ + target_filepath = self._line_target.text() + apply_clean = self._chk_clean.isChecked() + + # if patching succeeds, we're all done! close the dialog + if self.controller.attempt_patch(target_filepath, apply_clean): + self.accept() + return + + # patching must have failed, attempt to update the status / error message + self._refresh_status_message() + + def _checkboxes_changed(self): + """ + The status of the checkboxes changed. + """ + self.controller.patch_cleanly = self._chk_clean.isChecked() + self.controller.quick_apply = self._chk_quick.isChecked() + + #-------------------------------------------------------------------------- + # Refresh + #-------------------------------------------------------------------------- + + def _refresh_status_message(self): + """ + Refresh the status / error message text based on the underlying UI state. + """ + self._label_status.setText(self.controller.status_message) + if self.controller.status_color: + self._label_status.setStyleSheet("QLabel { font-weight: bold; color: %s; }" % (self.controller.status_color)) + else: + self._label_status.setStyleSheet(None) diff --git a/plugins/patching/util/__init__.py b/plugins/patching/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/patching/util/ida.py b/plugins/patching/util/ida.py new file mode 100644 index 0000000..e5de1bb --- /dev/null +++ b/plugins/patching/util/ida.py @@ -0,0 +1,865 @@ +import re +import ctypes + +import ida_ua +import ida_ida +import ida_idp +import ida_auto +import ida_nalt +import ida_name +import ida_bytes +import ida_lines +import ida_idaapi +import ida_struct +import ida_kernwin +import ida_segment + +from .qt import * +from .python import swap_value + +#------------------------------------------------------------------------------ +# IDA Hooks +#------------------------------------------------------------------------------ + +class UIHooks(ida_kernwin.UI_Hooks): + def ready_to_run(self): + pass + def get_lines_rendering_info(self, out, widget, rin): + pass + def populating_widget_popup(self, widget, popup, ctx): + pass + +class IDPHooks(ida_idp.IDP_Hooks): + def ev_ending_undo(self, action_name, is_undo): + pass + +class IDBHooks(ida_idp.IDB_Hooks): + def auto_empty_finally(self): + pass + +#------------------------------------------------------------------------------ +# IDA Misc +#------------------------------------------------------------------------------ + +def is_reg_name(reg_name): + """ + Return True if the given string is a known register name. + """ + ri = ida_idp.reg_info_t() + return bool(ida_idp.parse_reg_name(ri, reg_name)) + +def is_mnemonic(mnemonic): + """ + Return True if the given string is a known mnemonic (roughly). + + TODO: remove or offload to Keystone if possible? this is just 'best effort' + TODO: actually this can probably be removed now? no longer used... + """ + + # cache known mnemonics for the current proc on the first invocation + if not hasattr(is_mnemonic, 'known_mnemonics'): + is_mnemonic.known_mnemonics = set([name.upper() for name, _ in ida_idp.ph.instruc]) + + # check if the given mnemonic is in the list of known mnemonics + mnemonic = mnemonic.upper() + return bool(mnemonic in is_mnemonic.known_mnemonics) + +def is_range_patched(start_ea, end_ea=None): + """ + Return True if a patch exists within the given address range. + """ + if end_ea == None: + end_ea = start_ea + 1 + + def visitor(ea, file_offset, original_value, patched_value): + return 1 + + return bool(ida_bytes.visit_patched_bytes(start_ea, end_ea, visitor)) + +def apply_patches(filepath): + """ + Apply the current IDB patches to the given filepath. + """ + + with open(filepath, 'r+b') as f: + + # + # a visitor function that will be called for each patched byte. + # + # NOTE: this is a python version of IDA's built in 'Apply patches...' + # routine that has simply been reverse engineered + # + + def visitor(ea, file_offset, original_value, patched_value): + + # the patched byte does not have a know file address + if file_offset == ida_idaapi.BADADDR: + print("%08X: has no file mapping (original: %02X patched: %02X)...skipping...\n" % (ea, original_value, patched_value)) + return 0 + + # seek to the patch location + f.seek(file_offset) + + # fetch the 'number of bits in a byte' for the given address (? lol) + bits = ida_bytes.nbits(ea) + + # round the number of bits up to bytes + num_bytes = (bits + 7) // 8 + + # IDA does this, basically (swap_value(...)) so we will too + if ida_ida.inf_is_wide_high_byte_first(): + byte_order = 'big' + else: + byte_order = 'little' + + # convert the int/long patch value to bytes (and swap endianess, if needed) + patched_value = patched_value.to_bytes(num_bytes, byte_order) + + # write the patched byte(s) to the output file + f.write(patched_value) + + # + # return 0 so that the visitor keeps going to the next patched bytes + # instead of stopping after this one. + # + + return 0 + + # + # RUN THE VISITOR / APPLY PATCHES + # + + ida_bytes.visit_patched_bytes(0, ida_idaapi.BADADDR, visitor) + + # + # all done, file will close as we leave this 'with' scoping + # + + pass + + # done done + return + +#------------------------------------------------------------------------------ +# IDA UI +#------------------------------------------------------------------------------ + +def attach_submenu_to_popup(popup_handle, submenu_name, prev_action_name): + """ + Create an IDA submenu AFTER the action name specified by prev_action_name. + + TODO/XXX/HACK/Hex-Rays: this is a workaround for not being able to create + and position submenu groups for rightclick menus + """ + if not QT_AVAILABLE: + return None + + # cast an IDA 'popup handle' pointer back to a QMenu object + p_qmenu = ctypes.cast(int(popup_handle), ctypes.POINTER(ctypes.c_void_p))[0] + qmenu = sip.wrapinstance(int(p_qmenu), QtWidgets.QMenu) + + # create a Qt (sub)menu that can be injected into an IDA-originating menu + submenu = QtWidgets.QMenu(submenu_name) + + # search for the target action to insert the submenu next to + all_actions = list(qmenu.actions()) + for i, current_action in enumerate(all_actions[:-1]): + if current_action.text() == prev_action_name: + insertion_point = all_actions[i+1] + qmenu.insertMenu(insertion_point, submenu) + break + + # + # if we did not find the action we wanted to place the new submenu after, + # simply append it to the end of the menu + # + + else: + qmenu.addMenu(submenu) + + # + # not totally sure if we need to be managing the lifetime of this submenu + # even after it has been inserted. so we return it here, just in-case. + # + + return submenu + +#------------------------------------------------------------------------------ +# Symbols +#------------------------------------------------------------------------------ + +# TODO: err this might not be a good assumption for mangling... eg '()' +IGNORED_CHARS = R"!,[]{}#+-*:" +IGNORED_CHARS_MAP = {ord(x): ' ' for x in IGNORED_CHARS} +IGNORED_REGISTERS = set() +IGNORED_KEYWORDS = set( + [ + # x86 / x64 + 'byte', 'short', 'word', 'dword', 'qword', 'xword', 'xmmword', 'ymmword', 'tbyte', 'large', 'long', 'near', 'far', 'ptr', 'offset', + + # ARM + 'eq', 'ne', 'cs', 'hs', 'cc', 'lo', 'mi', 'pl', 'vs', 'vc', 'hi', 'ls', 'ge', 'lt', 'gt', 'le', 'al' + ] +) + +def scrape_symbols(disassembly_text): + """ + Attempt to scrape symbol-like values from a line of disassembly. + """ + global IGNORED_REGISTERS + symbols = [] + + # split a comment off the given disassembly text, if present + #x, sep, y = disassembly_text.rpartition('; ') + #dis, cmt = (x, y) if sep else (y, x) + assert ';' not in disassembly_text + + # + # TODO: I'm really not sure how we should deal with cpp / demangled-ish + # symbols in disassembly text. if we see something like foo::bar(...) + # in the given disassembly text, our code is going to explode + # + # so for now we're just going to make no effort to parse out possible + # cpp symbols and will figure out how to deal with them later :/ + # + + if '::' in disassembly_text or '`' in disassembly_text: + return [] + + # remove common disas chars that will not appear in an IDA name + dis = disassembly_text.translate(IGNORED_CHARS_MAP) + + # + # regex match any remaining 'non-whitespace' text, which should have its + # position preserved from the original string. this should allow us to + # return the symbols and their index in the given text + # + + for m in re.finditer(r'\S+', dis): + + # normalize the potential symbol text + original_symbol = m.group() + word = original_symbol.lower() + + # ignore previously seen registers (fastpath) + if word in IGNORED_REGISTERS: + continue + + # ignore numbers / immediates (only imms can start with a number) + if word[0] in '0123456789': + continue + + # ignore IDA keywords (approximate) + if word in IGNORED_KEYWORDS: + continue + + # ignore new registers (and cache it for future scrapes) + if is_reg_name(word): + IGNORED_REGISTERS.add(word) + continue + + # XXX: kind of a hack for things like 'movzx eax, ds:(jump_table_11580-20h)[eax]' + if original_symbol[0] == '(': + original_symbol = original_symbol[1:] + + # eg: '$)' + elif original_symbol[-1] == ')' and '(' not in original_symbol: + original_symbol = original_symbol[:-1] + + # possible symbol! + symbols.append((original_symbol, m.span())) + + # return list of likely symbols + return symbols + +def resolve_symbol(from_ea, name): + """ + Return an address or value for the given symbol. + + TODO/Hex-Rays: this function is overly complex and is probably something + that should be baked into IDA as more aggressive 'resolve symbol' API imo + + this function will yield matching symbol values (operating as a + generator). this is because IDA can show 'visually identical' symbols in + rendered instructions that have different 'true' names. + + eg. a func named '.X.' appears as '_X_' in IDA's x86 disassembly. but + a second func could be named '.X_' which will also appear as '_X_' + + while this is maybe okay in the context of IDA (where it has concrete + instruction / address info) ... it is not okay for trying to 'resolve' + a symbol when your only information is assembly text. + + if the user types in the following instruction: + + eg. call _X_ + + how can we know which value to select as a jump target? + + (the user will have to decide... through some symbol collision hinting... + but the point still stands: a function like this has to be able to return + 'multiple' potential values) + """ + + # + # first, we will attempt to parse the given symbol as a global + # struct path. + # + # eg. 'g_foo.bar.baz' + # + # NOTE: this kind of has to be first, because our second section of + # symbol resolution (get_name_ea, get_name_value) will incorrectly + # 'resolve' a global struct path used at a given address. + # + # by incorrectly, i mean that global struct path reference in an + # instruction will resolve to the base address of the global, not + # the actual referenced field within the global + # + # TODO: there's a bug or something in my code still, this is not + # computing the right offset in some cases (try assemble_all() on + # ntoskrnl.exe from Windows 11 to see some of the failures) + # + + global_name, sep, struct_path = name.partition('.') + + # + # if sep 'exists', that means there is a '.' in the given symbol so it + # *could* be a global struct path. let's try to walk though it + # + + if sep: + + resolved_paths = 0 + + for global_ea, real_name in resolve_symbol(from_ea, global_name): + + # if the resolved symbol address is not a global struct, ignore it + if not ida_bytes.is_struct(ida_bytes.get_flags(global_ea)): + continue + + # get the struct info for the resolved global address + sid = ida_nalt.get_strid(global_ea) + sptr = ida_struct.get_struc(sid) + + # + # walk through the rest of the struct path to compute the offset (and + # final address) of the referenced field eg. global.foo.bar + # + + offset = 0 + while struct_path and sptr != None: + + member_name, sep, struct_path = struct_path.partition('.') + member = ida_struct.get_member_by_name(sptr, member_name) + + if member is None: + print(" - INVALID STRUCT MEMBER!", member_name) + break + + offset += member.get_soff() + sptr = ida_struct.get_sptr(member) + if not sptr: + assert not('.' in struct_path), 'Expected end of struct path?' + yield (global_ea+offset, name) + resolved_paths += 1 + + # + # TODO/XXX: if we yielded at least one struct path... we're *probably* + # good. I don't think + # + + if resolved_paths: + return + + # + # if the given symbol does not appear to be a global struct path, we + # will try to use some of IDA's more typical 'name' --> address API's + # + # should any of these succeed, they are most certainly to be the symbol + # value the user / instruction intended + # + + value = ida_name.get_name_ea(from_ea, name) + if value != ida_idaapi.BADADDR: + yield (value, name) + return + + nt, value = ida_name.get_name_value(from_ea, name) + if nt != ida_name.NT_NONE: + yield (value, name) + return + + if name == '$': + yield (from_ea, name) + return + + # + # yield all matches for a sanitized (codepage-validated?) name + # + # TODO/PERF: lol this is ridiculously expensive + # + + # alias for speed (does this pseudo-optimization even work in py3 anymore? lol) + get_nlist_ea = ida_name.get_nlist_ea + get_nlist_name = ida_name.get_nlist_name + #get_short_name = ida_name.get_short_name + get_visible_name = ida_name.get_visible_name + + for idx in range(ida_name.get_nlist_size()): + address = get_nlist_ea(idx) + #visible_name = get_short_name(address) + visible_name = get_visible_name(address) + #visible_name = ida_name.validate_name(real_name, ida_name.VNT_IDENT) # ??? + if visible_name == name: + real_name = get_nlist_name(idx) + yield (address, real_name) + +def get_dtype_name(dtype, size): + """ + Return the keyword for the given data type. + """ + dtype_map = \ + { + ida_ua.dt_byte: 'byte', # 8 bit + ida_ua.dt_word: 'word', # 16 bit + ida_ua.dt_dword: 'dword', # 32 bit + ida_ua.dt_float: 'dword', # 4 byte + ida_ua.dt_double: 'qword', # 8 byte + ida_ua.dt_qword: 'qword', # 64 bit + ida_ua.dt_byte16: 'xmmword', # 128 bit + ida_ua.dt_byte32: 'ymmword', # 256 bit + } + + if dtype == ida_ua.dt_tbyte and size == 10: + return 'xword' + + return dtype_map.get(dtype, None) + +def get_tag_name(scolor): + """ + Return the name of a given COLOR tag. + """ + attribute_names = dir(ida_lines) + + for name in attribute_names: + if not name.startswith('SCOLOR_'): + continue + value = getattr(ida_lines, name) + if value == scolor: + return name + + return '' + +def rewrite_tag_addrs(line, wrap=False): + """ + Rewrite symbol text with their COLOR values + + TODO: remove? + """ + if not line: + return + + og_line = line + og_index = 0 + + while len(line) > 0: + + skipcode_index = ida_lines.tag_skipcode(line) + + if skipcode_index == 0: # No code found + line = line[1:] # Skip one character ahead + og_index += 1 + continue + + if not(line[0] == ida_lines.COLOR_ON and line[1] == chr(ida_lines.COLOR_ADDR)): + line = line[skipcode_index:] + og_index += skipcode_index + continue + + # parse the hidden text address from the tagged line + address = int(line[2:skipcode_index], 16) + + # skip past the address to the symbol + line = line[skipcode_index:] + og_index += skipcode_index + + # copy the symbol out of the tagged line + symbol = line[:line.index(ida_lines.COLOR_OFF)] + symbol_index = og_index + #print("Found addr: 0x%08X, '%s'" % (address, symbol)) + + if wrap: + address_text = "[0x%X]" % address + else: + address_text = "0x%X" + + # write the address text over the place of the original symbol + og_line = og_line[:symbol_index] + address_text + og_line[symbol_index+len(symbol):] + + # continue past the extracted symbol text + skipcode_index = ida_lines.tag_skipcode(line) + line = line[skipcode_index:] + og_index += len(address_text) # special adjustment, to account for the injected address text + + return ida_lines.tag_remove(og_line) + +def get_disassembly_components_slow(ea): + """ + Return (prefix, mnemonic, [operands]) from IDA's disassembly text. + + TODO: remove? + """ + if not ida_bytes.is_code(ida_bytes.get_flags(ea)): + return (None, None, []) + + # alias for simpler code / formatting + COLOR_OPNDS = [chr(ida_lines.COLOR_OPND1+i) for i in range(7)] + + # tag parsing output + comps_insn = [] + comps_ops = [None for i in range(7)] + + # tag parsing state + tag_chars = [] + tag_stack = [] + + # fetch the 'colored' (tagged) instruction text from IDA for parsing + insn_text = ida_lines.generate_disasm_line(ea) + + # + # using the IDA 'color' tags, we can parse spans of text generated by IDA + # to determine the different parts of a printed instruction. + # + # this is useful because we can let IDA's core / proc module handle the + # printing of specific features (e.g. instruction prefixes, size + # annotations, segment references) without trying to re-implement the + # full insn printing pipeline on our own. + # + + while insn_text: + skipcode_index = ida_lines.tag_skipcode(insn_text) + + # + # if we are not sitting on top of a 'color code' / tag action, then + # we do not need to take any special parsing action. + # + + if skipcode_index == 0: + tag_chars.append(insn_text[0]) + insn_text = insn_text[1:] + continue + + #print('BYTES', ' '.join(['%02X' % ord(x) for x in insn_text[0:2]])) + tag_action, tag_type = insn_text[0:2] + + # + # entering a new color tag / text span + # + + if tag_action == ida_lines.SCOLOR_ON: + + # + # address tags do not have a closing tag, so we must consume + # them immediately. + # + + if tag_type == ida_lines.SCOLOR_ADDR: + + # parse the 'invisible' address reference + address = int(insn_text[2:2+ida_lines.COLOR_ADDR_SIZE], 16) + #symbol = insn_text[2+ida_lines.COLOR_ADDR:skipcode_index] + #print("FOUND SYMBOL '%s' ADDRESS 0x%8X" % (symbol, address)) + + # continue parsing the line + insn_text = insn_text[skipcode_index:] + continue + + tag_stack.append((tag_type, tag_chars)) + tag_chars = [] + + # + # exiting a color tag / text span + # + + elif tag_action == ida_lines.SCOLOR_OFF: + entered_tag, prev_tag_chars = tag_stack.pop() + assert entered_tag == tag_type, "EXITED '%s' EXPECTED '%s'" % (get_tag_name(tag_type), get_tag_name(entered_tag)) + tag_text = ''.join(tag_chars).strip() + + # save instruction prefixes or the mnemonic + if tag_type == ida_lines.SCOLOR_INSN: + comps_insn.append(tag_text) + + # save instruction operands + elif tag_type in COLOR_OPNDS: + op_num = ord(tag_type) - ida_lines.COLOR_OPND1 + #print("ADDRESS 0x%08X OP %u: %s" % (ea, op_num, tag_text)) + comps_ops[op_num] = tag_text + + # ignore the rest? (for now I guess) + else: + #print("NOT SAVING: '%s' TAG TYPE '%s' " % (tag_text, get_tag_name(tag_type))) + pass + + tag_chars = prev_tag_chars + tag_chars + + # continue past the 'color codes' / tag info + insn_text = insn_text[skipcode_index:] + + # if there is more than one 'insn component', assume they are prefixes + if len(comps_insn) > 1: + prefix = ' '.join(comps_insn[:-1]) + else: + prefix = '' + + # the instruction mnemonic should be the 'last' instruction component + mnemonic = comps_insn[-1] + + return (prefix, mnemonic, comps_ops) + +# +# TODO/XXX: ehh there's no way to really get / enumerate instruction prefixes +# from IDA processor modules +# + +KNOWN_PREFIXES = set(['xacquire', 'xrelease', 'lock', 'bnd', 'rep', 'repe', 'repne']) + +def get_disassembly_components(ea): + """ + Return (prefix, mnemonic, operands) instruction components for a given address. + """ + line_text = ida_lines.tag_remove(ida_lines.generate_disasm_line(ea)) + return parse_disassembly_components(line_text) + +def parse_disassembly_components(line_text): + """ + Return (prefix, mnemonic, operands) from the given instruction text. + """ + + # remove comment (if present) + insn_text = line_text.split(';', 1)[0] + + # split instruction roughly into its respective elements + elements = insn_text.split(' ') + + # + # parse prefixes + # + + for i, value in enumerate(elements): + if not (value in KNOWN_PREFIXES): + break + + # + # if we didn't break from the loop, that means *every* element in the + # split text was an instruction prefix. this seems odd, but it can + # happen, eg the 'lock' instruction... by itself (in x86) is valid + # + # in this case, there is no mnemonic, or operands. just a prefix + # + + else: + return (' '.join(elements), '', '') + + # + # there can be multiple instruction prefix 'words' so we stitch them + # together here, in such cases + # + + prefix = ' '.join(elements[:i]) + + # + # parse mnemonic + # + + mnemonic = elements[i] + i += 1 + + # + # operands + # + + operands = ' '.join(elements[i:]) + + return (prefix, mnemonic, operands) + +def all_instruction_addresses(ea=0): + """ + Return a generator that yields each instruction address in the IDB. + """ + + # alias for speed + BADADDR = ida_idaapi.BADADDR + SEG_CODE = ida_segment.SEG_CODE + get_flags = ida_bytes.get_flags + get_seg_type = ida_segment.segtype + get_next_head = ida_bytes.next_head + is_code = ida_bytes.is_code + + # yield each instruction address in the IDB + while ea < BADADDR: + + if get_seg_type(ea) != SEG_CODE: + ea = get_next_head(ea, BADADDR) + continue + + # skip any address that is not an instruction + if not is_code(get_flags(ea)): + ea = get_next_head(ea, BADADDR) + continue + + # return the current 'instruction' address + yield ea + + # continue forward to the next address + ea = get_next_head(ea, BADADDR) + +def disassemble_bytes(data, ea): + """ + Disassemble the given bytes using IDA at the given address. + """ + old = ida_auto.set_auto_state(False) + + # fetch the current bytes (they could be patched already!) + original_data = ida_bytes.get_bytes(ea, len(data)) + + # + # temporarily patch in the data we want IDA to disassemble, and fetch + # the resulting disassembly text + # + + ida_bytes.patch_bytes(ea, data) + text = ida_lines.generate_disasm_line(ea) + + # revert the saved bytes back to the prior state + ida_bytes.patch_bytes(ea, original_data) + + # re-enable the auto analyzer and return the disassembled text + ida_auto.enable_auto(old) + return ida_lines.tag_remove(text) + +#------------------------------------------------------------------------------ +# IDA Viewer Shims +#------------------------------------------------------------------------------ + +# +# TODO/Hex-Rays: +# +# IDA's simplecustviewer_t() does not support populating/hinting fields of +# the 'ctx' structure passed onto IDA Action handlers +# +# for this reason, we have to do some manual resolution of context for our +# patching viewer. these shims are to help keep the action code above a +# bit cleaner until Hex-Rays can improve simple code viewers +# + +def parse_line_ea(colored_line): + """ + Parse a code / instruction address from a colored line in the patching dialog. + """ + line = ida_lines.tag_remove(colored_line) + ea = int(line.split('|')[0], 16) + return ea + +def get_current_ea(ctx): + """ + Return the current address for the given action context. + """ + + # custom / interactive patching view + if ida_kernwin.get_widget_title(ctx.widget) == 'PatchingCodeViewer': + return parse_line_ea(ida_kernwin.get_custom_viewer_curline(ctx.widget, False)) + + # normal IDA widgets / viewers + return ctx.cur_ea + +def read_range_selection(ctx): + """ + Return the currently selected address range for the given action context. + """ + + # custom / interactive patching view + if ida_kernwin.get_widget_title(ctx.widget) == 'PatchingCodeViewer': + + # no active selection in the patching view, nothing to do... + if not(ctx.cur_flags & ida_kernwin.ACF_HAS_SELECTION): + return (False, ida_idaapi.BADADDR, ida_idaapi.BADADDR) + + # extract the start/end cursor locations (place_t) from the given ctx + splace_from = ida_kernwin.place_t_as_simpleline_place_t(ctx.cur_sel._from.at) + splace_to = ida_kernwin.place_t_as_simpleline_place_t(ctx.cur_sel.to.at) + + # + # TODO/Hex-Rays: lol a *BRUTAL HACK* to get the src / dst lines + # + # the problem here is that there is no way to get the contents of an + # arbitrary line (by number) in the custom viewer we created. at least not + # from here, where we don't have a python reference of simplecustviewer_t() + # + # luckily... we can 'generate' (fetch?) the viewer's line through a place_t + # + # lol... + # + + start_line = splace_from.generate(ida_kernwin.get_viewer_user_data(ctx.widget), 1)[0][0] + end_line = splace_to.generate(ida_kernwin.get_viewer_user_data(ctx.widget), 1)[0][0] + + # parse the leading address from the 'colored' text fetched from the patching window + start_ea = parse_line_ea(start_line) + end_ea = parse_line_ea(end_line) + end_ea = ida_bytes.get_item_end(end_ea) + #print("Got %08X --> %08X for custom viewer range parse" % (start_ea, end_ea)) + + # not a true 'range selection' if the start and end line / number is the same + if start_ea == end_ea: + return (False, ida_idaapi.BADADDR, ida_idaapi.BADADDR) + + # return the range of selected lines + return (True, start_ea, end_ea) + + # normal IDA view + return ida_kernwin.read_range_selection(ctx.widget) + +def remove_ida_actions(popup): + """ + Remove default IDA actions from a given IDA popup (handle). + """ + if not QT_AVAILABLE: + return None + + # + # TODO/Hex-Rays: + # + # so, i'm pretty picky about my UI / interactions. IDA puts items in + # the right click context menus of custom (code) viewers. + # + # these items aren't really relevant (imo) to the plugin's use case + # so I do some dirty stuff here to filter them out and ensure only + # my items will appear in the context menu. + # + # there's only one right click context item right now, but in the + # future i'm sure there will be more. + # + + class FilterMenu(QtCore.QObject): + def __init__(self, qmenu): + super(QtCore.QObject, self).__init__() + self.qmenu = qmenu + + def eventFilter(self, obj, event): + if event.type() != QtCore.QEvent.Polish: + return False + for action in self.qmenu.actions(): + if action.text() in ["&Font...", "&Synchronize with"]: # lol.. + qmenu.removeAction(action) + self.qmenu.removeEventFilter(self) + self.qmenu = None + return True + + p_qmenu = ctypes.cast(int(popup), ctypes.POINTER(ctypes.c_void_p))[0] + qmenu = sip.wrapinstance(int(p_qmenu), QtWidgets.QMenu) + filter = FilterMenu(qmenu) + qmenu.installEventFilter(filter) + + # return the filter as I think we need to maintain its lifetime in py + return filter diff --git a/plugins/patching/util/misc.py b/plugins/patching/util/misc.py new file mode 100644 index 0000000..76bf244 --- /dev/null +++ b/plugins/patching/util/misc.py @@ -0,0 +1,53 @@ +import os + +#------------------------------------------------------------------------------ +# Plugin Util +#------------------------------------------------------------------------------ + +PLUGIN_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + +def plugin_resource(resource_name): + """ + Return the full path for a given plugin resource file. + """ + return os.path.join( + PLUGIN_PATH, + "ui", + "resources", + resource_name + ) + +#------------------------------------------------------------------------------ +# Misc / OS Util +#------------------------------------------------------------------------------ + +def is_file_locked(filepath): + """ + Checks to see if a file is locked. Performs three checks + + 1. Checks if the file even exists + + 2. Attempts to open the file for reading. This will determine if the + file has a write lock. Write locks occur when the file is being + edited or copied to, e.g. a file copy destination + + 3. Attempts to rename the file. If this fails the file is open by some + other process for reading. The file can be read, but not written to + or deleted. + + Not perfect, but it doesn't have to be. Source: https://stackoverflow.com/a/63761161 + """ + if not (os.path.exists(filepath)): + return False + + try: + f = open(filepath, 'r') + f.close() + except IOError: + return True + + try: + os.rename(filepath, filepath) + return False + except WindowsError: + return True diff --git a/plugins/patching/util/python.py b/plugins/patching/util/python.py new file mode 100644 index 0000000..da7f679 --- /dev/null +++ b/plugins/patching/util/python.py @@ -0,0 +1,228 @@ +import sys +import struct +import weakref + +from types import ModuleType +from importlib import reload + +#------------------------------------------------------------------------------ +# Python helpers +#------------------------------------------------------------------------------ + +def hexdump(data, wrap=0): + """ + Return a spaced string of printed hex bytes for the given data. + """ + wrap = wrap if wrap else len(data) + if not data: + return '' + + lines = [] + for i in range(0, len(data), wrap): + lines.append(' '.join(['%02X' % x for x in data[i:i+wrap]])) + + return '\n'.join(lines) + +def swap_value(value, size): + """ + Swap endianness of a given value in memory. (size width in bytes) + """ + if size == 1: + return value + if size == 2: + return struct.unpack("H", value))[0] + if size == 4: + return struct.unpack("I", value))[0] + if size == 8: + return struct.unpack("Q", value))[0] + if size == 16: + lower64 = swap_value(value & ((1 << 64) - 1), 8) + upper64 = swap_value((value >> 64), 8) + return (lower64 << 64) | upper64 + raise ValueError("Invalid input (value %X and size %u" % (value, size)) + +#------------------------------------------------------------------------------ +# Python Callback / Signals +#------------------------------------------------------------------------------ + +def register_callback(callback_list, callback): + """ + Register a callable function to the given callback_list. + + Adapted from http://stackoverflow.com/a/21941670 + """ + + # create a weakref callback to an object method + try: + callback_ref = weakref.ref(callback.__func__), weakref.ref(callback.__self__) + + # create a wweakref callback to a stand alone function + except AttributeError: + callback_ref = weakref.ref(callback), None + + # 'register' the callback + callback_list.append(callback_ref) + +def notify_callback(callback_list, *args): + """ + Notify the given list of registered callbacks of an event. + + The given list (callback_list) is a list of weakref'd callables + registered through the register_callback() function. To notify the + callbacks of an event, this function will simply loop through the list + and call them. + + This routine self-heals by removing dead callbacks for deleted objects as + it encounters them. + + Adapted from http://stackoverflow.com/a/21941670 + """ + cleanup = [] + + # + # loop through all the registered callbacks in the given callback_list, + # notifying active callbacks, and removing dead ones. + # + + for callback_ref in callback_list: + callback, obj_ref = callback_ref[0](), callback_ref[1] + + # + # if the callback is an instance method, deference the instance + # (an object) first to check that it is still alive + # + + if obj_ref: + obj = obj_ref() + + # if the object instance is gone, mark this callback for cleanup + if obj is None: + cleanup.append(callback_ref) + continue + + # call the object instance callback + try: + callback(obj, *args) + + # assume a Qt cleanup/deletion occurred + except RuntimeError as e: + cleanup.append(callback_ref) + continue + + # if the callback is a static method... + else: + + # if the static method is deleted, mark this callback for cleanup + if callback is None: + cleanup.append(callback_ref) + continue + + # call the static callback + callback(*args) + + # remove the deleted callbacks + for callback_ref in cleanup: + callback_list.remove(callback_ref) + +#------------------------------------------------------------------------------ +# Module Reloading +#------------------------------------------------------------------------------ + +# +# NOTE: these are mostly for DEV / testing and are not required for the +# plugin to actually function. these basically enable hot-reloading plugins +# under the right conditions +# + +def reload_package(target_module): + """ + Recursively reload a 'stateless' python module / package. + """ + target_name = target_module.__name__ + visited_modules = {target_name: target_module} + _recursive_reload(target_module, target_name, visited_modules) + +def _scrape_module_objects(module): + """ + Scrape objects from a given module. + """ + ignore = {"__builtins__", "__cached__", "__doc__", "__file__", "__loader__", "__name__", "__package__", "__spec__", "__path__"} + values = [] + + # scrape objects from the module + for attribute_name in dir(module): + + # skip objects/refs we don't care about + if attribute_name in ignore: + continue + + # fetch the object/class/item definition from the module by its name + attribute_value = getattr(module, attribute_name) + + # TODO: set/dict/other iterables? + if type(attribute_value) == list: + for item in attribute_value: + values.append(item) + else: + values.append(attribute_value) + + # return all the 'interesting' objects scraped from the module + return values + +def _recursive_reload(module, target_name, visited): + #print("entered", module.__name__) + + # XXX: lol, ignore reloading keystone for now (it probably isn't changing anyway) + if 'keystone' in module.__name__: + #reload(module) + return + + visited[module.__name__] = module + module_objects = _scrape_module_objects(module) + + for obj in module_objects: + + # ignore simple types + if type(obj) in [str, int, bytes, bool]: + continue + + if type(obj) == ModuleType: + attribute_module_name = obj.__name__ + attribute_module = obj + + elif callable(obj): + attribute_module_name = obj.__module__ + attribute_module = sys.modules[attribute_module_name] + + # TODO: recursive list obj scraping... / introspection + elif type(obj) in [list, set, dict, tuple]: + continue + + # + # NOTE/XXX: something changed with IDA 7.7 ish to warrant this (module + # wrappers?) really this should just be something that the ModuleType + # conditional above catches... + # + + elif obj.__name__.startswith('ida'): + continue + + # fail + else: + raise ValueError("UNKNOWN TYPE TO RELOAD %s %s" % (obj, type(obj))) + + if not target_name in attribute_module_name: + #print(" - Not a module of interest...") + continue + + if "__plugins__" in attribute_module_name: + #print(" - Skipping IDA base plugin module...") + continue + + if attribute_module_name in visited: + continue + + _recursive_reload(attribute_module, target_name, visited) + + #print("Okay done with %s, reloading self!" % module.__name__) + reload(module) diff --git a/plugins/patching/util/qt.py b/plugins/patching/util/qt.py new file mode 100644 index 0000000..bf0093b --- /dev/null +++ b/plugins/patching/util/qt.py @@ -0,0 +1,59 @@ +# +# this global is used to indicate whether Qt bindings for python are present +# and whether the plugin should expect to be using UI features +# + +QT_AVAILABLE = False + +# attempt to load PyQt5 +try: + import PyQt5.QtGui as QtGui + import PyQt5.QtCore as QtCore + import PyQt5.QtWidgets as QtWidgets + from PyQt5 import sip + + # importing PyQt5 went okay, let's see if we're in an IDA Qt context + try: + import ida_kernwin + QT_AVAILABLE = ida_kernwin.is_idaq() + except ImportError: + pass + +# import failed, PyQt5 is not available +except ImportError: + pass + +#-------------------------------------------------------------------------- +# Qt Misc Helpers +#-------------------------------------------------------------------------- + +def get_main_window(): + """ + Return the Qt Main Window. + """ + app = QtWidgets.QApplication.instance() + for widget in app.topLevelWidgets(): + if isinstance(widget, QtWidgets.QMainWindow): + return widget + return None + +def center_widget(widget): + """ + Center the given widget to the Qt Main Window. + """ + main_window = get_main_window() + if not main_window: + return False + + # + # compute a new position for the floating widget such that it will center + # over the Qt application's main window + # + + rect_main = main_window.geometry() + rect_widget = widget.rect() + + centered_position = rect_main.center() - rect_widget.center() + widget.move(centered_position) + + return True diff --git a/screenshots/assemble.gif b/screenshots/assemble.gif new file mode 100644 index 0000000..a2234b6 Binary files /dev/null and b/screenshots/assemble.gif differ diff --git a/screenshots/clobber.png b/screenshots/clobber.png new file mode 100644 index 0000000..be65680 Binary files /dev/null and b/screenshots/clobber.png differ diff --git a/screenshots/forcejump.gif b/screenshots/forcejump.gif new file mode 100644 index 0000000..650e8ce Binary files /dev/null and b/screenshots/forcejump.gif differ diff --git a/screenshots/nop.gif b/screenshots/nop.gif new file mode 100644 index 0000000..8a73a9e Binary files /dev/null and b/screenshots/nop.gif differ diff --git a/screenshots/revert.gif b/screenshots/revert.gif new file mode 100644 index 0000000..fd6d449 Binary files /dev/null and b/screenshots/revert.gif differ diff --git a/screenshots/save.gif b/screenshots/save.gif new file mode 100644 index 0000000..b4e0595 Binary files /dev/null and b/screenshots/save.gif differ diff --git a/screenshots/title.png b/screenshots/title.png new file mode 100644 index 0000000..463597f Binary files /dev/null and b/screenshots/title.png differ diff --git a/screenshots/usage.gif b/screenshots/usage.gif new file mode 100644 index 0000000..8c048a0 Binary files /dev/null and b/screenshots/usage.gif differ