├── plugins ├── patching │ ├── util │ │ ├── __init__.py │ │ ├── qt.py │ │ ├── misc.py │ │ ├── python.py │ │ └── ida.py │ ├── __init__.py │ ├── ui │ │ ├── resources │ │ │ ├── nop.png │ │ │ ├── save.png │ │ │ ├── revert.png │ │ │ ├── assemble.png │ │ │ └── forcejump.png │ │ ├── save.py │ │ ├── save_ui.py │ │ ├── preview.py │ │ └── preview_ui.py │ ├── exceptions.py │ ├── keystone │ │ └── README.md │ ├── actions.py │ ├── asm.py │ └── core.py └── patching.py ├── screenshots ├── nop.gif ├── save.gif ├── title.png ├── usage.gif ├── assemble.gif ├── clobber.png ├── revert.gif └── forcejump.gif ├── LICENSE ├── .github └── workflows │ └── package-plugin.yaml ├── .gitignore ├── README.md └── install.py /plugins/patching/util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugins/patching/__init__.py: -------------------------------------------------------------------------------- 1 | from patching.core import PatchingCore -------------------------------------------------------------------------------- /screenshots/nop.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/HEAD/screenshots/nop.gif -------------------------------------------------------------------------------- /screenshots/save.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/HEAD/screenshots/save.gif -------------------------------------------------------------------------------- /screenshots/title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/HEAD/screenshots/title.png -------------------------------------------------------------------------------- /screenshots/usage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/HEAD/screenshots/usage.gif -------------------------------------------------------------------------------- /screenshots/assemble.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/HEAD/screenshots/assemble.gif -------------------------------------------------------------------------------- /screenshots/clobber.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/HEAD/screenshots/clobber.png -------------------------------------------------------------------------------- /screenshots/revert.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/HEAD/screenshots/revert.gif -------------------------------------------------------------------------------- /screenshots/forcejump.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/HEAD/screenshots/forcejump.gif -------------------------------------------------------------------------------- /plugins/patching/ui/resources/nop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/HEAD/plugins/patching/ui/resources/nop.png -------------------------------------------------------------------------------- /plugins/patching/ui/resources/save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/HEAD/plugins/patching/ui/resources/save.png -------------------------------------------------------------------------------- /plugins/patching/ui/resources/revert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/HEAD/plugins/patching/ui/resources/revert.png -------------------------------------------------------------------------------- /plugins/patching/ui/resources/assemble.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/HEAD/plugins/patching/ui/resources/assemble.png -------------------------------------------------------------------------------- /plugins/patching/ui/resources/forcejump.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/HEAD/plugins/patching/ui/resources/forcejump.png -------------------------------------------------------------------------------- /plugins/patching/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | #------------------------------------------------------------------------------ 3 | # Exception Definitions 4 | #------------------------------------------------------------------------------ 5 | 6 | class PatchingError(Exception): 7 | def __init__(self, *args, **kwargs): 8 | super().__init__(*args, **kwargs) 9 | 10 | class PatchBackupError(PatchingError): 11 | def __init__(self, message, filepath=''): 12 | super().__init__(message) 13 | self.filepath = filepath 14 | 15 | class PatchTargetError(PatchingError): 16 | def __init__(self, message, filepath): 17 | super().__init__(message) 18 | self.filepath = filepath 19 | 20 | class PatchApplicationError(PatchingError): 21 | def __init__(self, message, filepath): 22 | super().__init__(message) 23 | self.filepath = filepath -------------------------------------------------------------------------------- /plugins/patching/keystone/README.md: -------------------------------------------------------------------------------- 1 | # Keystone Engine (Patching) 2 | 3 | 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. 4 | 5 | This is simply out of convenience for distributing fixes or making breaking changes for the betterment of the plugin. 6 | 7 | # Why is this folder empty? 8 | 9 | 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. 10 | 11 | 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 ;-) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Markus Gaasedelen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/package-plugin.yaml: -------------------------------------------------------------------------------- 1 | name: Package IDA Plugin 📦 2 | 3 | on: push 4 | 5 | jobs: 6 | package: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | 11 | - name: Download workflow artifact 12 | uses: dawidd6/action-download-artifact@v6 13 | with: 14 | 15 | # the target repo for external artifacts (built libs) 16 | repo: gaasedelen/keystone 17 | branch: master 18 | 19 | # token to fetch artifacts from the repo 20 | github_token: ${{secrets.KEYSTONE_PATCHING_TOKEN}} 21 | 22 | # which workflow to search for artifacts 23 | workflow: python-publish.yml 24 | workflow_conclusion: success 25 | 26 | - name: Package distributions 27 | shell: bash 28 | run: | 29 | mkdir dist && cd dist 30 | mkdir win32 && cp -r ../plugins/* ./win32/ && cp -r ../artifact_windows-latest/* ./win32/patching/keystone && cd ./win32 && zip -r ../patching_win32.zip ./* && cd .. 31 | mkdir linux && cp -r ../plugins/* ./linux/ && cp -r ../artifact_ubuntu-latest/* ./linux/patching/keystone && cd ./linux && zip -r ../patching_linux.zip ./* && cd .. 32 | mkdir darwin && cp -r ../plugins/* ./darwin/ && cp -r ../artifact_macos-latest/* ./darwin/patching/keystone && cd ./darwin && zip -r ../patching_macos.zip ./* && cd .. 33 | 34 | - uses: actions/upload-artifact@v4 35 | with: 36 | path: ${{ github.workspace }}/dist/*.zip -------------------------------------------------------------------------------- /plugins/patching/util/qt.py: -------------------------------------------------------------------------------- 1 | # 2 | # this global is used to indicate whether Qt bindings for python are present 3 | # and whether the plugin should expect to be using UI features 4 | # 5 | 6 | QT_AVAILABLE = False 7 | 8 | # attempt to load PyQt5 9 | try: 10 | import PyQt5.QtGui as QtGui 11 | import PyQt5.QtCore as QtCore 12 | import PyQt5.QtWidgets as QtWidgets 13 | from PyQt5 import sip 14 | 15 | # importing PyQt5 went okay, let's see if we're in an IDA Qt context 16 | try: 17 | import ida_kernwin 18 | QT_AVAILABLE = ida_kernwin.is_idaq() 19 | except ImportError: 20 | pass 21 | 22 | # import failed, PyQt5 is not available 23 | except ImportError: 24 | pass 25 | 26 | #-------------------------------------------------------------------------- 27 | # Qt Misc Helpers 28 | #-------------------------------------------------------------------------- 29 | 30 | def get_main_window(): 31 | """ 32 | Return the Qt Main Window. 33 | """ 34 | app = QtWidgets.QApplication.instance() 35 | for widget in app.topLevelWidgets(): 36 | if isinstance(widget, QtWidgets.QMainWindow): 37 | return widget 38 | return None 39 | 40 | def center_widget(widget): 41 | """ 42 | Center the given widget to the Qt Main Window. 43 | """ 44 | main_window = get_main_window() 45 | if not main_window: 46 | return False 47 | 48 | # 49 | # compute a new position for the floating widget such that it will center 50 | # over the Qt application's main window 51 | # 52 | 53 | rect_main = main_window.geometry() 54 | rect_widget = widget.rect() 55 | 56 | centered_position = rect_main.center() - rect_widget.center() 57 | widget.move(centered_position) 58 | 59 | return True 60 | -------------------------------------------------------------------------------- /plugins/patching/util/misc.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | #------------------------------------------------------------------------------ 4 | # Plugin Util 5 | #------------------------------------------------------------------------------ 6 | 7 | PLUGIN_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 8 | 9 | def plugin_resource(resource_name): 10 | """ 11 | Return the full path for a given plugin resource file. 12 | """ 13 | return os.path.join( 14 | PLUGIN_PATH, 15 | "ui", 16 | "resources", 17 | resource_name 18 | ) 19 | 20 | #------------------------------------------------------------------------------ 21 | # Misc / OS Util 22 | #------------------------------------------------------------------------------ 23 | 24 | def is_file_locked(filepath): 25 | """ 26 | Checks to see if a file is locked. Performs three checks 27 | 28 | 1. Checks if the file even exists 29 | 30 | 2. Attempts to open the file for reading. This will determine if the 31 | file has a write lock. Write locks occur when the file is being 32 | edited or copied to, e.g. a file copy destination 33 | 34 | 3. Attempts to rename the file. If this fails the file is open by some 35 | other process for reading. The file can be read, but not written to 36 | or deleted. 37 | 38 | Not perfect, but it doesn't have to be. Source: https://stackoverflow.com/a/63761161 39 | """ 40 | if not (os.path.exists(filepath)): 41 | return False 42 | 43 | try: 44 | f = open(filepath, 'r') 45 | f.close() 46 | except IOError: 47 | return True 48 | 49 | try: 50 | os.rename(filepath, filepath) 51 | return False 52 | except WindowsError: 53 | return True 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # ida dev 132 | *.id0 133 | *.id1 134 | *.id2 135 | *.nam 136 | *.til 137 | *.idb 138 | *.i64 139 | 140 | # ida test suite 141 | samples/* 142 | plugins/patching/keystone/* -------------------------------------------------------------------------------- /plugins/patching.py: -------------------------------------------------------------------------------- 1 | #------------------------------------------------------------------------------ 2 | # Plugin Preflight 3 | #------------------------------------------------------------------------------ 4 | # 5 | # the purpose of this 'preflight' is to test if the plugin is compatible 6 | # with the environment it is being loaded in. specifically, these preflight 7 | # checks are designed to be compatible with IDA 7.0+ and Python 2/3 8 | # 9 | # if the environment does not meet the specifications required by the 10 | # plugin, this file will gracefully decline to load the plugin without 11 | # throwing noisy errors (besides a simple print to the IDA console) 12 | # 13 | # this makes it easy to install the plugin on machines with numerous 14 | # versions of IDA / Python / virtualenvs which employ a shared plugin 15 | # directory such as the 'preferred' IDAUSR plugin directory... 16 | # 17 | 18 | import sys 19 | 20 | # this plugin requires Python 3 21 | SUPPORTED_PYTHON = sys.version_info[0] == 3 22 | 23 | # this plugin requires IDA 7.6 or newer 24 | try: 25 | import ida_pro 26 | import ida_idaapi 27 | IDA_GLOBAL_SCOPE = sys.modules['__main__'] 28 | SUPPORTED_IDA = ida_pro.IDA_SDK_VERSION >= 760 29 | except: 30 | SUPPORTED_IDA = False 31 | 32 | # is this deemed to be a compatible environment for the plugin to load? 33 | SUPPORTED_ENVIRONMENT = bool(SUPPORTED_IDA and SUPPORTED_PYTHON) 34 | if not SUPPORTED_ENVIRONMENT: 35 | print("Patching plugin is not compatible with this IDA/Python version") 36 | 37 | #------------------------------------------------------------------------------ 38 | # IDA Plugin Stub 39 | #------------------------------------------------------------------------------ 40 | 41 | if SUPPORTED_ENVIRONMENT: 42 | import patching 43 | from patching.util.python import reload_package 44 | 45 | def PLUGIN_ENTRY(): 46 | """ 47 | Required plugin entry point for IDAPython plugins. 48 | """ 49 | return PatchingPlugin() 50 | 51 | class PatchingPlugin(ida_idaapi.plugin_t): 52 | """ 53 | The IDA Patching plugin stub. 54 | """ 55 | 56 | # 57 | # Plugin flags: 58 | # - PLUGIN_PROC: Load / unload this plugin when an IDB opens / closes 59 | # - PLUGIN_HIDE: Hide this plugin from the IDA plugin menu 60 | # - PLUGIN_UNL: Unload the plugin after calling run() 61 | # 62 | 63 | flags = ida_idaapi.PLUGIN_PROC | ida_idaapi.PLUGIN_HIDE | ida_idaapi.PLUGIN_UNL 64 | comment = "A plugin to enable binary patching in IDA" 65 | help = "" 66 | wanted_name = "Patching" 67 | wanted_hotkey = "" 68 | 69 | def __init__(self): 70 | self.__updated = getattr(IDA_GLOBAL_SCOPE, 'RESTART_REQUIRED', False) 71 | 72 | #-------------------------------------------------------------------------- 73 | # IDA Plugin Overloads 74 | #-------------------------------------------------------------------------- 75 | 76 | def init(self): 77 | """ 78 | This is called by IDA when it is loading the plugin. 79 | """ 80 | if not SUPPORTED_ENVIRONMENT or self.__updated: 81 | return ida_idaapi.PLUGIN_SKIP 82 | 83 | # load the plugin core 84 | self.core = patching.PatchingCore(defer_load=True) 85 | 86 | # inject a reference to the plugin context into the IDA console scope 87 | IDA_GLOBAL_SCOPE.patching = self 88 | 89 | # mark the plugin as loaded 90 | return ida_idaapi.PLUGIN_KEEP 91 | 92 | def run(self, arg): 93 | """ 94 | This is called by IDA when this file is loaded as a script. 95 | """ 96 | pass 97 | 98 | def term(self): 99 | """ 100 | This is called by IDA when it is unloading the plugin. 101 | """ 102 | try: 103 | self.core.unload() 104 | except Exception as e: 105 | pass 106 | self.core = None 107 | 108 | #-------------------------------------------------------------------------- 109 | # Development Helpers 110 | #-------------------------------------------------------------------------- 111 | 112 | def reload(self): 113 | """ 114 | Hot-reload the plugin. 115 | """ 116 | if self.core: 117 | self.core.unload() 118 | reload_package(patching) 119 | self.core = patching.PatchingCore() 120 | -------------------------------------------------------------------------------- /plugins/patching/ui/save.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | import ida_nalt 4 | 5 | from patching.util.qt import QT_AVAILABLE 6 | from patching.exceptions import * 7 | 8 | if QT_AVAILABLE: 9 | from patching.ui.save_ui import SaveDialog 10 | 11 | class SaveController(object): 12 | """ 13 | The backing logic & model (data) for the patch saving UI. 14 | """ 15 | WINDOW_TITLE = "Apply patches to..." 16 | 17 | def __init__(self, core, error=None): 18 | self.core = core 19 | self.view = None 20 | 21 | # init fields 22 | self._init_settings() 23 | 24 | # init error (if there was one that caused the dialog to pop) 25 | self.attempts = 1 if error else 0 26 | self._set_error(error) 27 | 28 | # only create the UI for the save dialog as needed 29 | if QT_AVAILABLE: 30 | self.view = SaveDialog(self) 31 | 32 | def _init_settings(self): 33 | """ 34 | Initialize dialog settings from the plugin core / IDA state. 35 | """ 36 | 37 | # inherit certain settings from the plugin core 38 | self.patch_cleanly = self.core.prefer_patch_cleanly 39 | self.quick_apply = self.core.prefer_quick_apply 40 | 41 | # the target file to patch / apply patches to 42 | self.target_filepath = self.core.patched_filepath 43 | if not self.target_filepath: 44 | self.target_filepath = ida_nalt.get_input_file_path() 45 | 46 | def _set_error(self, exception): 47 | """ 48 | Set the save dialog error text based on the given exception. 49 | """ 50 | 51 | # no error given, reset message text / color fields 52 | if exception is None: 53 | self.status_message = '' 54 | self.status_color = '' 55 | return 56 | 57 | # 58 | # something went wrong trying to ensure a usable backup / clean 59 | # executable was available for the patching operation. this should 60 | # only ever occur when the user is attempting to 'patch cleanly' 61 | # 62 | # this is most likely because the plugin could not locate a clean 63 | # version of the executable on disk. if the user would like to try 64 | # yolo-patching the target file, they can un-check 'Patch cleanly' 65 | # 66 | 67 | if isinstance(exception, PatchBackupError): 68 | self.status_message = str(exception) + "\nDisable 'Patch cleanly' to try patching anyway (att #%u)" % self.attempts 69 | self.status_color = 'red' 70 | 71 | # 72 | # something went wrong explicitly trying to modify the target / output 73 | # file for the patching operation. 74 | # 75 | # this is most likely because the file is locked, but the target file 76 | # could also be missing (among other reasons) 77 | # 78 | 79 | elif isinstance(exception, PatchTargetError) or isinstance(exception, PatchApplicationError): 80 | self.status_message = str(exception) + "\nIs the filepath above locked? or missing? (att #%u)" % self.attempts 81 | self.status_color = 'red' 82 | 83 | # unknown / unhandled error? 84 | else: 85 | self.status_message = "Unknown error? (att #%u)\n%s" % (self.attempts, str(exception)) 86 | self.status_color = 'red' 87 | 88 | #-------------------------------------------------------------------------- 89 | # Actions 90 | #-------------------------------------------------------------------------- 91 | 92 | def interactive(self): 93 | """ 94 | Spawn an interactive user dialog and wait for it to close. 95 | """ 96 | if not self.view: 97 | return False 98 | return self.view.exec_() 99 | 100 | def attempt_patch(self, target_filepath, clean): 101 | """ 102 | Attempt to patch the target binary. 103 | """ 104 | 105 | # 106 | # increment the 'patch attempt' count over the lifetime of this 107 | # dialog. the purpose of this counter is simple: it is a visual 108 | # cue to users who will continue to mash the 'Apply Patches' 109 | # button even in the face of a big red error message. 110 | # 111 | # the idea is that (hopefully) they will see this 'attempt count' 112 | # updating in the otherwise static error message text to indicate 113 | # that 'yes, the file is still locked/unavailabe/missing' until 114 | # they go rectify the issue 115 | # 116 | 117 | self.attempts += 1 118 | 119 | # 120 | # attempt to apply patches to the target file on behalf of the 121 | # interactive dialog / user request 122 | # 123 | 124 | try: 125 | self.core.apply_patches(target_filepath, clean) 126 | except Exception as e: 127 | self._set_error(e) 128 | return False 129 | 130 | # 131 | # if we made it this far, patching must have succeeded, save patch 132 | # settings to the core plugin 133 | # 134 | 135 | self.status_message = '' 136 | self.core.prefer_patch_cleanly = self.patch_cleanly 137 | self.core.prefer_quick_apply = self.quick_apply 138 | 139 | # return success 140 | return True 141 | 142 | def update_target(self, target_filepath): 143 | """ 144 | Update the targeted filepath. 145 | """ 146 | self.target_filepath = target_filepath 147 | if self.patch_cleanly: 148 | return 149 | 150 | # 151 | # if the UI setting for 'Patch cleanly' is explicitly unchecked but 152 | # the user *just* updated the target filepath via file dialog, we 153 | # will quickly try to check if the selected file appears to be 154 | # a good candidate for making a copy (backup) of during the likely 155 | # imminent patch save / application operation 156 | # 157 | 158 | try: 159 | disk_md5 = hashlib.md5(open(target_filepath, 'rb').read()).digest() 160 | except Exception: 161 | return 162 | 163 | # the MD5 hash of the file (executable) used to generate this IDB 164 | input_md5 = ida_nalt.retrieve_input_file_md5() 165 | if input_md5 != disk_md5: 166 | return 167 | 168 | # 169 | # at this point, the user has explicitly selected a patch target that 170 | # appears to be clean, yet they have 'Patch cleanly' disabled, so we 171 | # should provide them with a 'soft' hint / warning that it would be 172 | # best for them to turn 'Patch cleanly' back on... 173 | # 174 | 175 | self.status_message = "The patch target appears to be a clean executable,\nit is recommended you turn on 'Patch cleanly'" 176 | self.status_color = 'orange' 177 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Patching - Interactive Binary Patching for IDA Pro 2 | 3 |








Q", value))[0] 38 | if size == 16: 39 | lower64 = swap_value(value & ((1 << 64) - 1), 8) 40 | upper64 = swap_value((value >> 64), 8) 41 | return (lower64 << 64) | upper64 42 | raise ValueError("Invalid input (value %X and size %u" % (value, size)) 43 | 44 | #------------------------------------------------------------------------------ 45 | # Python Callback / Signals 46 | #------------------------------------------------------------------------------ 47 | 48 | def register_callback(callback_list, callback): 49 | """ 50 | Register a callable function to the given callback_list. 51 | 52 | Adapted from http://stackoverflow.com/a/21941670 53 | """ 54 | 55 | # create a weakref callback to an object method 56 | try: 57 | callback_ref = weakref.ref(callback.__func__), weakref.ref(callback.__self__) 58 | 59 | # create a wweakref callback to a stand alone function 60 | except AttributeError: 61 | callback_ref = weakref.ref(callback), None 62 | 63 | # 'register' the callback 64 | callback_list.append(callback_ref) 65 | 66 | def notify_callback(callback_list, *args): 67 | """ 68 | Notify the given list of registered callbacks of an event. 69 | 70 | The given list (callback_list) is a list of weakref'd callables 71 | registered through the register_callback() function. To notify the 72 | callbacks of an event, this function will simply loop through the list 73 | and call them. 74 | 75 | This routine self-heals by removing dead callbacks for deleted objects as 76 | it encounters them. 77 | 78 | Adapted from http://stackoverflow.com/a/21941670 79 | """ 80 | cleanup = [] 81 | 82 | # 83 | # loop through all the registered callbacks in the given callback_list, 84 | # notifying active callbacks, and removing dead ones. 85 | # 86 | 87 | for callback_ref in callback_list: 88 | callback, obj_ref = callback_ref[0](), callback_ref[1] 89 | 90 | # 91 | # if the callback is an instance method, deference the instance 92 | # (an object) first to check that it is still alive 93 | # 94 | 95 | if obj_ref: 96 | obj = obj_ref() 97 | 98 | # if the object instance is gone, mark this callback for cleanup 99 | if obj is None: 100 | cleanup.append(callback_ref) 101 | continue 102 | 103 | # call the object instance callback 104 | try: 105 | callback(obj, *args) 106 | 107 | # assume a Qt cleanup/deletion occurred 108 | except RuntimeError as e: 109 | cleanup.append(callback_ref) 110 | continue 111 | 112 | # if the callback is a static method... 113 | else: 114 | 115 | # if the static method is deleted, mark this callback for cleanup 116 | if callback is None: 117 | cleanup.append(callback_ref) 118 | continue 119 | 120 | # call the static callback 121 | callback(*args) 122 | 123 | # remove the deleted callbacks 124 | for callback_ref in cleanup: 125 | callback_list.remove(callback_ref) 126 | 127 | #------------------------------------------------------------------------------ 128 | # Module Reloading 129 | #------------------------------------------------------------------------------ 130 | 131 | # 132 | # NOTE: these are mostly for DEV / testing and are not required for the 133 | # plugin to actually function. these basically enable hot-reloading plugins 134 | # under the right conditions 135 | # 136 | 137 | def reload_package(target_module): 138 | """ 139 | Recursively reload a 'stateless' python module / package. 140 | """ 141 | target_name = target_module.__name__ 142 | visited_modules = {target_name: target_module} 143 | _recursive_reload(target_module, target_name, visited_modules) 144 | 145 | def _scrape_module_objects(module): 146 | """ 147 | Scrape objects from a given module. 148 | """ 149 | ignore = {"__builtins__", "__cached__", "__doc__", "__file__", "__loader__", "__name__", "__package__", "__spec__", "__path__"} 150 | values = [] 151 | 152 | # scrape objects from the module 153 | for attribute_name in dir(module): 154 | 155 | # skip objects/refs we don't care about 156 | if attribute_name in ignore: 157 | continue 158 | 159 | # fetch the object/class/item definition from the module by its name 160 | attribute_value = getattr(module, attribute_name) 161 | 162 | # TODO: set/dict/other iterables? 163 | if type(attribute_value) == list: 164 | for item in attribute_value: 165 | values.append(item) 166 | else: 167 | values.append(attribute_value) 168 | 169 | # return all the 'interesting' objects scraped from the module 170 | return values 171 | 172 | def _recursive_reload(module, target_name, visited): 173 | #print("entered", module.__name__) 174 | 175 | # XXX: lol, ignore reloading keystone for now (it probably isn't changing anyway) 176 | if 'keystone' in module.__name__: 177 | #reload(module) 178 | return 179 | 180 | visited[module.__name__] = module 181 | module_objects = _scrape_module_objects(module) 182 | 183 | for obj in module_objects: 184 | 185 | # ignore simple types 186 | if type(obj) in [str, int, bytes, bool]: 187 | continue 188 | 189 | if type(obj) == ModuleType: 190 | attribute_module_name = obj.__name__ 191 | attribute_module = obj 192 | 193 | elif callable(obj): 194 | attribute_module_name = obj.__module__ 195 | attribute_module = sys.modules[attribute_module_name] 196 | 197 | # TODO: recursive list obj scraping... / introspection 198 | elif type(obj) in [list, set, dict, tuple]: 199 | continue 200 | 201 | # 202 | # NOTE/XXX: something changed with IDA 7.7 ish to warrant this (module 203 | # wrappers?) really this should just be something that the ModuleType 204 | # conditional above catches... 205 | # 206 | 207 | elif obj.__name__.startswith('ida'): 208 | continue 209 | 210 | # fail 211 | else: 212 | raise ValueError("UNKNOWN TYPE TO RELOAD %s %s" % (obj, type(obj))) 213 | 214 | if not target_name in attribute_module_name: 215 | #print(" - Not a module of interest...") 216 | continue 217 | 218 | if "__plugins__" in attribute_module_name: 219 | #print(" - Skipping IDA base plugin module...") 220 | continue 221 | 222 | if attribute_module_name in visited: 223 | continue 224 | 225 | _recursive_reload(attribute_module, target_name, visited) 226 | 227 | #print("Okay done with %s, reloading self!" % module.__name__) 228 | reload(module) 229 | -------------------------------------------------------------------------------- /install.py: -------------------------------------------------------------------------------- 1 | #------------------------------------------------------------------------------ 2 | # Script Preflight 3 | #------------------------------------------------------------------------------ 4 | 5 | # this plugin requires Python 3 6 | try: 7 | import os 8 | import sys 9 | import glob 10 | import json 11 | import shutil 12 | import zipfile 13 | import urllib.request 14 | from pathlib import Path 15 | SUPPORTED_PYTHON = sys.version_info[0] == 3 16 | except: 17 | SUPPORTED_PYTHON = False 18 | 19 | # this plugin requires IDA 7.6 or newer 20 | try: 21 | import ida_pro 22 | import ida_diskio 23 | import ida_loader 24 | IDA_GLOBAL_SCOPE = sys.modules['__main__'] 25 | SUPPORTED_IDA = ida_pro.IDA_SDK_VERSION >= 760 26 | except: 27 | SUPPORTED_IDA = False 28 | 29 | # 30 | # XXX/NOTE: older versions of IDA have compatability issues with newer 31 | # versions of Python. if the user is running IDA 8.2 or below and Python 3.11 32 | # or above, we will not proceed with installation. 33 | # 34 | # https://hex-rays.com/products/ida/news/8_2sp1/ 35 | # https://github.com/gaasedelen/patching/issues/10 36 | # https://github.com/gaasedelen/patching/issues/16 37 | # ... 38 | # 39 | 40 | if SUPPORTED_IDA and ida_pro.IDA_SDK_VERSION < 830: 41 | SUPPORTED_PYTHON = sys.version_info[0] == 3 and sys.version_info[1] < 11 42 | if not SUPPORTED_PYTHON: 43 | print("[i] IDA 8.2 and below do not support Python 3.11 and above") 44 | 45 | # is this deemed to be a compatible environment for the plugin to load? 46 | SUPPORTED_ENVIRONMENT = bool(SUPPORTED_IDA and SUPPORTED_PYTHON) 47 | 48 | #------------------------------------------------------------------------------ 49 | # IDA Plugin Installer 50 | #------------------------------------------------------------------------------ 51 | 52 | PLUGIN_NAME = 'Patching' 53 | PLUGIN_URL = 'https://api.github.com/repos/gaasedelen/patching/releases/latest' 54 | 55 | def install_plugin(): 56 | """ 57 | Auto-install plugin (or update it). 58 | """ 59 | print("[*] Starting auto installer for '%s' plugin..." % PLUGIN_NAME) 60 | 61 | # ensure the user plugin directory exists 62 | plugins_directory = os.path.join(ida_diskio.get_user_idadir(), 'plugins') 63 | Path(plugins_directory).mkdir(parents=True, exist_ok=True) 64 | 65 | # special handling to rename 'darwin' to macos (a bit more friendly) 66 | platform_name = sys.platform 67 | if platform_name == 'darwin': 68 | platform_name = 'macos' 69 | 70 | # compute the full filename of the plugin package to download from git 71 | package_name = 'patching_%s.zip' % platform_name 72 | 73 | # fetch the plugin download info from the latest github releases 74 | print("[*] Fetching info from GitHub...") 75 | try: 76 | release_json = urllib.request.urlopen(PLUGIN_URL).read() 77 | release_info = json.loads(release_json) 78 | release_tag = release_info['tag_name'] 79 | except: 80 | print("[-] Failed to fetch info from GitHub") 81 | return False 82 | 83 | # locate the git asset info that matches our desired plugin package 84 | for asset in release_info['assets']: 85 | if asset['name'] == package_name: 86 | break 87 | else: 88 | print("[-] Failed to locate asset '%s' in latest GitHub release" % package_name) 89 | return False 90 | 91 | print("[*] Downloading %s..." % package_name) 92 | 93 | try: 94 | package_url = asset['browser_download_url'] 95 | package_data = urllib.request.urlopen(package_url).read() 96 | package_path = os.path.join(plugins_directory, package_name) 97 | except Exception as e: 98 | print("[-] Failed to download %s\nError: %s" % (package_url, e)) 99 | return False 100 | 101 | print("[*] Saving %s to disk..." % package_name) 102 | try: 103 | with open(package_path, 'wb') as f: 104 | f.write(package_data) 105 | except: 106 | print("[-] Failed to write to %s" % package_path) 107 | return False 108 | 109 | patching_directory = os.path.join(plugins_directory, 'patching') 110 | keystone_directory = os.path.join(patching_directory, 'keystone') 111 | 112 | # 113 | # if the plugin is already installed into this environment, a few more 114 | # steps are required to ensure we can replace the existing version 115 | # 116 | 117 | if os.path.exists(patching_directory): 118 | 119 | # 120 | # contrary to what this sort of looks like, load_and_run_plugin() 121 | # will execute and UNLOAD our plugin (if it is in-use) because 122 | # our plugin has been marked with the PLUGIN_UNL flag 123 | # 124 | # NOTE: this is basically just us asking IDA nicely to unload our 125 | # plugin in a best effort to keep things clean 126 | # 127 | 128 | if ida_loader.find_plugin(PLUGIN_NAME, False): 129 | print("[*] Unloading plugin core...") 130 | ida_loader.load_and_run_plugin(PLUGIN_NAME, 0) 131 | 132 | # 133 | # pay special attention when trying to remove Keystone. this is the 134 | # most likely point in failure for the entire plugin update/install 135 | # 136 | # even if the plugin is not in use, the Keystone DLL / lib will be 137 | # loaded into memory by nature of Python imports. we are going to 138 | # try and AGGRESSIVELY unload it such that we can overwrite it 139 | # 140 | # because this is pretty dangerous, we set this flag to ensure the 141 | # patching plugin is completeley neutered and cannot be used in any 142 | # form until IDA is restarted 143 | # 144 | 145 | IDA_GLOBAL_SCOPE.RESTART_REQUIRED = True 146 | 147 | print("[*] Removing existing plugin...") 148 | if not remove_keystone(keystone_directory): 149 | print("[-] Could not remove Keystone (file locked?)") 150 | print("[!] Please ensure no other instance of IDA are running and try again...") 151 | return False 152 | 153 | # remove the rest of the plugin only IF removing Keystone succeeded 154 | shutil.rmtree(patching_directory) 155 | 156 | # 157 | # now we can resume with the actual plugin update / installation 158 | # 159 | 160 | print("[*] Unzipping %s..." % package_name) 161 | try: 162 | with zipfile.ZipFile(package_path, "r") as zip_ref: 163 | zip_ref.extractall(plugins_directory) 164 | except: 165 | print("[-] Failed to unzip %s to %s" % (package_name, plugins_directory)) 166 | return False 167 | 168 | print("[+] %s %s installed successfully!" % (PLUGIN_NAME, release_tag)) 169 | 170 | # try and remove the downloaded zip (cleanup) 171 | try: 172 | os.remove(package_path) 173 | except: 174 | pass 175 | 176 | # do not attempt to load the newly installed plugin if we just updated 177 | if getattr(IDA_GLOBAL_SCOPE, 'RESTART_REQUIRED', False): 178 | print("[!] Restart IDA to use the updated plugin") 179 | return True 180 | 181 | # load the plugin if this was a fresh install 182 | plugin_path = os.path.join(plugins_directory, 'patching.py') 183 | ida_loader.load_plugin(plugin_path) 184 | 185 | # if a database appears open, force plugin core to load immediately 186 | if ida_loader.get_path(ida_loader.PATH_TYPE_IDB): 187 | IDA_GLOBAL_SCOPE.patching.core.load() 188 | 189 | return True 190 | 191 | def remove_keystone(keystone_directory): 192 | """ 193 | Delete the Keystone directory at the given path and return True on success. 194 | """ 195 | if sys.platform == 'win32': 196 | lib_paths = [os.path.join(keystone_directory, 'keystone.dll')] 197 | else: 198 | lib_paths = glob.glob(os.path.join(keystone_directory, 'libkeystone*')) 199 | 200 | # 201 | # it is critical we try and delete the Keystone library first as it can 202 | # be locked by IDA / Python. if we cannot delete the Keystone library 203 | # on-disk, then there is no point in proceeding with the update. 204 | # 205 | # in a rather aggressive approach to force the Keystone library to unlock, 206 | # we forcefully unload the backing library from python. this is obviously 207 | # dangerous, but the plugin should be completely deactivated by this point 208 | # 209 | 210 | try: 211 | 212 | # 213 | # attempt to get the handle of the loaded Keystone library and 214 | # forcefully unload it 215 | # 216 | 217 | import _ctypes 218 | 219 | keystone = sys.modules['patching.keystone'] 220 | lib_file = keystone.keystone._ks._name 221 | _ctypes.FreeLibrary(keystone.keystone._ks._handle) 222 | 223 | # 224 | # failing to delete the library from disk here means that another 225 | # instance of IDA is is probably still running, keeping it locked 226 | # 227 | 228 | os.remove(lib_file) 229 | 230 | except: 231 | pass 232 | 233 | # 234 | # for good measure, go over all the expected Keystone library files on 235 | # disk and attempt to remove them 236 | # 237 | 238 | lib_still_exists = [] 239 | for lib_file in lib_paths: 240 | try: 241 | os.remove(lib_file) 242 | except: 243 | pass 244 | lib_still_exists.append(os.path.exists(lib_file)) 245 | 246 | # if the library still exist after all this, the update will be canceled 247 | if any(lib_still_exists): 248 | return False 249 | 250 | # 251 | # deleting the library appears to have been successful, now delete the 252 | # rest of the Keystone directory. 253 | # 254 | 255 | try: 256 | shutil.rmtree(keystone_directory) 257 | except: 258 | pass 259 | 260 | # return True if Keystone was successfully deleted 261 | return not(os.path.exists(keystone_directory)) 262 | 263 | #------------------------------------------------------------------------------ 264 | # IDA Plugin Installer 265 | #------------------------------------------------------------------------------ 266 | 267 | if SUPPORTED_ENVIRONMENT: 268 | install_plugin() 269 | else: 270 | print("[-] Plugin is not compatible with this IDA/Python version") -------------------------------------------------------------------------------- /plugins/patching/ui/preview.py: -------------------------------------------------------------------------------- 1 | import ida_name 2 | import ida_bytes 3 | import ida_lines 4 | import ida_idaapi 5 | import ida_kernwin 6 | 7 | from patching.util.qt import QT_AVAILABLE 8 | from patching.util.ida import parse_disassembly_components, scrape_symbols 9 | from patching.util.python import hexdump 10 | 11 | if QT_AVAILABLE: 12 | from patching.ui.preview_ui import PatchingDockable 13 | 14 | LAST_LINE_IDX = -1 15 | 16 | class PatchingController(object): 17 | """ 18 | The backing logic & model (data) for the patch editing UI. 19 | """ 20 | WINDOW_TITLE = "Patching" 21 | 22 | def __init__(self, core, ea=ida_idaapi.BADADDR): 23 | self.core = core 24 | self.view = None 25 | 26 | # 27 | # if no context (an address to patch at) was provided, use IDA's 28 | # current cursor position instead as the origin for the dialog 29 | # 30 | 31 | if ea == ida_idaapi.BADADDR: 32 | ea = ida_kernwin.get_screen_ea() 33 | 34 | self._address_origin = ida_bytes.get_item_head(ea) 35 | 36 | # public properties 37 | self.address = self._address_origin 38 | self.address_idx = LAST_LINE_IDX 39 | self.assembly_text = '' 40 | self.assembly_bytes = b'' 41 | 42 | # for error text or other dynamic information to convey to the user 43 | self.status_message = '' 44 | 45 | # do an initial 'refresh' to populate data for the patching dialog 46 | self.refresh() 47 | 48 | # connect signals from the plugin core to the patching dialog 49 | self.core.patches_changed(self.refresh) 50 | 51 | # only create the UI for the patching dialog as needed 52 | if QT_AVAILABLE: 53 | self.view = PatchingDockable(self) 54 | self.view.Show() 55 | 56 | #------------------------------------------------------------------------- 57 | # Actions 58 | #------------------------------------------------------------------------- 59 | 60 | def select_address(self, ea, idx=LAST_LINE_IDX): 61 | """ 62 | Select the given address. 63 | """ 64 | insn, lineno = self.get_insn_lineno(ea) 65 | 66 | # if the target instruction does not exist 67 | if insn.address != ea: 68 | idx = LAST_LINE_IDX 69 | 70 | # 71 | # clear all clobber highlights if the cursor is moving to a new line 72 | # 73 | # TODO/NOTE: this feels a bit dirty / out of place. there is probably 74 | # a place for it that is more appropriate 75 | # 76 | 77 | if insn.address != self.address or self.address_idx != idx: 78 | for insn_cur in self.instructions: 79 | insn_cur.clobbered = False 80 | 81 | self.address = insn.address 82 | self.address_idx = idx 83 | 84 | self._update_assembly_text(self.core.assembler.format_assembly(insn.address)) 85 | 86 | if self.view: 87 | self.view.refresh_fields() 88 | self.view.refresh_cursor() 89 | 90 | def edit_assembly(self, assembly_text): 91 | """ 92 | Edit the assembly text. 93 | """ 94 | self._update_assembly_text(assembly_text) 95 | 96 | # refresh visible fields, as the assembled bytes may have changed 97 | if self.view: 98 | self.view.refresh_fields() 99 | 100 | # fetch the displayed instruction that the user is 'editing' 101 | current_insn = self.get_insn(self.address) 102 | 103 | # 104 | # if the newly assembled instruction is smaller than the existing 105 | # instruction, there is no need to highlight clobbers 106 | # 107 | 108 | edit_index = self.instructions.index(current_insn) 109 | clobber_end = self.address + len(self.assembly_bytes) 110 | will_clobber = clobber_end > (current_insn.address + current_insn.size) 111 | 112 | # loop through the next N instructions 113 | for next_insn in self.instructions[edit_index+1:]: 114 | next_insn.clobbered = (next_insn.address < clobber_end) and will_clobber 115 | 116 | # done marking clobbered instructions, nothing else to do 117 | if self.view: 118 | self.view.refresh_code() 119 | 120 | def commit_assembly(self): 121 | """ 122 | Commit the current assembly. 123 | """ 124 | if not self.assembly_bytes: 125 | return 126 | 127 | # patch the instruction at the current address 128 | self.core.patch(self.address, self.assembly_bytes) 129 | 130 | # refresh lines 131 | self._refresh_lines() 132 | 133 | def _update_assembly_text(self, assembly_text): 134 | """ 135 | Update the assembly text (and attempt to assemble it). 136 | """ 137 | self.assembly_text = assembly_text 138 | self.assembly_bytes = bytes() 139 | self.status_message = '' 140 | 141 | # 142 | # before trying to assemble the user input, we'll try to check for a 143 | # few problematic and unsupported cases before even attempting to 144 | # assemble the given text 145 | # 146 | # TODO/NOTE: we should probably move this into the 'assembler' 147 | # class and expose an error reason message/text for failures 148 | # 149 | 150 | _, mnemonic, operands = parse_disassembly_components(assembly_text) 151 | 152 | # 153 | # if it looks like the user is trying to assemble an instruction that 154 | # we KNOW Keystone does not support for whatever reason, we should 155 | # give them a heads up instead of an 'unspecified error' (...) 156 | # 157 | 158 | if mnemonic.upper() in self.core.assembler.UNSUPPORTED_MNEMONICS: 159 | self.status_message = "Keystone does not support this instruction (%s)" % mnemonic 160 | return 161 | 162 | # 163 | # in the odd event that a user pastes a massive blob of random text 164 | # into the the assembly field by accident, the plugin could 'hang' 165 | # IDA in an attempt to resolve a bunch of words as 'symbols' while 166 | # assembling the 'text' -- which is not what wen want 167 | # 168 | 169 | if len(scrape_symbols(operands)) > 10: 170 | self.status_message = "Too many potential symbols in the assembly text" 171 | return 172 | 173 | # 174 | # TODO/XXX/KEYSTONE: 11th hour hack, but Keystone will HANG if the 175 | # user tries to assemble the following inputs: 176 | # 177 | # .string ' 178 | # .string " 179 | # 180 | # so we're just going to try and block those until we can fix it 181 | # in Keystone proper :-X 182 | # 183 | 184 | assembly_normalized = assembly_text.strip().lower() 185 | 186 | if assembly_normalized.startswith('.string'): 187 | self.status_message = "Unsupported declaration (.string can hang Keystone)" 188 | return 189 | 190 | # 191 | # TODO: in v0.2.0 we should try to to re-enable multi-instruction 192 | # inputs. the only reason it is 'disabled' for now is that I need more 193 | # time to better define its behavior in the context of the plugin 194 | # 195 | # NOTE: Keystone supports 'xor eax, eax; ret;' just fine, it's purely 196 | # ensuring the rest of this plugin / wrapping layers are going to 197 | # handle it okay 198 | # 199 | 200 | if ';' in assembly_normalized: 201 | self.status_message = "Multi-instruction input not yet supported (';' not allowed)" 202 | return 203 | 204 | # 205 | # we didn't catch any 'early' issues with the user input, go ahead 206 | # and try to assemble it to see what happens 207 | # 208 | 209 | self.assembly_bytes = self.core.assemble(self.assembly_text, self.address) 210 | if not self.assembly_bytes: 211 | self.status_message = '...' # error assembling 212 | 213 | #------------------------------------------------------------------------- 214 | # Misc 215 | #------------------------------------------------------------------------- 216 | 217 | def refresh(self): 218 | """ 219 | Refresh the controller state based on the current IDA state. 220 | """ 221 | self._refresh_lines() 222 | self.select_address(self.address) 223 | 224 | def _refresh_lines(self): 225 | """ 226 | Refresh the disassembly for the dialog based on the current IDA state. 227 | """ 228 | instructions, current_address = [], self._address_origin 229 | 230 | PREV_INSTRUCTIONS = 50 231 | NEXT_INSTRUCTIONS = 50 232 | MAX_PREVIEW_BYTES = self.core.assembler.MAX_PREVIEW_BYTES 233 | 234 | # rewind a little bit from the target address to create a buffer 235 | for i in range(PREV_INSTRUCTIONS): 236 | current_address -= ida_bytes.get_item_size(current_address) 237 | 238 | # generate lines for the region of instructions around the target address 239 | for i in range(PREV_INSTRUCTIONS + NEXT_INSTRUCTIONS): 240 | try: 241 | line = InstructionLine(current_address, MAX_PREVIEW_BYTES) 242 | except ValueError: 243 | current_address += 1 244 | continue 245 | current_address += line.size 246 | instructions.append(line) 247 | 248 | self.instructions = instructions 249 | 250 | if self.view: 251 | self.view.refresh_code() 252 | 253 | def get_insn(self, ea): 254 | """ 255 | Return the instruction text object for the given address. 256 | """ 257 | insn, _ = self.get_insn_lineno(ea) 258 | return insn 259 | 260 | def get_insn_lineno(self, ea): 261 | """ 262 | Return the instruction text object and its line number for the given address. 263 | """ 264 | lineno = 0 265 | for insn in self.instructions: 266 | if insn.address <= ea < insn.address + insn.size: 267 | return (insn, lineno) 268 | lineno += insn.num_lines 269 | return (None, 0) 270 | 271 | #----------------------------------------------------------------------------- 272 | # 273 | #----------------------------------------------------------------------------- 274 | 275 | COLORED_SEP = ida_lines.COLSTR('|', ida_lines.SCOLOR_SYMBOL) 276 | 277 | class InstructionLine(object): 278 | """ 279 | A helper for drawing an instruction in a simple IDA viewer. 280 | """ 281 | def __init__(self, ea, max_preview=4): 282 | 283 | # 284 | # NOTE/XXX: this kind of needs to be called first, otherwise 285 | # 'get_item_size(ea)' may fetch a stale size for the instruction 286 | # if it was *just* patched 287 | # 288 | 289 | self.colored_instruction = ida_lines.generate_disasm_line(ea) 290 | if not self.colored_instruction: 291 | raise ValueError("Bad address... 0x%08X" % ea) 292 | 293 | # a label / jump target if this instruction has one 294 | self.name = ida_name.get_short_name(ea) 295 | 296 | # the number of lines this instruction object will render as 297 | self.num_lines = 1 + (2 if self.name else 0) 298 | 299 | # info about the instruction 300 | self.size = ida_bytes.get_item_size(ea) 301 | self.bytes = ida_bytes.get_bytes(ea, self.size) 302 | self.address = ea 303 | 304 | # flag to tell code view to highlight line as clobbered 305 | self.clobbered = False 306 | 307 | # how many instruction bytes to show before eliding 308 | self._max_preview = max_preview 309 | 310 | @property 311 | def colored_address(self): 312 | """ 313 | Return an IDA-colored string for the instruction address. 314 | """ 315 | pretty_address = ida_lines.COLSTR('%08X' % self.address, ida_lines.SCOLOR_PREFIX) 316 | return pretty_address 317 | 318 | @property 319 | def colored_bytes(self): 320 | """ 321 | Return an IDA-colored string for the instruction bytes. 322 | """ 323 | MAX_BYTES = self._max_preview 324 | 325 | if self.size > MAX_BYTES: 326 | text_bytes = hexdump(self.bytes[:MAX_BYTES-1]).ljust(3*MAX_BYTES-1, '.') 327 | else: 328 | text_bytes = hexdump(self.bytes).ljust(3*MAX_BYTES-1, ' ') 329 | 330 | pretty_bytes = ida_lines.COLSTR(text_bytes, ida_lines.SCOLOR_BINPREF) 331 | return pretty_bytes 332 | 333 | @property 334 | def line_blank(self): 335 | """ 336 | Return an IDA-colored string for a blank line at this address. 337 | """ 338 | byte_padding = ' ' * ((self._max_preview*3) - 1) 339 | self._line_blank = ' '.join(['', self.colored_address, COLORED_SEP, byte_padding , COLORED_SEP]) 340 | return self._line_blank 341 | 342 | @property 343 | def line_name(self): 344 | """ 345 | Return an IDA-colored string for the name text line (if a named address). 346 | """ 347 | if not self.name: 348 | return None 349 | 350 | pretty_name = ida_lines.COLSTR(self.name, ida_lines.SCOLOR_CNAME) + ':' 351 | byte_padding = ' ' * ((self._max_preview*3) - 1) 352 | 353 | self._line_name = ' '.join(['', self.colored_address, COLORED_SEP, byte_padding , COLORED_SEP, pretty_name]) 354 | return self._line_name 355 | 356 | @property 357 | def line_instruction(self): 358 | """ 359 | Return an IDA-colored string for the instruction text line. 360 | """ 361 | self._line_text = ' '.join(['', self.colored_address, COLORED_SEP, self.colored_bytes, COLORED_SEP + ' ', self.colored_instruction]) 362 | return self._line_text -------------------------------------------------------------------------------- /plugins/patching/ui/preview_ui.py: -------------------------------------------------------------------------------- 1 | import ida_name 2 | import ida_kernwin 3 | 4 | from patching.util.qt import * 5 | from patching.util.ida import * 6 | from patching.util.python import hexdump 7 | 8 | LAST_LINE_IDX = -1 9 | 10 | class PatchingDockable(ida_kernwin.PluginForm): 11 | """ 12 | The UI components of the Patching dialog. 13 | """ 14 | 15 | def __init__(self, controller): 16 | super().__init__() 17 | self.controller = controller 18 | self.count = 0 19 | 20 | #-------------------------------------------------------------------------- 21 | # IDA PluginForm Overloads 22 | #-------------------------------------------------------------------------- 23 | 24 | def Show(self): 25 | 26 | # TODO/Hex-Rays/XXX: can't make window Floating? using plgform_show(...) instead 27 | flags = ida_kernwin.PluginForm.WOPN_DP_FLOATING | ida_kernwin.PluginForm.WOPN_CENTERED 28 | #super(PatchingDockable, self).Show(self.controller.WINDOW_TITLE, flags) 29 | ida_kernwin.plgform_show(self.__clink__, self, self.controller.WINDOW_TITLE, flags) 30 | self._center_dialog() 31 | 32 | # 33 | # set the initial cursor position to focus on the target address 34 | # 35 | # we bump the focus location down a few lines from the top of the 36 | # window to center the cursor a bit. 37 | # 38 | 39 | self.set_cursor_pos(self.controller.address, self.controller.address_idx, 0, 6) 40 | 41 | # set the initial keyboard focus the editable assembly line 42 | self._line_assembly.setFocus(QtCore.Qt.FocusReason.ActiveWindowFocusReason) 43 | 44 | def OnCreate(self, form): 45 | self._twidget = form 46 | self.widget = ida_kernwin.PluginForm.TWidgetToPyQtWidget(self._twidget) 47 | self._ui_init() 48 | 49 | def OnClose(self, form): 50 | self._edit_timer.stop() 51 | self._edit_timer = None 52 | self._code_view = None 53 | self.controller.view = None 54 | return super().OnClose(form) 55 | 56 | #-------------------------------------------------------------------------- 57 | # Initialization - UI 58 | #-------------------------------------------------------------------------- 59 | 60 | def _ui_init(self): 61 | """ 62 | Initialize UI elements. 63 | """ 64 | self.widget.setMinimumSize(350, 350) 65 | 66 | # setup a monospace font for code / text printing 67 | self._font = QtGui.QFont("Courier New") 68 | self._font.setStyleHint(QtGui.QFont.Monospace) 69 | 70 | # initialize our ui elements 71 | self._ui_init_code() 72 | self._ui_init_fields() 73 | 74 | # populate the dialog/fields with initial contents from the database 75 | self.refresh() 76 | 77 | # set the code view to focus on an initial line 78 | self._code_view.Jump(self._code_view.GetLineNo(), y=5) 79 | 80 | # layout the populated ui just before showing it 81 | self._ui_layout() 82 | 83 | # 84 | # NOTE: we 'defer' real-time instruction assembly (while typing) in 85 | # the patching dialog if we think the database is 'big enough' to 86 | # make the text input lag due to slow symbol resolution (eg. having 87 | # to search the entire IDA 'name list' for an invalid symbol) 88 | # 89 | 90 | self._edit_timer = QtCore.QTimer(self.widget) 91 | self._edit_timer.setSingleShot(True) 92 | self._edit_timer.timeout.connect(self._edit_stopped) 93 | 94 | if ida_name.get_nlist_size() > 20000: 95 | self._line_assembly.textEdited.connect(self._edit_started) 96 | else: 97 | self._line_assembly.textEdited.connect(self.controller.edit_assembly) 98 | 99 | # connect signals 100 | self._line_assembly.returnPressed.connect(self._enter_pressed) 101 | 102 | def _ui_init_fields(self): 103 | """ 104 | Initialize the interactive text fields for this UI control. 105 | """ 106 | self._line_address = QtWidgets.QLineEdit() 107 | self._line_address.setFont(self._font) 108 | self._line_address.setReadOnly(True) 109 | self._label_address = QtWidgets.QLabel("Address:") 110 | self._label_address.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) 111 | 112 | # configure the line that displays assembly text 113 | self._line_assembly = AsmLineEdit(self._code_view) 114 | self._line_assembly.setFont(self._font) 115 | self._line_assembly.setMinimumWidth(350) 116 | self._label_assembly = QtWidgets.QLabel("Assembly:") 117 | self._label_assembly.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) 118 | 119 | # configure the line that displays assembled bytes 120 | self._line_bytes = QtWidgets.QLineEdit() 121 | self._line_bytes.setFont(self._font) 122 | self._line_bytes.setReadOnly(True) 123 | self._label_bytes = QtWidgets.QLabel("Bytes:") 124 | self._label_bytes.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) 125 | 126 | def _ui_init_code(self): 127 | """ 128 | Initialize the interactive code view for this UI control. 129 | """ 130 | self._code_view = PatchingCodeViewer(self.controller) 131 | 132 | def _ui_layout(self): 133 | """ 134 | Layout the major UI elements of the widget. 135 | """ 136 | layout = QtWidgets.QGridLayout(self.widget) 137 | 138 | # arrange the widgets in a 'grid' row col row span col span 139 | layout.addWidget(self._label_address, 0, 0, 1, 1) 140 | layout.addWidget(self._line_address, 0, 1, 1, 1) 141 | layout.addWidget(self._label_assembly, 1, 0, 1, 1) 142 | layout.addWidget(self._line_assembly, 1, 1, 1, 1) 143 | layout.addWidget(self._label_bytes, 2, 0, 1, 1) 144 | layout.addWidget(self._line_bytes, 2, 1, 1, 1) 145 | layout.addWidget(self._code_view.widget, 3, 0, 1, 2) 146 | 147 | # apply the layout to the widget 148 | self.widget.setLayout(layout) 149 | 150 | def _center_dialog(self): 151 | """ 152 | Center the current dialog to the IDA MainWindow. 153 | 154 | TODO/Hex-Rays: WOPN_CENTERED flag?! does it not work? or how do I use it? 155 | 156 | XXX: I have no idea why the get_main_window(...) + center_widget(...) 157 | code I wrote in qt.py does not work for wid_dialog / IDA dockables even 158 | though it is effectively identical to this lol 159 | 160 | NOTE: this hack will cause a 'widget flicker' as we are moving the widget 161 | shortly after it is made visible... 162 | """ 163 | wid_main, wid_dialog = None, None 164 | 165 | # 166 | # search upwards through the current dialog/widget's parent widgets 167 | # until the IDA main window is located 168 | # 169 | 170 | parent = self.widget.parent() 171 | while parent: 172 | 173 | if isinstance(parent, QtWidgets.QMainWindow): 174 | wid_main = parent 175 | break 176 | 177 | elif isinstance(parent, QtWidgets.QWidget): 178 | if parent.windowTitle() == self.controller.WINDOW_TITLE: 179 | wid_dialog = parent 180 | 181 | parent = parent.parent() 182 | 183 | # 184 | # fail, could not find the IDA main window and the parent container 185 | # for this widget (unlikely) 186 | # 187 | 188 | if not (wid_main and wid_dialog): 189 | return False 190 | 191 | rect_main = wid_main.geometry() 192 | rect_dialog = wid_dialog.rect() 193 | 194 | # 195 | # compute a new position for the dialog such that it will center 196 | # to the IDA main window 197 | # 198 | 199 | pos_dialog = rect_main.center() - rect_dialog.center() 200 | wid_dialog.move(pos_dialog) 201 | 202 | #-------------------------------------------------------------------------- 203 | # Refresh 204 | #-------------------------------------------------------------------------- 205 | 206 | def refresh(self): 207 | """ 208 | Refresh the entire patching dialog. 209 | """ 210 | self.refresh_fields() 211 | self.refresh_code() 212 | 213 | def refresh_fields(self): 214 | """ 215 | Refresh the patching fields. 216 | """ 217 | 218 | # update the address field to show the currently selected address 219 | self._line_address.setText('0x%08X' % self.controller.address) 220 | 221 | # update the assembly text to show the currently selected instruction 222 | if self._line_assembly.text() != self.controller.assembly_text: 223 | self._line_assembly.setText(self.controller.assembly_text) 224 | 225 | # update the assembly bytes field... which can also show an error message 226 | if self.controller.status_message: 227 | self._line_bytes.setText(self.controller.status_message) 228 | else: 229 | self._line_bytes.setText(hexdump(self.controller.assembly_bytes)) 230 | 231 | def refresh_code(self): 232 | """ 233 | Refresh the patching code view. 234 | """ 235 | self._code_view.ClearLines() 236 | 237 | # regenerate the view from the current set of lines in the backing model 238 | for line in self.controller.instructions: 239 | 240 | # 241 | # instructions with an 'assembly label' (eg. loc_140004200) 242 | # attached to their address should have these extra lines visible 243 | # to better simulate a true IDA disassembly listing 244 | # 245 | 246 | if line.name: 247 | self._code_view.AddLine(line.line_blank) 248 | self._code_view.AddLine(line.line_name) 249 | 250 | # emit the actual instruction text 251 | self._code_view.AddLine(line.line_instruction) 252 | 253 | self._code_view.Refresh() 254 | 255 | def refresh_cursor(self): 256 | """ 257 | Refresh the user cursor in the patching code view. 258 | """ 259 | 260 | # get the text based co-ordinates within the IDA code view 261 | ida_pos = self._code_view.GetPos() 262 | lineno_sel, x, y = ida_pos if ida_pos else (0, 0, 0) 263 | 264 | # fetch the instruction 'selected' by the controller/model 265 | insn, lineno_insn = self.controller.get_insn_lineno(self.controller.address) 266 | 267 | if self.controller.address_idx == LAST_LINE_IDX: 268 | lineno_new = lineno_insn + (insn.num_lines - 1) 269 | else: 270 | lineno_new = lineno_insn + self.controller.address_idx 271 | 272 | self._code_view.Jump(lineno_new, x, y) 273 | 274 | #------------------------------------------------------------------------- 275 | # Events 276 | #------------------------------------------------------------------------- 277 | 278 | def _edit_started(self): 279 | """ 280 | The assembly text was changed by the user. 281 | """ 282 | self._edit_timer.stop() 283 | 284 | assembly_text = self._line_assembly.text() 285 | _, _, ops = parse_disassembly_components(assembly_text) 286 | 287 | # 288 | # if there's no symbols that would have to be resolved for the 289 | # the current input, we should attempt assembly immediately as it 290 | # should be in-expensive (won't lag the text input) 291 | # 292 | 293 | if not scrape_symbols(ops): 294 | self.controller.edit_assembly(assembly_text) 295 | return 296 | 297 | # 298 | # in 500ms if the user hasn't typed anything else into the assembly 299 | # field, we will consider their editing as 'stopped' and attempt 300 | # to evaluate (assemble) their current input 301 | # 302 | 303 | self._edit_timer.start(500) 304 | 305 | def _edit_stopped(self): 306 | """ 307 | Some amount of time has passed since the user last edited the assembly text. 308 | """ 309 | assembly_text = self._line_assembly.text() 310 | self.controller.edit_assembly(assembly_text) 311 | 312 | def _enter_pressed(self): 313 | """ 314 | The user pressed enter while the assembly text line was focused. 315 | """ 316 | if self._edit_timer.isActive(): 317 | self._edit_timer.stop() 318 | self.controller.edit_assembly(self._line_assembly.text()) 319 | self.controller.commit_assembly() 320 | 321 | #-------------------------------------------------------------------------- 322 | # Misc 323 | #-------------------------------------------------------------------------- 324 | 325 | def get_cursor(self): 326 | """ 327 | Return the current view cursor information. 328 | """ 329 | 330 | # the line the view is currently focused on 331 | view_line = self._code_view.GetCurrentLine() 332 | view_address = parse_line_ea(view_line) 333 | 334 | # get the text based co-ordinates within the IDA code view 335 | view_pos = self._code_view.GetPos() 336 | lineno, x, y = view_pos if view_pos else (0, 0, 0) 337 | 338 | # 339 | # compute the relative line number within the focused address 340 | # 341 | 342 | global_idx, relative_idx = 0, -1 343 | while True: 344 | 345 | # fetch a line from the code view 346 | line = self._code_view.GetLine(global_idx) 347 | if not line: 348 | break 349 | 350 | # unpack the returned code viewer line tuple 351 | colored_line, _, _ = line 352 | line_address = parse_line_ea(colored_line) 353 | 354 | if line_address == view_address: 355 | 356 | # 357 | # found the first instruction line matching our cursor 358 | # address, start the relative line index counter 359 | # 360 | 361 | if relative_idx == -1: 362 | relative_idx = 0 363 | 364 | # next line 365 | else: 366 | relative_idx += 1 367 | 368 | # 369 | # we have reached the first line with an address GREATER than the 370 | # lines with an address matching the view's current selection 371 | # 372 | 373 | elif line_address > view_address: 374 | break 375 | 376 | global_idx += 1 377 | 378 | # 379 | # return a position (like, our own place_t) that can be used to jump 380 | # the patching view to this exact position again, even if the lines 381 | # or formatting changes around 'a bit' 382 | # 383 | 384 | return (view_address, relative_idx, x, y) 385 | 386 | def set_cursor_pos(self, address, idx=0, x=0, y=0): 387 | """ 388 | TODO 389 | """ 390 | insn, lineno = self.controller.get_insn_lineno(address) 391 | if not insn: 392 | raise ValueError("Failed to jump to given address 0x%08X" % address) 393 | 394 | # 395 | # idx as -1 is a special case to focus on the *last* line of the 396 | # instruction at the matching address. for example, this is used to 397 | # focus on the *ACTUAL* instruction text / line for an address that 398 | # contains multiple lines (blank line + label line + instruction line) 399 | # 400 | 401 | if idx == -1: 402 | idx = insn.num_lines - 1 403 | elif address != insn.address: 404 | idx = 0 405 | 406 | final_lineno = lineno + idx 407 | self._code_view.Jump(final_lineno, x, y) 408 | 409 | class AsmLineEdit(QtWidgets.QLineEdit): 410 | """ 411 | A Qt LineEdit with a few extra tweaks. 412 | """ 413 | 414 | def __init__(self, code_view, parent=None): 415 | super().__init__() 416 | self.code_view = code_view 417 | 418 | def keyPressEvent(self, event): 419 | """ 420 | Key press received. 421 | """ 422 | 423 | # navigate DOWN one line in the asm view if the 'down arrow' key 424 | if event.key() == QtCore.Qt.Key_Down: 425 | lineno, x, y = self.code_view.GetPos() 426 | 427 | # clamp to the last line, and jump to it 428 | lineno = min(lineno+1, self.code_view.Count()-1) 429 | self.code_view.Jump(lineno, x, y) 430 | 431 | # manually trigger the 'Cursor Position Changed' handler 432 | self.code_view.OnCursorPosChanged() 433 | 434 | # mark the event as handled 435 | event.accept() 436 | return 437 | 438 | # navigate UP one line in the code view if the 'up arrow' key 439 | elif event.key() == QtCore.Qt.Key_Up: 440 | lineno, x, y = self.code_view.GetPos() 441 | 442 | # clamp to the first line 443 | lineno = max(lineno-1, 0) 444 | self.code_view.Jump(lineno, x, y) 445 | 446 | # manually trigger the 'Cursor Position Changed' handler 447 | self.code_view.OnCursorPosChanged() 448 | 449 | # mark the event as handled 450 | event.accept() 451 | return 452 | 453 | # let the key press be handled normally 454 | super().keyPressEvent(event) 455 | 456 | #------------------------------------------------------------------------------ 457 | # IDA Code Viewer 458 | #------------------------------------------------------------------------------ 459 | 460 | class PatchingCodeViewer(ida_kernwin.simplecustviewer_t): 461 | """ 462 | An IDA controlled 'code viewer' to simulate a disassembly view. 463 | """ 464 | 465 | def __init__(self, controller): 466 | super().__init__() 467 | self.controller = controller 468 | self._ui_hooks = UIHooks() 469 | self._ui_hooks.get_lines_rendering_info = self._highlight_lines 470 | self.Create() 471 | 472 | #-------------------------------------------------------------------------- 473 | # IDA Code Viewer Overloads 474 | #-------------------------------------------------------------------------- 475 | 476 | def Create(self): 477 | if not super().Create('PatchingCodeViewer'): 478 | return False 479 | self._twidget = self.GetWidget() 480 | self.widget = ida_kernwin.PluginForm.TWidgetToPyQtWidget(self._twidget) 481 | self._ui_hooks.hook() 482 | return True 483 | 484 | def OnClose(self): 485 | self._ui_hooks.unhook() 486 | self._filter = None 487 | 488 | def OnCursorPosChanged(self): 489 | 490 | # get the currently selected line in the code view 491 | view_line = self.GetCurrentLine() 492 | view_lineno = self.GetLineNo() 493 | view_address = parse_line_ea(view_line) 494 | 495 | # 496 | # get the info about the currently selected instruction from the 497 | # underlying view controller / model 498 | # 499 | 500 | insn, insn_lineno = self.controller.get_insn_lineno(view_address) 501 | 502 | # compute the cursor's relative index into lines with the same address 503 | relative_idx = view_lineno - insn_lineno 504 | 505 | # notify the controller of the updated cursor / selection 506 | self.controller.select_address(view_address, relative_idx) 507 | 508 | def OnPopup(self, form, popup_handle): 509 | self._filter = remove_ida_actions(popup_handle) 510 | return False 511 | 512 | #-------------------------------------------------------------------------- 513 | # Events 514 | #-------------------------------------------------------------------------- 515 | 516 | def _highlight_lines(self, out, widget, rin): 517 | """ 518 | IDA is drawing disassembly lines and requesting highlighting info. 519 | """ 520 | 521 | # ignore line highlight events that are not for the current code view 522 | if widget != self._twidget: 523 | return 524 | 525 | selected_lnnum, x, y = self.GetPos() 526 | 527 | # highlight lines/addresses that have been patched by the user 528 | assert len(rin.sections_lines) == 1 529 | for i, line in enumerate(rin.sections_lines[0]): 530 | splace = ida_kernwin.place_t_as_simpleline_place_t(line.at) 531 | line_info = self.GetLine(splace.n) 532 | if not line_info: 533 | continue 534 | 535 | colored_text, _, _ = line_info 536 | address = parse_line_ea(colored_text) 537 | 538 | current_insn = self.controller.get_insn(address) 539 | if not current_insn: 540 | continue 541 | 542 | # convert (ea, size) to represent the full address of each byte in an instruction 543 | insn_addresses = set(range(current_insn.address, current_insn.address + current_insn.size)) 544 | 545 | # green: selected line 546 | if splace.n == selected_lnnum: 547 | color = ida_kernwin.CK_EXTRA1 548 | 549 | # red: clobbered line 550 | elif current_insn.clobbered: 551 | color = ida_kernwin.CK_EXTRA11 552 | 553 | # yellow: patched line 554 | elif insn_addresses & self.controller.core.patched_addresses: 555 | color = ida_kernwin.CK_EXTRA2 556 | 557 | # no highlighting needed 558 | else: 559 | continue 560 | 561 | # highlight the line if it is patched in some way 562 | e = ida_kernwin.line_rendering_output_entry_t(line) 563 | e.bg_color = color 564 | e.flags = ida_kernwin.LROEF_FULL_LINE 565 | 566 | # save the highlight to the output line highlight list 567 | out.entries.push_back(e) 568 | -------------------------------------------------------------------------------- /plugins/patching/asm.py: -------------------------------------------------------------------------------- 1 | import ida_ua 2 | import ida_idp 3 | import ida_nalt 4 | import ida_lines 5 | import ida_segregs 6 | 7 | from patching.util.ida import * 8 | import patching.keystone as keystone 9 | 10 | TEST_KS_RESOLVER = False 11 | 12 | class KeystoneAssembler(object): 13 | """ 14 | An abstraction of a CPU-specific fixup layer to wrap Keystone. 15 | """ 16 | 17 | # the mnemonic for an unconditional jump 18 | UNCONDITIONAL_JUMP = NotImplementedError 19 | 20 | # the list of known conditional jump mnemonics 21 | CONDITIONAL_JUMPS = [] 22 | 23 | # a list of mnemonics that we KNOW are currently unsupported 24 | UNSUPPORTED_MNEMONICS = [] 25 | 26 | # the number of instruction bytes to show in the patch preview pane 27 | MAX_PREVIEW_BYTES = 4 28 | 29 | # 30 | # NOTE: for now, we explicitly try to print operands using 'blank' type 31 | # info because it can produce simpler output for the assembler engine 32 | # 33 | # we initialize just one instance of this blank printop for performance 34 | # reasons, so we do not have to initialize a new one for *every* print. 35 | # 36 | # it is particularly useful when using the assemble_all(...) DEV / test 37 | # function to round-trip assemble an entire IDB 38 | # 39 | 40 | _NO_OP_TYPE = ida_nalt.printop_t() 41 | 42 | def __init__(self, arch, mode): 43 | 44 | # a super low-effort TODO assert to ensure we're not using incomplete code 45 | assert self.UNCONDITIONAL_JUMP != NotImplementedError, "Incomplete Assembler Implementation" 46 | 47 | # initialize a backing keystone assembler 48 | self._arch = arch 49 | self._mode = mode | (keystone.KS_OPT_SYM_RESOLVER if TEST_KS_RESOLVER else 0) 50 | self._ks = keystone.Ks(arch, mode) 51 | 52 | # TODO/XXX: the keystone sym resolver callback is only for DEV / testing 53 | if TEST_KS_RESOLVER: 54 | self._ks.sym_resolver = self._ks_sym_resolver 55 | 56 | def _ks_sym_resolver(self, symbol, value): 57 | """ 58 | TODO: the keystone symbol resolver can be a bit goofy, so we opt not 59 | to use it (keypatch doesn't, either!) for now. it has been left here 60 | for future testing or further bugfixing of keystone 61 | 62 | NOTE: this *CAN* be beneficial to use for MULTI INSTRUCTION assembly, 63 | such as assembling a block of instructions (eg. shellcode, or a 64 | more complex patch) which makes use of labels within said block. 65 | """ 66 | symbol = symbol.decode('utf-8') 67 | 68 | # 69 | # some symbols in IDA names / chars cannot pass cleanly through 70 | # keystone. for that reason, we try to replace some 'problematic' 71 | # characters that may appear in IDA symbols (and then disas text) 72 | # 73 | # when they pop back up here, in keystone's symbol resolver, we 74 | # try to subsitute the 'problematic' characters back in so that 75 | # we can look up the original symbol value in IDA 76 | # 77 | 78 | if 'AT_SPECIAL_AT' in symbol: 79 | symbol = symbol.replace('AT_SPECIAL_AT', '@') 80 | if 'QU_SPECIAL_QU' in symbol: 81 | symbol = symbol.replace('QU_SPECIAL_QU', '?') 82 | 83 | # 84 | # XXX: pretty messy, sorry. no way to resolve 'symbol collisions' 85 | # that could technically manifest from IDA 86 | # 87 | 88 | for sym_value, sym_real_name in resolve_symbol(self._ks_address, symbol): 89 | value[0] = sym_value 90 | return True 91 | 92 | # symbol resolution failed 93 | return False 94 | 95 | def rewrite_symbols(self, assembly, ea): 96 | """ 97 | Rewrite the symbols in the given assembly text to their concrete values. 98 | """ 99 | 100 | # 101 | # TODO: is there a reason i'm not using parse_disassembly_components() 102 | # here? I forget, this code probably predates that. 103 | # 104 | 105 | mnem, sep, ops = assembly.partition(' ') 106 | 107 | # 'mnem' appears to be an instruction prefix actually, so keep parsing 108 | if mnem in KNOWN_PREFIXES: 109 | real_mnem, sep, ops = ops.partition(' ') 110 | mnem += ' ' + real_mnem 111 | 112 | # 113 | # scrape symbols from *just* the operands text, as that's the only 114 | # place we would expect to see them in assembly code anyway! 115 | # 116 | 117 | symbols = scrape_symbols(ops) 118 | 119 | # 120 | # if the symbol count is too high, it might take 'too long' to try 121 | # and resolve them all in a big database. At 10+ symbols, it is 122 | # probably just an invalid input to the assembler as is (at least, 123 | # for a single instruction ...) 124 | # 125 | # TODO: really, we should be throwing a set of more descriptive 126 | # errors from the assembler that the dialog can render rather 127 | # than trying to catch issues in preview.py (UI land) 128 | # 129 | 130 | if len(symbols) > 10: 131 | print("Aborting symbol re-writing, too (%u) many potential symbols..." % (len(symbols))) 132 | return assembly 133 | 134 | # 135 | # with a list of believed symbols and their text location, we will 136 | # try to resolve a value for each text symbol and swap a raw hex 137 | # number in to replace the symbol text 138 | # 139 | # eg. 'mov eax, [foo]' --> 'mov eax, [0x410800]' 140 | # 141 | # where 'foo' was a symbol name entered by the user, but we can 142 | # query IDA to try and resolve (func address, data address, etc) 143 | # 144 | 145 | prev_index = 0 146 | new_ops = '' 147 | 148 | for name, location in symbols: 149 | sym_start, sym_end = location 150 | 151 | for sym_value, sym_real_name in resolve_symbol(ea, name): 152 | sym_value_text = '0x%X' % sym_value 153 | 154 | # 155 | # we are carefully carving around the original symbol text 156 | # to build out a new 'string' for the full operand text 157 | # 158 | 159 | new_ops += ops[prev_index:sym_start] + sym_value_text 160 | prev_index = sym_end 161 | 162 | # 163 | # TODO: the case where resolve_symbol can return 'multiple' 164 | # results (eg, a symbol 'collision') is currently unhandled 165 | # but could happen in very rare cases 166 | # 167 | # by always breaking on the first iteration of this loop, 168 | # we're effectively always selecting the first symbol value 169 | # without any consideration of others (TODO how?) 170 | # 171 | # lol, this symbol resolution / rewriting is ugly enough as 172 | # is. it will probably have to get re-written an simplified 173 | # at a later time, if possible :S 174 | # 175 | 176 | break 177 | 178 | else: 179 | #print("%08X: Failed to resolve possible symbol '%s'" % (ea, name)) 180 | continue 181 | 182 | new_ops += ops[prev_index:] 183 | raw_assembly = mnem + sep + new_ops 184 | 185 | # 186 | # return assembly text that has (ideally) had possible symbols 187 | # replaced with unambiguous values that are easy for the assembler 188 | # to consume 189 | # 190 | 191 | return raw_assembly 192 | 193 | def asm(self, assembly, ea=0, resolve=True): 194 | """ 195 | Assemble the given instruction with an optional base address. 196 | 197 | TODO/v0.2.0: support 'simple' one-line but multi-instruction assembly? 198 | """ 199 | unaliased_assembly = self.unalias(assembly) 200 | 201 | if TEST_KS_RESOLVER: 202 | raw_assembly = unaliased_assembly 203 | raw_assembly = raw_assembly.replace('@', 'AT_SPECIAL_AT') 204 | raw_assembly = raw_assembly.replace('?', 'QU_SPECIAL_QU') 205 | self._ks_address = ea 206 | elif resolve: 207 | raw_assembly = self.rewrite_symbols(unaliased_assembly, ea) 208 | else: 209 | raw_assembly = unaliased_assembly 210 | 211 | #print(" Assembling: '%s' @ ea 0x%08X" % (raw_assembly, ea)) 212 | 213 | # 214 | # TODO: this whole function is kind of gross, and it would be good if 215 | # we could surface at least 'some' of the error information that 216 | # keystone can produce of failures 217 | # 218 | 219 | # try assemble 220 | try: 221 | asm_bytes, count = self._ks.asm(raw_assembly, ea, True) 222 | if asm_bytes == None: 223 | return bytes() 224 | except Exception as e: 225 | #print("FAIL", e) 226 | return bytes() 227 | 228 | # return the generatied instruction bytes if keystone succeeded 229 | return asm_bytes 230 | 231 | def is_conditional_jump(self, mnem): 232 | """ 233 | Return True if the given mnemonic is a conditional jump. 234 | 235 | TODO: 'technically' I think IDA might actually have some CPU 236 | agnostic API's to tell if an instruction is a conditional jump. 237 | 238 | so maybe the need to manually define CONDITIONAL_JUMPS mnemonics 239 | for CPU's can be removed in a future version of this plugin... 240 | """ 241 | return bool(mnem.upper() in self.CONDITIONAL_JUMPS) 242 | 243 | def nop_buffer(self, start_ea, end_ea): 244 | """ 245 | Generate a NOP buffer for the given address range. 246 | """ 247 | range_size = end_ea - start_ea 248 | if range_size < 0: 249 | return bytes() 250 | 251 | # fetch the bytes for a NOP instruction (and its size) 252 | nop_data = self.asm('nop', start_ea) 253 | nop_size = len(nop_data) 254 | 255 | # generate a buffer of NOP's equal to the range we are filling in 256 | nop_buffer = nop_data * (range_size // nop_size) 257 | 258 | return nop_buffer 259 | 260 | #-------------------------------------------------------------------------- 261 | # Assembly Normalization 262 | #-------------------------------------------------------------------------- 263 | 264 | def format_prefix(self, insn, prefix): 265 | """ 266 | Return an assembler compatible version of the given prefix. 267 | """ 268 | return prefix 269 | 270 | def format_mnemonic(self, insn, mnemonic): 271 | """ 272 | Return an assembler compatible version of the given mnemonic. 273 | """ 274 | return mnemonic 275 | 276 | def format_memory_op(self, insn, n): 277 | """ 278 | Return an assembler compatible version of the given memory op. 279 | """ 280 | op_text = ida_ua.print_operand(insn.ea, n, 0, self._NO_OP_TYPE) 281 | return op_text 282 | 283 | def format_imm_op(self, insn, n): 284 | """ 285 | Return an assembler compatible version of the given imm val op. 286 | """ 287 | return ida_ua.print_operand(insn.ea, n) 288 | 289 | def format_assembly(self, ea): 290 | """ 291 | Return assembler compatible disassembly for the given address. 292 | 293 | This function sort re-implements the general instruction printing 294 | pipeline of the loaded processor module, but just way more shady. 295 | """ 296 | prefix, mnem, _ = get_disassembly_components(ea) 297 | 298 | # 299 | # TODO: this 'used' to be used to handle a failure from the above 300 | # function, but I don't think it is needed anymore. as the above func 301 | # has been dramatically simplified to parse 'dumber' than it used to 302 | # 303 | # it had to do with something with trying to parse/format addresses 304 | # that would return stuff like 'align 10h' (not real instructions) 305 | # 306 | 307 | if mnem is None: 308 | return '' 309 | 310 | # 311 | # decode the instruction just once so the CPU-specific layers can 312 | # read and use it to apply specific fixups when needed 313 | # 314 | 315 | insn = ida_ua.insn_t() 316 | ida_ua.decode_insn(insn, ea) 317 | 318 | # this will accumulate the final fixed up text for all ops 319 | ops = [] 320 | 321 | # this will hold the fixed up operand text for the current op 322 | op_text = '' 323 | 324 | # 325 | # generate the operand text for each op, with callbacks into the 326 | # processor specific fixups as necessary for each op type 327 | # 328 | 329 | for op in insn.ops: 330 | 331 | # 332 | # NOTE/PERF: these if/elif statements have been arranged based on 333 | # frequency (at least in x86/x64) for performance reasons 334 | # 335 | # be careful re-ordering them, as it may make assemble_all(...) 336 | # run twice as slow!! 337 | # 338 | 339 | if op.type in [ida_ua.o_reg, ida_ua.o_far, ida_ua.o_near]: 340 | op_text = ida_ua.print_operand(ea, op.n) 341 | 342 | # reached final operand in this instruction 343 | elif op.type == ida_ua.o_void: 344 | break 345 | 346 | # 347 | # TODO: ideally we should allow users to toggle between 'pretty' 348 | # and 'raw' displacement / phrase ops, but I think there's keystone / 349 | # LLVM weirdness that is causing some bad assembly to be generated? 350 | # 351 | # IDA: 'mov [esp+6Ch+dest], esi' 352 | # RAW: 'mov [esp+6Ch+0xFFFFFF94], esi' 353 | # WHICH IS: 'mov [esp], esi' 354 | # 355 | # but this is what keystone 'evaluates' and generates 'bad' asm for 356 | # 357 | # IDA: 'mov [esp], esi' -- 89 34 24 358 | # keystone: 'mov [esp+0x100000000], esi' -- 89 74 24 (? invalid asm) 359 | # 360 | # this will have to be investigated later. so for now we generate asm 361 | # without IDA's special offsetting... 362 | # 363 | 364 | elif op.type in [ida_ua.o_displ, ida_ua.o_phrase]: 365 | op_text = ida_ua.print_operand(ea, op.n, 0, self._NO_OP_TYPE) 366 | 367 | elif op.type == ida_ua.o_imm: 368 | op_text = self.format_imm_op(insn, op.n) 369 | 370 | elif op.type == ida_ua.o_mem: 371 | op_text = self.format_memory_op(insn, op.n) 372 | 373 | else: 374 | op_text = ida_ua.print_operand(ea, op.n) 375 | 376 | # 377 | # the operand is marked as invisible according to IDA, 378 | # so we shouldn't be showing / generating text for it anyway 379 | # (eg. Op4 for UMULH in ARM64) 380 | # 381 | 382 | if not(op.flags & ida_ua.OF_SHOW): 383 | continue 384 | 385 | ops.append(op_text) 386 | 387 | ops = list(map(ida_lines.tag_remove, filter(None, ops))) 388 | prefix = self.format_prefix(insn, prefix) 389 | mnem = self.format_mnemonic(insn, mnem) 390 | 391 | if prefix: 392 | mnem = prefix + ' ' + mnem 393 | 394 | # generate the fully disassembled instruction / text 395 | text = '%s %s' % (mnem.ljust(7, ' '), ', '.join(ops)) 396 | 397 | # TODO/XXX: ehh this should probably be cleaned up / moved in v0.2.0 398 | for banned in ['[offset ', '(offset ', ' offset ', ' short ', ' near ptr ', ' far ptr ', ' large ']: 399 | text = text.replace(banned, banned[0]) 400 | 401 | return text.strip() 402 | 403 | def unalias(self, assembly): 404 | """ 405 | Translate an instruction alias / shorthand to its full version. 406 | """ 407 | return assembly 408 | 409 | #------------------------------------------------------------------------------ 410 | # x86 / x86_64 411 | #------------------------------------------------------------------------------ 412 | 413 | class AsmX86(KeystoneAssembler): 414 | """ 415 | Intel x86 & x64 specific wrapper for Keystone. 416 | """ 417 | 418 | UNCONDITIONAL_JUMP = 'JMP' 419 | CONDITIONAL_JUMPS = \ 420 | [ 421 | 'JZ', 'JE', 'JNZ', 'JNE', 'JC', 'JNC', 422 | 'JO', 'JNO', 'JS', 'JNS', 'JP', 'JPE', 423 | 'JNP', 'JPO', 'JCXZ', 'JECXZ', 'JRCXZ', 424 | 'JG', 'JNLE', 'JGE', 'JNL', 'JL', 'JNGE', 425 | 'JLE', 'JNG', 'JA', 'JNBE', 'JAE', 'JNB', 426 | 'JB', 'JNAE', 'JBE', 'JNA' 427 | ] 428 | 429 | UNSUPPORTED_MNEMONICS = \ 430 | [ 431 | # intel CET 432 | 'ENDBR32', 'ENDBR64', 433 | 'RDSSPD', 'RDSSPQ', 434 | 'INCSSPD', 'INCSSPQ', 435 | 'SAVEPREVSSP', 'RSTORSSP', 436 | 'WRSSD', 'WRSSQ', 'WRUSSD', 'WRUSSQ', 437 | 'SETSSBSY', 'CLRSSBSY', 438 | 439 | # misc 440 | 'MONITOR', 'MWAIT', 'MONITORX', 'MWAITX', 441 | 'INVPCID', 442 | 443 | # bugged? 444 | 'REPE CMPSW', 445 | ] 446 | 447 | def __init__(self): 448 | arch = keystone.KS_ARCH_X86 449 | 450 | if ida_ida.inf_is_64bit(): 451 | mode = keystone.KS_MODE_64 452 | self.MAX_PREVIEW_BYTES = 7 453 | elif ida_ida.inf_is_32bit_exactly(): 454 | mode = keystone.KS_MODE_32 455 | self.MAX_PREVIEW_BYTES = 6 456 | else: 457 | mode = keystone.KS_MODE_16 458 | 459 | # initialize keystone-based assembler 460 | super(AsmX86, self).__init__(arch, mode) 461 | 462 | #-------------------------------------------------------------------------- 463 | # Intel Assembly Formatting / Fixups 464 | #-------------------------------------------------------------------------- 465 | 466 | def format_mnemonic(self, insn, mnemonic): 467 | original = mnemonic.strip() 468 | 469 | # normalize the mnemonic case for fixup checking 470 | mnemonic = original.upper() 471 | 472 | if mnemonic == 'RETN': 473 | return 'ret' 474 | if mnemonic == 'XLAT': 475 | return 'xlatb' 476 | 477 | # no mnemonic fixups, return the original 478 | return original 479 | 480 | def format_memory_op(self, insn, n): 481 | 482 | # 483 | # because IDA generates some 'non-standard' syntax in favor of human 484 | # readability, we have to fixup / re-print most memory operands to 485 | # reconcile them with what the assembler expects. 486 | # 487 | # (i'll go through later and document examples of each 'case' below) 488 | # 489 | 490 | op_text = super(AsmX86, self).format_memory_op(insn, n) 491 | op_text = ida_lines.tag_remove(op_text) 492 | 493 | # 494 | # since this is a memory operation, we expect there to be a '[...]' 495 | # present in the operand text. if there isn't we should try to wrap 496 | # the appropriate parts of operand with square brackets 497 | # 498 | 499 | if '[' not in op_text: 500 | 501 | # 502 | # this case is to wrap segment:offset kind of prints: 503 | # 504 | # eg. 505 | # - .text:00000001400AD89A 65 48 8B 04 25 58 00+ mov rax, gs:58h 506 | # 507 | # NOTE: the secondary remaining[0] != ':' check is to avoid 'cpp' 508 | # cases, basically ensuring we are not modifying a '::' 509 | # 510 | # eg. 511 | # - .text:000000014000A4F2 48 8D 05 EF 14 25 00 lea rax, const QT::QSplitter::'vftable' 512 | # 513 | 514 | start, sep, remaining = op_text.partition(':') 515 | if sep and remaining[0] != ':': 516 | op_text = start + sep + '[' + remaining + ']' 517 | 518 | # 519 | # eg. 520 | # - .text:08049F52 F6 05 A4 40 0F 08 02 test byte ptr dword_80F40A4, 2 521 | # 522 | 523 | elif ' ptr ' in op_text: 524 | start, sep, remaining = op_text.partition(' ptr ') 525 | op_text = start + sep + '[' + remaining + ']' 526 | 527 | # 528 | # eg. 529 | # - .text:000000014002F0C6 48 8D 0D 53 B9 E2 00 lea rcx, unk_140E5AA20 530 | # 531 | 532 | else: 533 | op_text = '[' + op_text + ']' 534 | 535 | if ' ptr ' in op_text and self._mode is keystone.KS_MODE_32: 536 | return op_text 537 | 538 | # 539 | # TODO: document these cases 540 | # 541 | 542 | op = insn.ops[n] 543 | seg_reg = (op.specval & 0xFFFF0000) >> 16 544 | 545 | if seg_reg: 546 | #print("SEG REG: 0x%X 0x%X" % (op.specval & 0xFFFF, ((op.specval & 0xFFFF0000) >> 16))) 547 | seg_reg_name = ida_idp.ph.regnames[seg_reg] 548 | if seg_reg_name == 'cs': 549 | op_text = op_text.replace('cs:', '') 550 | elif seg_reg_name not in op_text: 551 | op_text = '%s:%s' % (seg_reg_name, op_text) 552 | 553 | if ' ptr ' in op_text: 554 | return op_text 555 | 556 | t_name = get_dtype_name(op.dtype, ida_ua.get_dtype_size(op.dtype)) 557 | op_text = '%s ptr %s' % (t_name, op_text) 558 | 559 | return op_text 560 | 561 | def format_imm_op(self, insn, n): 562 | op_text = super(AsmX86, self).format_imm_op(insn, n) 563 | if '$+' in op_text: 564 | op_text = ida_ua.print_operand(insn.ea, n, 0, self._NO_OP_TYPE) 565 | return op_text 566 | 567 | def unalias(self, assembly): 568 | 569 | # normalize spacing / capitalization 570 | parts = list(filter(None, assembly.lower().split(' '))) 571 | full = ' '.join(parts) 572 | if not full: 573 | return assembly 574 | 575 | # 576 | # IDA64 likes to print 'int 3' for 'CC', but keystone assembles this 577 | # to 'CD 03'... so we alias 'int 3' to 'int3' here instead which will 578 | # emit the preferred form 'CC' 579 | # 580 | 581 | if full == 'int 3': 582 | return 'int3' 583 | 584 | # 585 | # TODO/XXX: keystone doesn't know about 'movsd' ? so we correct it 586 | # here for now ... this will handle 'movsd' / 'rep* movsd' 587 | # 588 | 589 | if parts[-1] == 'movsd': 590 | 591 | if self._mode & keystone.KS_MODE_64: 592 | regs = ('rdi', 'rsi') 593 | else: 594 | regs = ('edi', 'esi') 595 | 596 | # preserves prefix ... if there was one 597 | return assembly + ' dword ptr [%s], dword ptr [%s]' % regs 598 | 599 | # no special aliasing / fixups 600 | return assembly 601 | 602 | #------------------------------------------------------------------------------ 603 | # ARM / ARM64 604 | #------------------------------------------------------------------------------ 605 | 606 | class AsmARM(KeystoneAssembler): 607 | """ 608 | ARM specific wrapper for Keystone. 609 | """ 610 | 611 | UNCONDITIONAL_JUMP = 'B' 612 | CONDITIONAL_JUMPS = \ 613 | [ 614 | # ARM 615 | 'BEQ', 'BNE', 'BCC', 'BCS', 'BVC', 'BVS', 616 | 'BMI', 'BPL', 'BHS', 'BLO', 'BHI', 'BLS', 617 | 'BGE', 'BLT', 'BGT', 'BLE' 618 | 619 | # ARM64 620 | 'B.EQ', 'B.NE', 'B.CS', 'B.CC', 'B.MI', 'B.PL', 621 | 'B.VS', 'B.VC', 'B.HI', 'B.LS', 'B.GE', 'B.LT', 622 | 'B.GT', 'B.LE', 'CBNZ', 'CBZ', 'TBZ', 'TBNZ' 623 | ] 624 | 625 | UNSUPPORTED_MNEMONICS = \ 626 | [ 627 | 'ADR', 'ADRL', 628 | 629 | # Pointer Authentication 630 | 'AUTDA', 'AUTDZA', 'AUTDB', 'AUTDZB', 631 | 'AUTIA', 'AUTIA1716', 'AUTIASP', 'AUTIAZ', 'AUTIZA', 632 | 'AUTIB', 'AUTIB1716', 'AUTIBSP', 'AUTIBZ', 'AUTIZB', 633 | 634 | 'BLRAA', 'BLRAAZ', 'BLRAB', 'BLRABZ', 635 | 'BRAA', 'BRAAZ', 'BRAB', 'BRABZ', 636 | 637 | 'PACDA', 'PACDZA', 'PACDB', 'PACDZB', 'PACGA', 638 | 'PACIA', 'PACIA1716', 'PACIASP', 'PACIAZ', 'PACIZA', 639 | 'PACIB', 'PACIB1716', 'PACIBSP', 'PACIBZ', 'PACIZB', 640 | 'RETAA', 'RETAB', 641 | 642 | 'XPACD', 'XPACI', 'XPACLRI' 643 | 644 | # TODO: MRS and MOV (32/64 bit) are semi-supported too 645 | ] 646 | 647 | def __init__(self): 648 | 649 | # ARM64 650 | if ida_ida.inf_is_64bit(): 651 | arch = keystone.KS_ARCH_ARM64 652 | 653 | if ida_ida.inf_is_be(): 654 | mode = keystone.KS_MODE_BIG_ENDIAN 655 | else: 656 | mode = keystone.KS_MODE_LITTLE_ENDIAN 657 | 658 | # AArch64 does not use THUMB 659 | self._ks_thumb = None 660 | 661 | # ARM 662 | else: 663 | arch = keystone.KS_ARCH_ARM 664 | 665 | if ida_ida.inf_is_be(): 666 | mode = keystone.KS_MODE_ARM | keystone.KS_MODE_BIG_ENDIAN 667 | self._ks_thumb = keystone.Ks(arch, keystone.KS_MODE_THUMB | keystone.KS_MODE_BIG_ENDIAN) 668 | else: 669 | mode = keystone.KS_MODE_ARM | keystone.KS_MODE_LITTLE_ENDIAN 670 | self._ks_thumb = keystone.Ks(arch, keystone.KS_MODE_THUMB | keystone.KS_MODE_LITTLE_ENDIAN) 671 | 672 | # initialize keystone-based assembler 673 | super(AsmARM, self).__init__(arch, mode) 674 | 675 | # pre-assemble for later, repeated use 676 | self.__ARM_NOP_4, _ = self._ks.asm('NOP', as_bytes=True) 677 | if self._ks_thumb: 678 | self.__THUMB_NOP_2, _ = self._ks_thumb.asm('NOP', as_bytes=True) 679 | self.__THUMB_NOP_4, _ = self._ks_thumb.asm('NOP.W', as_bytes=True) 680 | 681 | def asm(self, assembly, ea=0, resolve=True): 682 | 683 | # swap engines when trying to assemble to a THUMB region 684 | if self.is_thumb(ea): 685 | ks = self._ks 686 | self._ks = self._ks_thumb 687 | data = super(AsmARM, self).asm(assembly, ea, resolve) 688 | self._ks = ks 689 | return data 690 | 691 | # assemble as ARM 692 | return super(AsmARM, self).asm(assembly, ea, resolve) 693 | 694 | @staticmethod 695 | def is_thumb(ea): 696 | """ 697 | Return True if the given address is marked as THUMB. 698 | """ 699 | return bool(ida_segregs.get_sreg(ea, ida_idp.str2reg('T')) == 1) 700 | 701 | def nop_buffer(self, start_ea, end_ea): 702 | """ 703 | Generate a NOP buffer for the given address range. 704 | """ 705 | range_size = end_ea - start_ea 706 | if range_size < 0: 707 | return bytes() 708 | 709 | # 710 | # TODO/XXX: how should we handle 'mis-aligned' NOP actions? or 711 | # truncated range? (eg, not enough bytes to fill as complete NOPs... 712 | # 713 | # Should we just reject them here? or attempt to NOP some? Need to 714 | # ensure UI fails gracefully, etc. 715 | # 716 | 717 | # the crafted buffer on NOP instructions to return 718 | nop_list = [] 719 | 720 | # 721 | # with ARM, it is imperative we attempt to retain the size of the 722 | # instruction being NOP'd. this is to help account for cases such as 723 | # the ITTT blocks in THUMB: 724 | # 725 | # __text:000021A2 1E BF ITTT NE 726 | # __text:000021A4 D4 F8 C4 30 LDRNE.W R3, [R4,#0xC4] 727 | # __text:000021A8 43 F0 04 03 ORRNE.W R3, R3, #4 728 | # __text:000021AC C4 F8 C4 30 STRNE.W R3, [R4,#0xC4] 729 | # __text:000021B0 94 F8 58 30 LDRB.W R3, [R4,#0x58] 730 | # 731 | # replacing these 4-byte THUMB instructions with 2-byte THUMB NOP's 732 | # breaks the intrinsics of the conditional block. therefore, we 733 | # will attempt to replace THUMB instructions with a NOP of the same 734 | # size as the original instruction 735 | # 736 | 737 | cur_ea = ida_bytes.get_item_head(start_ea) 738 | while cur_ea < end_ea: 739 | item_size = ida_bytes.get_item_size(cur_ea) 740 | 741 | # special handling to pick THUMB 2 / 4 byte NOP as applicable 742 | if self.is_thumb(cur_ea): 743 | if item_size == 2: 744 | nop_list.append(self.__THUMB_NOP_2) 745 | else: 746 | nop_list.append(self.__THUMB_NOP_4) 747 | 748 | # NOP'ing a normal 4-byte ARM instruction 749 | else: 750 | nop_list.append(self.__ARM_NOP_4) 751 | 752 | # continue to next instruction 753 | cur_ea += item_size 754 | 755 | # return a buffer of (NOP) instruction bytes 756 | return b''.join(nop_list) 757 | 758 | #-------------------------------------------------------------------------- 759 | # ARM Assembly Formatting / Fixups 760 | #-------------------------------------------------------------------------- 761 | 762 | def format_memory_op(self, insn, n): 763 | op = insn.ops[n] 764 | 765 | # ARM / ARM64 766 | if ida_idp.ph.regnames[op.reg] == 'PC': 767 | offset = (op.addr - insn.ea) - 8 768 | op_text = '[PC, #%s0x%X]' % ('-' if offset < 0 else '', abs(offset)) 769 | return op_text 770 | 771 | # 772 | # TODO: THUMB-ish... note this is kind of groess and should 773 | # probably be cleaned up / documented better. I don't think it's a 774 | # fair assumption that all THUMB memory references are PC rel? but 775 | # maybe that's true. (I'm not an ARM expert) 776 | # 777 | 778 | elif self.is_thumb(insn.ea): 779 | offset = (op.addr - insn.ea) - 4 + (insn.ea % 4) 780 | op_text = '[PC, #%s0x%X]' % ('-' if offset < 0 else '', abs(offset)) 781 | return op_text 782 | 783 | op_text = ida_lines.tag_remove(super(AsmARM, self).format_memory_op(insn, n)) 784 | 785 | if op_text[0] == '=': 786 | op_text = '#0x%X' % op.addr 787 | 788 | return op_text 789 | 790 | def format_imm_op(self, insn, n): 791 | """ 792 | TODO: this is temporary, until we do work on formatting IDA's 793 | ARM memory ref 'symbols' (which are often imms on ARM) 794 | """ 795 | op_text = ida_ua.print_operand(insn.ea, n, 0, self._NO_OP_TYPE) 796 | return op_text 797 | 798 | def unalias(self, assembly): 799 | prefix, mnemonic, ops = parse_disassembly_components(assembly) 800 | 801 | # IDA seems to prefer showing 'STMFA', but keystone expects 'STMIB' 802 | if mnemonic.upper() == 'STMFA': 803 | return ' '.join([prefix, 'STMIB', ops]) 804 | 805 | return assembly 806 | 807 | #------------------------------------------------------------------------------ 808 | # PPC / PPC64 TODO 809 | #------------------------------------------------------------------------------ 810 | 811 | class AsmPPC(KeystoneAssembler): 812 | 813 | def __init__(self): 814 | arch = keystone.KS_ARCH_PPC 815 | 816 | if ida_ida.inf_is_64bit(): 817 | mode = keystone.KS_MODE_PPC64 818 | else: 819 | mode = keystone.KS_MODE_PPC32 820 | 821 | # TODO: keystone does not support Little Endian mode for PPC? 822 | #if arch_name == 'ppc': 823 | # mode += keystone.KS_MODE_BIG_ENDIAN 824 | 825 | # initialize keystone-based assembler 826 | super(AsmPPC, self).__init__(arch, mode) 827 | 828 | #------------------------------------------------------------------------------ 829 | # MIPS / MIPS64 TODO 830 | #------------------------------------------------------------------------------ 831 | 832 | class AsmMIPS(KeystoneAssembler): 833 | 834 | def __init__(self): 835 | arch = keystone.KS_ARCH_MIPS 836 | 837 | if ida_ida.inf_is_64bit(): 838 | mode = keystone.KS_MODE_MIPS64 839 | else: 840 | mode = keystone.KS_MODE_MIPS32 841 | 842 | if ida_ida.inf_is_be(): 843 | mode |= keystone.KS_MODE_BIG_ENDIAN 844 | else: 845 | mode |= keystone.KS_MODE_LITTLE_ENDIAN 846 | 847 | # initialize keystone-based assembler 848 | super(AsmMIPS, self).__init__(arch, mode) 849 | 850 | #------------------------------------------------------------------------------ 851 | # SPARC TODO 852 | #------------------------------------------------------------------------------ 853 | 854 | class AsmSPARC(KeystoneAssembler): 855 | 856 | def __init__(self): 857 | arch = keystone.KS_ARCH_SPARC 858 | 859 | if ida_ida.inf_is_64bit(): 860 | mode = keystone.KS_MODE_SPARC64 861 | else: 862 | mode = keystone.KS_MODE_SPARC32 863 | 864 | if ida_ida.inf_is_be(): 865 | mode |= keystone.KS_MODE_BIG_ENDIAN 866 | else: 867 | mode |= keystone.KS_MODE_LITTLE_ENDIAN 868 | 869 | # initialize keystone-based assembler 870 | super(AsmSPARC, self).__init__(arch, mode) 871 | 872 | #------------------------------------------------------------------------------ 873 | # System-Z 874 | #------------------------------------------------------------------------------ 875 | 876 | class AsmSystemZ(KeystoneAssembler): 877 | 878 | def __init__(self): 879 | super(AsmSystemZ, self).__init__(keystone.KS_ARCH_SYSTEMZ, keystone.KS_MODE_BIG_ENDIAN) 880 | -------------------------------------------------------------------------------- /plugins/patching/util/ida.py: -------------------------------------------------------------------------------- 1 | import re 2 | import ctypes 3 | 4 | import ida_ua 5 | import ida_ida 6 | import ida_idp 7 | import ida_auto 8 | import ida_nalt 9 | import ida_name 10 | import ida_bytes 11 | import ida_lines 12 | import ida_idaapi 13 | import ida_kernwin 14 | import ida_segment 15 | 16 | from .qt import * 17 | from .python import swap_value 18 | 19 | #------------------------------------------------------------------------------ 20 | # IDA Hooks 21 | #------------------------------------------------------------------------------ 22 | 23 | class UIHooks(ida_kernwin.UI_Hooks): 24 | def ready_to_run(self): 25 | pass 26 | def get_lines_rendering_info(self, out, widget, rin): 27 | pass 28 | def populating_widget_popup(self, widget, popup, ctx): 29 | pass 30 | 31 | class IDPHooks(ida_idp.IDP_Hooks): 32 | def ev_ending_undo(self, action_name, is_undo): 33 | pass 34 | 35 | class IDBHooks(ida_idp.IDB_Hooks): 36 | def auto_empty_finally(self): 37 | pass 38 | def byte_patched(self, ea, value): 39 | pass 40 | 41 | #------------------------------------------------------------------------------ 42 | # IDA Misc 43 | #------------------------------------------------------------------------------ 44 | 45 | def is_reg_name(reg_name): 46 | """ 47 | Return True if the given string is a known register name. 48 | """ 49 | ri = ida_idp.reg_info_t() 50 | return bool(ida_idp.parse_reg_name(ri, reg_name)) 51 | 52 | def is_mnemonic(mnemonic): 53 | """ 54 | Return True if the given string is a known mnemonic (roughly). 55 | 56 | TODO: remove or offload to Keystone if possible? this is just 'best effort' 57 | TODO: actually this can probably be removed now? no longer used... 58 | """ 59 | 60 | # cache known mnemonics for the current proc on the first invocation 61 | if not hasattr(is_mnemonic, 'known_mnemonics'): 62 | is_mnemonic.known_mnemonics = set([name.upper() for name, _ in ida_idp.ph.instruc]) 63 | 64 | # check if the given mnemonic is in the list of known mnemonics 65 | mnemonic = mnemonic.upper() 66 | return bool(mnemonic in is_mnemonic.known_mnemonics) 67 | 68 | def is_range_patched(start_ea, end_ea=None): 69 | """ 70 | Return True if a patch exists within the given address range. 71 | """ 72 | if end_ea == None: 73 | end_ea = start_ea + 1 74 | 75 | def visitor(ea, file_offset, original_value, patched_value): 76 | return 1 77 | 78 | return bool(ida_bytes.visit_patched_bytes(start_ea, end_ea, visitor)) 79 | 80 | def apply_patches(filepath): 81 | """ 82 | Apply the current IDB patches to the given filepath. 83 | """ 84 | 85 | with open(filepath, 'r+b') as f: 86 | 87 | # 88 | # a visitor function that will be called for each patched byte. 89 | # 90 | # NOTE: this is a python version of IDA's built in 'Apply patches...' 91 | # routine that has simply been reverse engineered 92 | # 93 | 94 | def visitor(ea, file_offset, original_value, patched_value): 95 | 96 | # the patched byte does not have a know file address 97 | if file_offset == ida_idaapi.BADADDR: 98 | print("%08X: has no file mapping (original: %02X patched: %02X)...skipping...\n" % (ea, original_value, patched_value)) 99 | return 0 100 | 101 | # seek to the patch location 102 | f.seek(file_offset) 103 | 104 | # fetch the 'number of bits in a byte' for the given address (? lol) 105 | bits = ida_bytes.nbits(ea) 106 | 107 | # round the number of bits up to bytes 108 | num_bytes = (bits + 7) // 8 109 | 110 | # IDA does this, basically (swap_value(...)) so we will too 111 | if ida_ida.inf_is_wide_high_byte_first(): 112 | byte_order = 'big' 113 | else: 114 | byte_order = 'little' 115 | 116 | # convert the int/long patch value to bytes (and swap endianess, if needed) 117 | patched_value = patched_value.to_bytes(num_bytes, byte_order) 118 | 119 | # write the patched byte(s) to the output file 120 | f.write(patched_value) 121 | 122 | # 123 | # return 0 so that the visitor keeps going to the next patched bytes 124 | # instead of stopping after this one. 125 | # 126 | 127 | return 0 128 | 129 | # 130 | # RUN THE VISITOR / APPLY PATCHES 131 | # 132 | 133 | ida_bytes.visit_patched_bytes(0, ida_idaapi.BADADDR, visitor) 134 | 135 | # 136 | # all done, file will close as we leave this 'with' scoping 137 | # 138 | 139 | pass 140 | 141 | # done done 142 | return 143 | 144 | #------------------------------------------------------------------------------ 145 | # IDA UI 146 | #------------------------------------------------------------------------------ 147 | 148 | def attach_submenu_to_popup(popup_handle, submenu_name, prev_action_name): 149 | """ 150 | Create an IDA submenu AFTER the action name specified by prev_action_name. 151 | 152 | TODO/XXX/HACK/Hex-Rays: this is a workaround for not being able to create 153 | and position submenu groups for rightclick menus 154 | """ 155 | if not QT_AVAILABLE: 156 | return None 157 | 158 | # 159 | # convert IDA alt shortcut syntax to whatever they use in Qt Text menus 160 | # eg: '~A~ssemble patches to...' --> '&Assemble patches to...' 161 | # 162 | 163 | prev_action_name = re.sub(r'~(.)~', r'&\1', prev_action_name) 164 | 165 | # cast an IDA 'popup handle' pointer back to a QMenu object 166 | p_qmenu = ctypes.cast(int(popup_handle), ctypes.POINTER(ctypes.c_void_p))[0] 167 | qmenu = sip.wrapinstance(int(p_qmenu), QtWidgets.QMenu) 168 | 169 | # create a Qt (sub)menu that can be injected into an IDA-originating menu 170 | submenu = QtWidgets.QMenu(submenu_name) 171 | 172 | # search for the target action to insert the submenu next to 173 | all_actions = list(qmenu.actions()) 174 | for i, current_action in enumerate(all_actions[:-1]): 175 | if current_action.text() == prev_action_name: 176 | insertion_point = all_actions[i+1] 177 | qmenu.insertMenu(insertion_point, submenu) 178 | break 179 | 180 | # 181 | # if we did not find the action we wanted to place the new submenu after, 182 | # simply append it to the end of the menu 183 | # 184 | 185 | else: 186 | qmenu.addMenu(submenu) 187 | 188 | # 189 | # not totally sure if we need to be managing the lifetime of this submenu 190 | # even after it has been inserted. so we return it here, just in-case. 191 | # 192 | 193 | return submenu 194 | 195 | #------------------------------------------------------------------------------ 196 | # Symbols 197 | #------------------------------------------------------------------------------ 198 | 199 | # TODO: err this might not be a good assumption for mangling... eg '()' 200 | IGNORED_CHARS = R"!,[]{}#+-*:" 201 | IGNORED_CHARS_MAP = {ord(x): ' ' for x in IGNORED_CHARS} 202 | IGNORED_REGISTERS = set() 203 | IGNORED_KEYWORDS = set( 204 | [ 205 | # x86 / x64 206 | 'byte', 'short', 'word', 'dword', 'qword', 'xword', 'xmmword', 'ymmword', 'tbyte', 'large', 'long', 'near', 'far', 'ptr', 'offset', 207 | 208 | # ARM 209 | 'eq', 'ne', 'cs', 'hs', 'cc', 'lo', 'mi', 'pl', 'vs', 'vc', 'hi', 'ls', 'ge', 'lt', 'gt', 'le', 'al' 210 | ] 211 | ) 212 | 213 | def scrape_symbols(disassembly_text): 214 | """ 215 | Attempt to scrape symbol-like values from a line of disassembly. 216 | """ 217 | global IGNORED_REGISTERS 218 | symbols = [] 219 | 220 | # split a comment off the given disassembly text, if present 221 | #x, sep, y = disassembly_text.rpartition('; ') 222 | #dis, cmt = (x, y) if sep else (y, x) 223 | assert ';' not in disassembly_text 224 | 225 | # 226 | # TODO: I'm really not sure how we should deal with cpp / demangled-ish 227 | # symbols in disassembly text. if we see something like foo::bar(...) 228 | # in the given disassembly text, our code is going to explode 229 | # 230 | # so for now we're just going to make no effort to parse out possible 231 | # cpp symbols and will figure out how to deal with them later :/ 232 | # 233 | 234 | if '::' in disassembly_text or '`' in disassembly_text: 235 | return [] 236 | 237 | # remove common disas chars that will not appear in an IDA name 238 | dis = disassembly_text.translate(IGNORED_CHARS_MAP) 239 | 240 | # 241 | # regex match any remaining 'non-whitespace' text, which should have its 242 | # position preserved from the original string. this should allow us to 243 | # return the symbols and their index in the given text 244 | # 245 | 246 | for m in re.finditer(r'\S+', dis): 247 | 248 | # normalize the potential symbol text 249 | original_symbol = m.group() 250 | word = original_symbol.lower() 251 | 252 | # ignore previously seen registers (fastpath) 253 | if word in IGNORED_REGISTERS: 254 | continue 255 | 256 | # ignore numbers / immediates (only imms can start with a number) 257 | if word[0] in '0123456789': 258 | continue 259 | 260 | # ignore IDA keywords (approximate) 261 | if word in IGNORED_KEYWORDS: 262 | continue 263 | 264 | # ignore new registers (and cache it for future scrapes) 265 | if is_reg_name(word): 266 | IGNORED_REGISTERS.add(word) 267 | continue 268 | 269 | # XXX: kind of a hack for things like 'movzx eax, ds:(jump_table_11580-20h)[eax]' 270 | if original_symbol[0] == '(': 271 | original_symbol = original_symbol[1:] 272 | 273 | # eg: '$)' 274 | elif original_symbol[-1] == ')' and '(' not in original_symbol: 275 | original_symbol = original_symbol[:-1] 276 | 277 | # possible symbol! 278 | symbols.append((original_symbol, m.span())) 279 | 280 | # return list of likely symbols 281 | return symbols 282 | 283 | def resolve_symbol(from_ea, name): 284 | """ 285 | Return an address or value for the given symbol. 286 | 287 | TODO/Hex-Rays: this function is overly complex and is probably something 288 | that should be baked into IDA as more aggressive 'resolve symbol' API imo 289 | 290 | this function will yield matching symbol values (operating as a 291 | generator). this is because IDA can show 'visually identical' symbols in 292 | rendered instructions that have different 'true' names. 293 | 294 | eg. a func named '.X.' appears as '_X_' in IDA's x86 disassembly. but 295 | a second func could be named '.X_' which will also appear as '_X_' 296 | 297 | while this is maybe okay in the context of IDA (where it has concrete 298 | instruction / address info) ... it is not okay for trying to 'resolve' 299 | a symbol when your only information is assembly text. 300 | 301 | if the user types in the following instruction: 302 | 303 | eg. call _X_ 304 | 305 | how can we know which value to select as a jump target? 306 | 307 | (the user will have to decide... through some symbol collision hinting... 308 | but the point still stands: a function like this has to be able to return 309 | 'multiple' potential values) 310 | """ 311 | 312 | # XXX: deferred import to avoid breaking patching.reload() dev helper 313 | import idc 314 | 315 | # 316 | # first, we will attempt to parse the given symbol as a global 317 | # struct path. 318 | # 319 | # eg. 'g_foo.bar.baz' 320 | # 321 | # NOTE: this kind of has to be first, because our second section of 322 | # symbol resolution (get_name_ea, get_name_value) will incorrectly 323 | # 'resolve' a global struct path used at a given address. 324 | # 325 | # by incorrectly, i mean that global struct path reference in an 326 | # instruction will resolve to the base address of the global, not 327 | # the actual referenced field within the global 328 | # 329 | # TODO: there's a bug or something in my code still, this is not 330 | # computing the right offset in some cases (try assemble_all() on 331 | # ntoskrnl.exe from Windows 11 to see some of the failures) 332 | # 333 | 334 | global_name, sep, struct_path = name.partition('.') 335 | 336 | # 337 | # if sep 'exists', that means there is a '.' in the given symbol so it 338 | # *could* be a global struct path. let's try to walk though it 339 | # 340 | 341 | if sep: 342 | 343 | resolved_paths = 0 344 | 345 | for global_ea, real_name in resolve_symbol(from_ea, global_name): 346 | 347 | # if the resolved symbol address is not a global struct, ignore it 348 | if not ida_bytes.is_struct(ida_bytes.get_flags(global_ea)): 349 | continue 350 | 351 | # get the struct info for the resolved global address 352 | sid = ida_nalt.get_strid(global_ea) 353 | 354 | offset = 0 355 | while struct_path and sid != -1: 356 | member_name, sep, struct_path = struct_path.partition('.') 357 | member_offset = idc.get_member_offset(sid, member_name) 358 | 359 | if member_offset == -1: 360 | print(" - INVALID STRUCT MEMBER!", member_name) 361 | break 362 | 363 | offset += member_offset 364 | sid = idc.get_member_strid(sid, member_offset) 365 | 366 | # The idc.get_member_strid function in IDA 9.0 beta has a bug. 367 | # Even if a member is not a structure, it does not return -1. 368 | # Therefore, it's necessary to use struct_path to determine whether the retrieval is complete. 369 | if not struct_path or sid == -1: 370 | assert not('.' in struct_path), 'Expected end of struct path?' 371 | yield (global_ea+offset, name) 372 | resolved_paths += 1 373 | 374 | # 375 | # TODO/XXX: if we yielded at least one struct path... we're *probably* 376 | # good. I don't think 377 | # 378 | 379 | if resolved_paths: 380 | return 381 | 382 | # 383 | # if the given symbol does not appear to be a global struct path, we 384 | # will try to use some of IDA's more typical 'name' --> address API's 385 | # 386 | # should any of these succeed, they are most certainly to be the symbol 387 | # value the user / instruction intended 388 | # 389 | 390 | value = ida_name.get_name_ea(from_ea, name) 391 | if value != ida_idaapi.BADADDR: 392 | yield (value, name) 393 | return 394 | 395 | nt, value = ida_name.get_name_value(from_ea, name) 396 | if nt != ida_name.NT_NONE: 397 | yield (value, name) 398 | return 399 | 400 | if name == '$': 401 | yield (from_ea, name) 402 | return 403 | 404 | # 405 | # yield all matches for a sanitized (codepage-validated?) name 406 | # 407 | # TODO/PERF: lol this is ridiculously expensive 408 | # 409 | 410 | # alias for speed (does this pseudo-optimization even work in py3 anymore? lol) 411 | get_nlist_ea = ida_name.get_nlist_ea 412 | get_nlist_name = ida_name.get_nlist_name 413 | #get_short_name = ida_name.get_short_name 414 | get_visible_name = ida_name.get_visible_name 415 | 416 | for idx in range(ida_name.get_nlist_size()): 417 | address = get_nlist_ea(idx) 418 | #visible_name = get_short_name(address) 419 | visible_name = get_visible_name(address) 420 | #visible_name = ida_name.validate_name(real_name, ida_name.VNT_IDENT) # ??? 421 | if visible_name == name: 422 | real_name = get_nlist_name(idx) 423 | yield (address, real_name) 424 | 425 | def get_dtype_name(dtype, size): 426 | """ 427 | Return the keyword for the given data type. 428 | """ 429 | dtype_map = \ 430 | { 431 | ida_ua.dt_byte: 'byte', # 8 bit 432 | ida_ua.dt_word: 'word', # 16 bit 433 | ida_ua.dt_dword: 'dword', # 32 bit 434 | ida_ua.dt_float: 'dword', # 4 byte 435 | ida_ua.dt_double: 'qword', # 8 byte 436 | ida_ua.dt_qword: 'qword', # 64 bit 437 | ida_ua.dt_byte16: 'xmmword', # 128 bit 438 | ida_ua.dt_byte32: 'ymmword', # 256 bit 439 | } 440 | 441 | if dtype == ida_ua.dt_tbyte and size == 10: 442 | return 'xword' 443 | 444 | return dtype_map.get(dtype, None) 445 | 446 | def get_tag_name(scolor): 447 | """ 448 | Return the name of a given COLOR tag. 449 | """ 450 | attribute_names = dir(ida_lines) 451 | 452 | for name in attribute_names: 453 | if not name.startswith('SCOLOR_'): 454 | continue 455 | value = getattr(ida_lines, name) 456 | if value == scolor: 457 | return name 458 | 459 | return '' 460 | 461 | def rewrite_tag_addrs(line, wrap=False): 462 | """ 463 | Rewrite symbol text with their COLOR values 464 | 465 | TODO: remove? 466 | """ 467 | if not line: 468 | return 469 | 470 | og_line = line 471 | og_index = 0 472 | 473 | while len(line) > 0: 474 | 475 | skipcode_index = ida_lines.tag_skipcode(line) 476 | 477 | if skipcode_index == 0: # No code found 478 | line = line[1:] # Skip one character ahead 479 | og_index += 1 480 | continue 481 | 482 | if not(line[0] == ida_lines.COLOR_ON and line[1] == chr(ida_lines.COLOR_ADDR)): 483 | line = line[skipcode_index:] 484 | og_index += skipcode_index 485 | continue 486 | 487 | # parse the hidden text address from the tagged line 488 | address = int(line[2:skipcode_index], 16) 489 | 490 | # skip past the address to the symbol 491 | line = line[skipcode_index:] 492 | og_index += skipcode_index 493 | 494 | # copy the symbol out of the tagged line 495 | symbol = line[:line.index(ida_lines.COLOR_OFF)] 496 | symbol_index = og_index 497 | #print("Found addr: 0x%08X, '%s'" % (address, symbol)) 498 | 499 | if wrap: 500 | address_text = "[0x%X]" % address 501 | else: 502 | address_text = "0x%X" 503 | 504 | # write the address text over the place of the original symbol 505 | og_line = og_line[:symbol_index] + address_text + og_line[symbol_index+len(symbol):] 506 | 507 | # continue past the extracted symbol text 508 | skipcode_index = ida_lines.tag_skipcode(line) 509 | line = line[skipcode_index:] 510 | og_index += len(address_text) # special adjustment, to account for the injected address text 511 | 512 | return ida_lines.tag_remove(og_line) 513 | 514 | def get_disassembly_components_slow(ea): 515 | """ 516 | Return (prefix, mnemonic, [operands]) from IDA's disassembly text. 517 | 518 | TODO: remove? 519 | """ 520 | if not ida_bytes.is_code(ida_bytes.get_flags(ea)): 521 | return (None, None, []) 522 | 523 | # alias for simpler code / formatting 524 | COLOR_OPNDS = [chr(ida_lines.COLOR_OPND1+i) for i in range(7)] 525 | 526 | # tag parsing output 527 | comps_insn = [] 528 | comps_ops = [None for i in range(7)] 529 | 530 | # tag parsing state 531 | tag_chars = [] 532 | tag_stack = [] 533 | 534 | # fetch the 'colored' (tagged) instruction text from IDA for parsing 535 | insn_text = ida_lines.generate_disasm_line(ea) 536 | 537 | # 538 | # using the IDA 'color' tags, we can parse spans of text generated by IDA 539 | # to determine the different parts of a printed instruction. 540 | # 541 | # this is useful because we can let IDA's core / proc module handle the 542 | # printing of specific features (e.g. instruction prefixes, size 543 | # annotations, segment references) without trying to re-implement the 544 | # full insn printing pipeline on our own. 545 | # 546 | 547 | while insn_text: 548 | skipcode_index = ida_lines.tag_skipcode(insn_text) 549 | 550 | # 551 | # if we are not sitting on top of a 'color code' / tag action, then 552 | # we do not need to take any special parsing action. 553 | # 554 | 555 | if skipcode_index == 0: 556 | tag_chars.append(insn_text[0]) 557 | insn_text = insn_text[1:] 558 | continue 559 | 560 | #print('BYTES', ' '.join(['%02X' % ord(x) for x in insn_text[0:2]])) 561 | tag_action, tag_type = insn_text[0:2] 562 | 563 | # 564 | # entering a new color tag / text span 565 | # 566 | 567 | if tag_action == ida_lines.SCOLOR_ON: 568 | 569 | # 570 | # address tags do not have a closing tag, so we must consume 571 | # them immediately. 572 | # 573 | 574 | if tag_type == ida_lines.SCOLOR_ADDR: 575 | 576 | # parse the 'invisible' address reference 577 | address = int(insn_text[2:2+ida_lines.COLOR_ADDR_SIZE], 16) 578 | #symbol = insn_text[2+ida_lines.COLOR_ADDR:skipcode_index] 579 | #print("FOUND SYMBOL '%s' ADDRESS 0x%8X" % (symbol, address)) 580 | 581 | # continue parsing the line 582 | insn_text = insn_text[skipcode_index:] 583 | continue 584 | 585 | tag_stack.append((tag_type, tag_chars)) 586 | tag_chars = [] 587 | 588 | # 589 | # exiting a color tag / text span 590 | # 591 | 592 | elif tag_action == ida_lines.SCOLOR_OFF: 593 | entered_tag, prev_tag_chars = tag_stack.pop() 594 | assert entered_tag == tag_type, "EXITED '%s' EXPECTED '%s'" % (get_tag_name(tag_type), get_tag_name(entered_tag)) 595 | tag_text = ''.join(tag_chars).strip() 596 | 597 | # save instruction prefixes or the mnemonic 598 | if tag_type == ida_lines.SCOLOR_INSN: 599 | comps_insn.append(tag_text) 600 | 601 | # save instruction operands 602 | elif tag_type in COLOR_OPNDS: 603 | op_num = ord(tag_type) - ida_lines.COLOR_OPND1 604 | #print("ADDRESS 0x%08X OP %u: %s" % (ea, op_num, tag_text)) 605 | comps_ops[op_num] = tag_text 606 | 607 | # ignore the rest? (for now I guess) 608 | else: 609 | #print("NOT SAVING: '%s' TAG TYPE '%s' " % (tag_text, get_tag_name(tag_type))) 610 | pass 611 | 612 | tag_chars = prev_tag_chars + tag_chars 613 | 614 | # continue past the 'color codes' / tag info 615 | insn_text = insn_text[skipcode_index:] 616 | 617 | # if there is more than one 'insn component', assume they are prefixes 618 | if len(comps_insn) > 1: 619 | prefix = ' '.join(comps_insn[:-1]) 620 | else: 621 | prefix = '' 622 | 623 | # the instruction mnemonic should be the 'last' instruction component 624 | mnemonic = comps_insn[-1] 625 | 626 | return (prefix, mnemonic, comps_ops) 627 | 628 | # 629 | # TODO/XXX: ehh there's no way to really get / enumerate instruction prefixes 630 | # from IDA processor modules 631 | # 632 | 633 | KNOWN_PREFIXES = set(['xacquire', 'xrelease', 'lock', 'bnd', 'rep', 'repe', 'repne']) 634 | 635 | def get_disassembly_components(ea): 636 | """ 637 | Return (prefix, mnemonic, operands) instruction components for a given address. 638 | """ 639 | line_text = ida_lines.tag_remove(ida_lines.generate_disasm_line(ea)) 640 | return parse_disassembly_components(line_text) 641 | 642 | def parse_disassembly_components(line_text): 643 | """ 644 | Return (prefix, mnemonic, operands) from the given instruction text. 645 | """ 646 | 647 | # remove comment (if present) 648 | insn_text = line_text.split(';', 1)[0] 649 | 650 | # split instruction roughly into its respective elements 651 | elements = insn_text.split(' ') 652 | 653 | # 654 | # parse prefixes 655 | # 656 | 657 | for i, value in enumerate(elements): 658 | if not (value in KNOWN_PREFIXES): 659 | break 660 | 661 | # 662 | # if we didn't break from the loop, that means *every* element in the 663 | # split text was an instruction prefix. this seems odd, but it can 664 | # happen, eg the 'lock' instruction... by itself (in x86) is valid 665 | # 666 | # in this case, there is no mnemonic, or operands. just a prefix 667 | # 668 | 669 | else: 670 | return (' '.join(elements), '', '') 671 | 672 | # 673 | # there can be multiple instruction prefix 'words' so we stitch them 674 | # together here, in such cases 675 | # 676 | 677 | prefix = ' '.join(elements[:i]) 678 | 679 | # 680 | # parse mnemonic 681 | # 682 | 683 | mnemonic = elements[i] 684 | i += 1 685 | 686 | # 687 | # operands 688 | # 689 | 690 | operands = ' '.join(elements[i:]) 691 | 692 | return (prefix, mnemonic, operands) 693 | 694 | def all_instruction_addresses(ea=0): 695 | """ 696 | Return a generator that yields each instruction address in the IDB. 697 | """ 698 | 699 | # alias for speed 700 | BADADDR = ida_idaapi.BADADDR 701 | SEG_CODE = ida_segment.SEG_CODE 702 | get_flags = ida_bytes.get_flags 703 | get_seg_type = ida_segment.segtype 704 | get_next_head = ida_bytes.next_head 705 | is_code = ida_bytes.is_code 706 | 707 | # yield each instruction address in the IDB 708 | while ea < BADADDR: 709 | 710 | if get_seg_type(ea) != SEG_CODE: 711 | ea = get_next_head(ea, BADADDR) 712 | continue 713 | 714 | # skip any address that is not an instruction 715 | if not is_code(get_flags(ea)): 716 | ea = get_next_head(ea, BADADDR) 717 | continue 718 | 719 | # return the current 'instruction' address 720 | yield ea 721 | 722 | # continue forward to the next address 723 | ea = get_next_head(ea, BADADDR) 724 | 725 | def disassemble_bytes(data, ea): 726 | """ 727 | Disassemble the given bytes using IDA at the given address. 728 | """ 729 | old = ida_auto.set_auto_state(False) 730 | 731 | # fetch the current bytes (they could be patched already!) 732 | original_data = ida_bytes.get_bytes(ea, len(data)) 733 | 734 | # 735 | # temporarily patch in the data we want IDA to disassemble, and fetch 736 | # the resulting disassembly text 737 | # 738 | 739 | ida_bytes.patch_bytes(ea, data) 740 | text = ida_lines.generate_disasm_line(ea) 741 | 742 | # revert the saved bytes back to the prior state 743 | ida_bytes.patch_bytes(ea, original_data) 744 | 745 | # re-enable the auto analyzer and return the disassembled text 746 | ida_auto.enable_auto(old) 747 | return ida_lines.tag_remove(text) 748 | 749 | #------------------------------------------------------------------------------ 750 | # IDA Viewer Shims 751 | #------------------------------------------------------------------------------ 752 | 753 | # 754 | # TODO/Hex-Rays: 755 | # 756 | # IDA's simplecustviewer_t() does not support populating/hinting fields of 757 | # the 'ctx' structure passed onto IDA Action handlers 758 | # 759 | # for this reason, we have to do some manual resolution of context for our 760 | # patching viewer. these shims are to help keep the action code above a 761 | # bit cleaner until Hex-Rays can improve simple code viewers 762 | # 763 | 764 | def parse_line_ea(colored_line): 765 | """ 766 | Parse a code / instruction address from a colored line in the patching dialog. 767 | """ 768 | line = ida_lines.tag_remove(colored_line) 769 | ea = int(line.split('|')[0], 16) 770 | return ea 771 | 772 | def get_current_ea(ctx): 773 | """ 774 | Return the current address for the given action context. 775 | """ 776 | 777 | # custom / interactive patching view 778 | if ida_kernwin.get_widget_title(ctx.widget) == 'PatchingCodeViewer': 779 | return parse_line_ea(ida_kernwin.get_custom_viewer_curline(ctx.widget, False)) 780 | 781 | # normal IDA widgets / viewers 782 | return ctx.cur_ea 783 | 784 | def read_range_selection(ctx): 785 | """ 786 | Return the currently selected address range for the given action context. 787 | """ 788 | 789 | # custom / interactive patching view 790 | if ida_kernwin.get_widget_title(ctx.widget) == 'PatchingCodeViewer': 791 | 792 | # no active selection in the patching view, nothing to do... 793 | if not(ctx.cur_flags & ida_kernwin.ACF_HAS_SELECTION): 794 | return (False, ida_idaapi.BADADDR, ida_idaapi.BADADDR) 795 | 796 | # extract the start/end cursor locations (place_t) from the given ctx 797 | splace_from = ida_kernwin.place_t_as_simpleline_place_t(ctx.cur_sel._from.at) 798 | splace_to = ida_kernwin.place_t_as_simpleline_place_t(ctx.cur_sel.to.at) 799 | 800 | # 801 | # TODO/Hex-Rays: lol a *BRUTAL HACK* to get the src / dst lines 802 | # 803 | # the problem here is that there is no way to get the contents of an 804 | # arbitrary line (by number) in the custom viewer we created. at least not 805 | # from here, where we don't have a python reference of simplecustviewer_t() 806 | # 807 | # luckily... we can 'generate' (fetch?) the viewer's line through a place_t 808 | # 809 | # lol... 810 | # 811 | 812 | start_line = splace_from.generate(ida_kernwin.get_viewer_user_data(ctx.widget), 1)[0][0] 813 | end_line = splace_to.generate(ida_kernwin.get_viewer_user_data(ctx.widget), 1)[0][0] 814 | 815 | # parse the leading address from the 'colored' text fetched from the patching window 816 | start_ea = parse_line_ea(start_line) 817 | end_ea = parse_line_ea(end_line) 818 | end_ea = ida_bytes.get_item_end(end_ea) 819 | #print("Got %08X --> %08X for custom viewer range parse" % (start_ea, end_ea)) 820 | 821 | # not a true 'range selection' if the start and end line / number is the same 822 | if start_ea == end_ea: 823 | return (False, ida_idaapi.BADADDR, ida_idaapi.BADADDR) 824 | 825 | # return the range of selected lines 826 | return (True, start_ea, end_ea) 827 | 828 | # special tweak for IDA disassembly views 829 | elif ida_kernwin.get_widget_type(ctx.widget) == ida_kernwin.BWN_DISASM: 830 | 831 | # no active selection in the patching view, nothing to do... 832 | if not(ctx.cur_flags & ida_kernwin.ACF_HAS_SELECTION): 833 | return (False, ida_idaapi.BADADDR, ida_idaapi.BADADDR) 834 | 835 | # extract the start/end cursor locations within the IDA disas view 836 | p0 = ida_kernwin.twinpos_t() 837 | p1 = ida_kernwin.twinpos_t() 838 | 839 | # 840 | # this is where we do a special override such that a user can select a 841 | # few characters on a single instruction / line .. and we will return 842 | # the 'range' of just that single instruction 843 | # 844 | # with a few characters selected / highlighted, IDA will return True 845 | # to the read_selection() call below 846 | # 847 | 848 | if ida_kernwin.read_selection(ctx.widget, p0, p1): 849 | start_ea = p0.at.toea() 850 | end_ea = p1.at.toea() 851 | 852 | # 853 | # if the start and end address are the same with a successful 854 | # selection read, that means the user's selection is on a single 855 | # line / instruction 856 | # 857 | # we will calculate an appropriate 'end_ea' ourselves to capture 858 | # the length of the entire instruction and return this as our own 859 | # custom / mini range selection 860 | # 861 | # this facilitates the ability for users to revert individual 862 | # instructions within a patch by selecting a few characters of 863 | # the instruction in question 864 | # 865 | 866 | if start_ea == end_ea: 867 | end_ea = ida_bytes.get_item_end(end_ea) 868 | return (True, start_ea, end_ea) 869 | 870 | # any other IDA widget / viewer 871 | return ida_kernwin.read_range_selection(ctx.widget) 872 | 873 | def remove_ida_actions(popup): 874 | """ 875 | Remove default IDA actions from a given IDA popup (handle). 876 | """ 877 | if not QT_AVAILABLE: 878 | return None 879 | 880 | # 881 | # TODO/Hex-Rays: 882 | # 883 | # so, i'm pretty picky about my UI / interactions. IDA puts items in 884 | # the right click context menus of custom (code) viewers. 885 | # 886 | # these items aren't really relevant (imo) to the plugin's use case 887 | # so I do some dirty stuff here to filter them out and ensure only 888 | # my items will appear in the context menu. 889 | # 890 | # there's only one right click context item right now, but in the 891 | # future i'm sure there will be more. 892 | # 893 | 894 | class FilterMenu(QtCore.QObject): 895 | def __init__(self, qmenu): 896 | super(QtCore.QObject, self).__init__() 897 | self.qmenu = qmenu 898 | 899 | def eventFilter(self, obj, event): 900 | if event.type() != QtCore.QEvent.Polish: 901 | return False 902 | for action in self.qmenu.actions(): 903 | if action.text() in ["&Font...", "&Synchronize with"]: # lol.. 904 | qmenu.removeAction(action) 905 | self.qmenu.removeEventFilter(self) 906 | self.qmenu = None 907 | return True 908 | 909 | p_qmenu = ctypes.cast(int(popup), ctypes.POINTER(ctypes.c_void_p))[0] 910 | qmenu = sip.wrapinstance(int(p_qmenu), QtWidgets.QMenu) 911 | filter = FilterMenu(qmenu) 912 | qmenu.installEventFilter(filter) 913 | 914 | # return the filter as I think we need to maintain its lifetime in py 915 | return filter 916 | -------------------------------------------------------------------------------- /plugins/patching/core.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import hashlib 3 | import collections 4 | 5 | import ida_ua 6 | import ida_auto 7 | import ida_nalt 8 | import ida_bytes 9 | import ida_lines 10 | import ida_idaapi 11 | import ida_loader 12 | import ida_kernwin 13 | import ida_segment 14 | import idautils 15 | 16 | from patching.asm import * 17 | from patching.actions import * 18 | from patching.exceptions import * 19 | 20 | from patching.util.ida import * 21 | from patching.util.misc import plugin_resource 22 | from patching.util.python import register_callback, notify_callback 23 | 24 | #------------------------------------------------------------------------------ 25 | # Plugin Core 26 | #------------------------------------------------------------------------------ 27 | # 28 | # The plugin core constitutes the traditional 'main' plugin class. It 29 | # will host all of the plugin's objects and integrations, taking 30 | # responsibility for their initialization/teardown/lifetime. 31 | # 32 | # This pattern of splitting out the plugin core from the IDA plugin_t stub 33 | # is primarily to help separate the plugin functionality from IDA's and 34 | # make it easier to 'reload' for development / testing purposes. 35 | # 36 | 37 | class PatchingCore(object): 38 | 39 | PLUGIN_NAME = 'Patching' 40 | PLUGIN_VERSION = '0.2.0' 41 | PLUGIN_AUTHORS = 'Markus Gaasedelen' 42 | PLUGIN_DATE = '2024' 43 | 44 | def __init__(self, defer_load=False): 45 | 46 | # IDA UI Hooks 47 | self._ui_hooks = UIHooks() 48 | self._ui_hooks.ready_to_run = self.load 49 | self._ui_hooks.hook() 50 | 51 | # IDA 'Processor' Hooks 52 | self._idp_hooks = IDPHooks() 53 | self._idp_hooks.ev_ending_undo = self._ida_undo_occurred 54 | 55 | # IDA 'Database' Hooks 56 | self._idb_hooks = IDBHooks() 57 | if ida_kernwin.cvar.batch: 58 | self._idb_hooks.auto_empty_finally = self.load 59 | self._idb_hooks.byte_patched = self._ida_byte_patched 60 | self._idb_hooks.hook() 61 | 62 | # the backing engine to assemble instructions for the plugin 63 | self.assembler = None 64 | 65 | # a set of all addresses patched by the user 66 | self.patched_addresses = set() 67 | 68 | # the executable filepath that patches were applied to 69 | self.patched_filepath = None 70 | 71 | # the executable filepath used to apply patches from (the clean file) 72 | self.backup_filepath = None 73 | 74 | # apply saved patches from a known-good (clean) executable by default 75 | self.prefer_patch_cleanly = True 76 | 77 | # enable quick save after a successful patch application occurs 78 | self.prefer_quick_apply = True 79 | self.__saved_successfully = False 80 | 81 | # plugin events / callbacks 82 | self._patches_changed_callbacks = [] 83 | self._refresh_timer = None 84 | 85 | # 86 | # defer fully loading the plugin core until the IDB and UI itself 87 | # is settled. in this case, self.load() will be called later on 88 | # by IDA's UI ready_to_run event (or auto_empty_finally in batch) 89 | # 90 | 91 | if defer_load: 92 | return 93 | 94 | # 95 | # if loading is not being deferred, we have to load the plugin core 96 | # now. this is only used for development purposes such as 'hot 97 | # reloading' the plugin via the IDA console (DEV) 98 | # 99 | 100 | self.load() 101 | 102 | #-------------------------------------------------------------------------- 103 | # Initialization / Teardown 104 | #-------------------------------------------------------------------------- 105 | 106 | def load(self): 107 | """ 108 | Load the plugin core. 109 | """ 110 | 111 | # attempt to initialize an assembler engine matching the database 112 | self._init_assembler() 113 | 114 | # deactivate the plugin if this is an unsupported architecture 115 | if not self.assembler: 116 | self._ui_hooks.unhook() 117 | return 118 | 119 | # enable additional hooks since the plugin is going live 120 | self._ui_hooks.populating_widget_popup = self._populating_widget_popup 121 | self._ui_hooks.get_lines_rendering_info = self._highlight_lines 122 | 123 | # finish loading the plugin and integrating its UI elements / actions 124 | self._init_actions() 125 | self._idp_hooks.hook() 126 | self._refresh_patches() 127 | 128 | print("[%s] Loaded v%s - (c) %s - %s" % (self.PLUGIN_NAME, self.PLUGIN_VERSION, self.PLUGIN_AUTHORS, self.PLUGIN_DATE)) 129 | 130 | # parse / handle command line options for this plugin (DEV) 131 | self._run_cli_options() 132 | 133 | def unload(self): 134 | """ 135 | Unload the plugin core. 136 | """ 137 | self._idb_hooks.unhook() 138 | 139 | if not self.assembler: 140 | return 141 | 142 | print("[%s] Unloading v%s..." % (self.PLUGIN_NAME, self.PLUGIN_VERSION)) 143 | 144 | if self._refresh_timer: 145 | ida_kernwin.unregister_timer(self._refresh_timer) 146 | self._refresh_timer = None 147 | 148 | self._idb_hooks.unhook() 149 | self._idp_hooks.unhook() 150 | self._ui_hooks.unhook() 151 | self._unregister_actions() 152 | self._unload_assembler() 153 | 154 | def _init_assembler(self): 155 | """ 156 | Initialize the assembly engine to be used for patching. 157 | """ 158 | arch_name = ida_ida.inf_get_procname() 159 | 160 | if arch_name == 'metapc': 161 | assembler = AsmX86() 162 | elif arch_name.startswith('arm') or arch_name.startswith('ARM'): 163 | assembler = AsmARM() 164 | 165 | # 166 | # TODO: disabled until v0.2.0 167 | # 168 | #elif arch_name.startswith("ppc"): 169 | # assembler = AsmPPC(inf) 170 | #elif arch_name.startswith("mips"): 171 | # assembler = AsmMIPS(inf) 172 | #elif arch_name.startswith("sparc"): 173 | # assembler = AsmSPARC(inf) 174 | #elif arch_name.startswith("systemz") or arch_name.startswith("s390x"): 175 | # assembler = AsmSystemZ(inf) 176 | # 177 | 178 | else: 179 | assembler = None 180 | print(" - Unsupported CPU: '%s' (%s)" % (arch_name, ida_nalt.get_input_file_path())) 181 | 182 | self.assembler = assembler 183 | 184 | def _unload_assembler(self): 185 | """ 186 | Unload the assembly engine. 187 | """ 188 | 189 | # 190 | # NOTE: this is kind of aggressive attempt at deleting the assembler 191 | # and Keystone components in an effort to keep things safe if the user 192 | # is trying to do an easy install (updating) over the existing plugin 193 | # 194 | # read the install.py script (easy install) for a bit more context of 195 | # why we're trying to minimize exposure to Keystone on unload 196 | # 197 | 198 | del self.assembler._ks 199 | del self.assembler 200 | self.assembler = None 201 | 202 | def _init_actions(self): 203 | """ 204 | Initialize all IDA plugin actions. 205 | """ 206 | 207 | # initialize new actions provided by this plugin 208 | for action in PLUGIN_ACTIONS: 209 | 210 | # load and register an icon for our action if one is defined 211 | if action.ICON: 212 | icon_path = plugin_resource(action.ICON) 213 | icon_id = ida_kernwin.load_custom_icon(icon_path) 214 | else: 215 | icon_id = -1 216 | 217 | # instantiate an action description to register with IDA 218 | desc = ida_kernwin.action_desc_t( 219 | action.NAME, 220 | action.TEXT, 221 | action(self), 222 | action.HOTKEY, 223 | action.TOOLTIP, 224 | icon_id 225 | ) 226 | 227 | if not ida_kernwin.register_action(desc): 228 | print("Failed to register action '%s'" % action.NAME) 229 | 230 | # inject plugin's NOP action into IDA's edit submenu 231 | ida_kernwin.attach_action_to_menu("Edit/Patch program/Change byte...", "patching:nop", ida_kernwin.SETMENU_INS) 232 | 233 | # supersede IDA's default "Assemble" action with our own 234 | ida_kernwin.update_action_state("Assemble", ida_kernwin.AST_DISABLE_ALWAYS) 235 | ida_kernwin.update_action_visibility("Assemble", False) 236 | ida_kernwin.attach_action_to_menu("Edit/Patch program/Change word...", "patching:assemble", ida_kernwin.SETMENU_APP) 237 | 238 | # supersede IDA's default "Apply patches" action with our own 239 | ida_kernwin.update_action_state("ApplyPatches", ida_kernwin.AST_DISABLE_ALWAYS) 240 | ida_kernwin.update_action_visibility("ApplyPatches", False) 241 | ida_kernwin.attach_action_to_menu("Edit/Patch program/Patched bytes...", "patching:apply", ida_kernwin.SETMENU_APP) 242 | 243 | def _unregister_actions(self): 244 | """ 245 | Remove all plugin actions registered with IDA. 246 | """ 247 | for action in PLUGIN_ACTIONS: 248 | 249 | # fetch icon ID before we unregister the current action 250 | valid_id, icon_id = ida_kernwin.get_action_icon(action.NAME) 251 | 252 | # unregister the action from IDA 253 | if not ida_kernwin.unregister_action(action.NAME): 254 | print("Failed to unregister action '%s'" % action.NAME) 255 | 256 | # delete the icon now that the action should no longer be using it 257 | if valid_id: 258 | ida_kernwin.free_custom_icon(icon_id) 259 | 260 | # restore IDA actions that we had overridden 261 | ida_kernwin.update_action_state("Assemble", ida_kernwin.AST_ENABLE) 262 | ida_kernwin.update_action_visibility("Assemble", True) 263 | ida_kernwin.update_action_state("ApplyPatches", ida_kernwin.AST_ENABLE) 264 | ida_kernwin.update_action_visibility("ApplyPatches", True) 265 | 266 | def _run_cli_options(self): 267 | """ 268 | Run plugin actions based on command line flags (DEV). 269 | """ 270 | options = ida_loader.get_plugin_options('Patching') 271 | if not options: 272 | return 273 | 274 | # run the 'assemble_all' test with CLI flag -OPatching:assemble 275 | for option in options.split(':'): 276 | if option == 'assemble': 277 | self.assemble_all() 278 | 279 | #-------------------------------------------------------------------------- 280 | # Plugin API 281 | #-------------------------------------------------------------------------- 282 | 283 | def is_byte_patched(self, ea): 284 | """ 285 | Return True if the byte at the given address has been patched. 286 | """ 287 | return self.is_range_patched(ea, ea+1) 288 | 289 | def is_item_patched(self, ea): 290 | """ 291 | Return True if a patch exists within the item at the given address. 292 | """ 293 | item_size = ida_bytes.get_item_size(ea) 294 | return self.is_range_patched(ea, ea+item_size) 295 | 296 | def is_range_patched(self, start_ea, end_ea): 297 | """ 298 | Return True if a patch exists within the given address range. 299 | """ 300 | if start_ea == (end_ea + 1): 301 | return start_ea in self.patched_addresses 302 | return bool(self.patched_addresses & set(range(start_ea, end_ea))) 303 | 304 | def get_patch_at(self, ea): 305 | """ 306 | Return information about a patch at the given address. 307 | 308 | On success, returns (True, start_ea, patch_size) for the patch. 309 | """ 310 | if not self.is_item_patched(ea): 311 | return (False, ida_idaapi.BADADDR, 0) 312 | 313 | # 314 | # NOTE: this code seems 'overly complicated' because it tries to group 315 | # visually contiguous items that appear as 'one' patched region in 316 | # IDA, even if not all of the bytes within each item were changed. 317 | # 318 | # TODO/Hex-Rays: this kind of logic/API is probably something that 319 | # should be moved in-box as part of a 'patch metadata' overhaul 320 | # 321 | 322 | if ida_bytes.is_unknown(ida_bytes.get_flags(ea)): 323 | forward_ea = ea 324 | reverse_ea = ea - 1 325 | else: 326 | forward_ea = ida_bytes.get_item_head(ea) 327 | reverse_ea = ida_bytes.prev_head(forward_ea, 0) 328 | 329 | # scan forwards for the 'end' of the patched region 330 | while forward_ea != ida_idaapi.BADADDR: 331 | item_size = ida_bytes.get_item_size(forward_ea) 332 | item_addresses = set(range(forward_ea, forward_ea + item_size)) 333 | forward_ea = forward_ea + item_size 334 | if not (item_addresses & self.patched_addresses): 335 | forward_ea -= item_size 336 | break 337 | 338 | # scan backwards for the 'start' of the patched region 339 | while reverse_ea != ida_idaapi.BADADDR: 340 | item_size = ida_bytes.get_item_size(reverse_ea) 341 | item_addresses = set(range(reverse_ea, reverse_ea + item_size)) 342 | if not (item_addresses & self.patched_addresses): 343 | reverse_ea += item_size # revert to last 'hit' item 344 | break 345 | reverse_ea -= item_size 346 | 347 | # info about the discovered patch 348 | start_ea = reverse_ea 349 | end_ea = forward_ea 350 | length = forward_ea - reverse_ea 351 | #print("Found patch! 0x%08X --> 0x%08X (%u bytes)" % (start_ea, end_ea, length)) 352 | 353 | return (True, start_ea, length) 354 | 355 | def assemble(self, assembly, ea): 356 | """ 357 | Assemble and return bytes for the given assembly text. 358 | """ 359 | return self.assembler.asm(assembly, ea) 360 | 361 | def nop_item(self, ea): 362 | """ 363 | NOP the item at the given address. 364 | """ 365 | nop_size = ida_bytes.get_item_size(ea) 366 | return self.nop_range(ea, ea+nop_size) 367 | 368 | def nop_range(self, start_ea, end_ea): 369 | """ 370 | NOP all of the bytes within the given address range. 371 | """ 372 | if start_ea == end_ea: 373 | return False 374 | 375 | # generate a buffer of NOP data hinted at by the existing database / instructions 376 | nop_buffer = self.assembler.nop_buffer(start_ea, end_ea) 377 | 378 | # patch the specified region with NOP bytes 379 | self.patch(start_ea, nop_buffer, fill_nop=False) 380 | return True 381 | 382 | def revert_patch(self, ea): 383 | """ 384 | Revert all the modified bytes within a patch at the given address. 385 | """ 386 | found, start_ea, length = self.get_patch_at(ea) 387 | if not found: 388 | return False 389 | self.revert_range(start_ea, start_ea+length) 390 | return True 391 | 392 | def revert_range(self, start_ea, end_ea): 393 | """ 394 | Revert all the modified bytes within the given address range. 395 | """ 396 | 397 | # revert bytes to their original value within the target region 398 | for ea in range(start_ea, end_ea): 399 | ida_bytes.revert_byte(ea) 400 | 401 | # 'undefine' the reverted bytes (helps with re-analysis) 402 | length = end_ea - start_ea 403 | ida_bytes.del_items(start_ea, ida_bytes.DELIT_KEEPFUNC, length) 404 | 405 | # 406 | # if the reverted patch seems to be in a code-ish area, we tell the 407 | # auto-analyzer to try and analyze it as code 408 | # 409 | 410 | if ida_bytes.is_code(ida_bytes.get_flags(ida_bytes.prev_head(start_ea, 0))): 411 | ida_auto.auto_mark_range(start_ea, end_ea, ida_auto.AU_CODE) 412 | 413 | # attempt to re-analyze the reverted region 414 | ida_auto.plan_and_wait(start_ea, end_ea, True) 415 | 416 | # 417 | # having just reverted the bytes to their original values on the IDA 418 | # side of things, we now have to ensure these addresses are no longer 419 | # tracked by our plugin as 'patched' 420 | # 421 | 422 | self.patched_addresses -= set(range(start_ea, end_ea)) 423 | ida_kernwin.execute_sync(self._notify_patches_changed, ida_kernwin.MFF_NOWAIT|ida_kernwin.MFF_WRITE) 424 | return True 425 | 426 | def force_jump(self, ea): 427 | """ 428 | Force a conditional jump to be unconditional at the given address. 429 | """ 430 | mnemonic = ida_ua.print_insn_mnem(ea) 431 | 432 | # if the given address is not a conditional jump, ignore the request 433 | if not self.assembler.is_conditional_jump(mnemonic): 434 | return False 435 | 436 | # fetch the target address 437 | target = next(idautils.CodeRefsFrom(ea, False)) 438 | 439 | # assemble an unconditional jump with the same jump target 440 | patch_code = "%s 0x%X" % (self.assembler.UNCONDITIONAL_JUMP, target) 441 | patch_data = self.assembler.asm(patch_code, ea) 442 | 443 | # write the unconditional jump patch to the database 444 | self.patch(ea, patch_data) 445 | return True 446 | 447 | def patch(self, ea, patch_data, fill_nop=True): 448 | """ 449 | Write patch data / bytes to a given address. 450 | """ 451 | patch_size = len(patch_data) 452 | 453 | # incoming patch matches existing data, nothing to do 454 | original_data = ida_bytes.get_bytes(ea, patch_size) 455 | if original_data == patch_data: 456 | return 457 | 458 | next_address = ea + patch_size 459 | inst_start = ida_bytes.get_item_head(next_address) 460 | if ida_bytes.is_code(ida_bytes.get_flags(inst_start)): 461 | 462 | # if the patch clobbers part of an instruction, fill it with NOP 463 | if inst_start < next_address: 464 | inst_size = ida_bytes.get_item_size(inst_start) 465 | fill_size = (inst_start + inst_size) - next_address 466 | self.nop_range(next_address, next_address+fill_size) 467 | ida_auto.auto_make_code(next_address) 468 | 469 | # 470 | # write the actual patch data to the database. we also unhook the IDB 471 | # events to prevent the plugin from seeing the numerous 'patch' events 472 | # that IDA will generate as we write the patch data to the database 473 | # 474 | 475 | self._idb_hooks.unhook() 476 | ida_bytes.patch_bytes(ea, patch_data) 477 | self._idb_hooks.hook() 478 | 479 | # 480 | # record the region of patched addresses 481 | # 482 | 483 | addresses = set(range(ea, ea+patch_size)) 484 | if is_range_patched(ea, ea+patch_size): 485 | self.patched_addresses |= addresses 486 | 487 | # 488 | # according to IDA, none of the 'patched' addresses in the database 489 | # actually have a different value... so they technically were not 490 | # patched (eg. maybe they were patched back to their ORIGINAL value!) 491 | # 492 | # in this case it means the patching plugin shouldn't see these 493 | # addresses as patched, either... 494 | # 495 | 496 | else: 497 | self.patched_addresses -= addresses 498 | 499 | # request re-analysis of the patched range 500 | ida_auto.auto_mark_range(ea, ea+patch_size, ida_auto.AU_USED) 501 | ida_kernwin.execute_sync(self._notify_patches_changed, ida_kernwin.MFF_NOWAIT|ida_kernwin.MFF_WRITE) 502 | 503 | def apply_patches(self, target_filepath, clean=False): 504 | """ 505 | Apply the current patches to the given filepath. 506 | """ 507 | self.__saved_successfully = False 508 | 509 | # 510 | # ensure that a 'clean' source executable exists for this operation, 511 | # and then write (or overwrite) the target filepath with the clean 512 | # file so that we can apply patches to it from a known-good state 513 | # 514 | 515 | if clean: 516 | self.backup_filepath = self._ensure_clean_backup(target_filepath) 517 | 518 | # 519 | # due to the variety of errors that may occur from trying to copy 520 | # a file, we simply trap them all to a more descriptive issue for 521 | # what action failed in the context of our patching attempt 522 | # 523 | 524 | try: 525 | shutil.copyfile(self.backup_filepath, target_filepath) 526 | except Exception: 527 | raise PatchTargetError("Failed to overwrite patch target with a clean executable", target_filepath) 528 | 529 | # 530 | # attempt to apply the patches to the target filepath 531 | # 532 | # NOTE: this 'Exception' catch-all is probably a bit too liberal, 533 | # instead we should probably have apply_patches(...) raise a generic 534 | # error if opening the target file for writing fails, leaving any 535 | # other (unexpected!) patching exceptions uncaught 536 | # 537 | 538 | try: 539 | apply_patches(target_filepath) 540 | except Exception: 541 | raise PatchApplicationError("Failed to write patches into the target file", target_filepath) 542 | 543 | # patching seems successful? update the stored filepath to the patched binary 544 | self.patched_filepath = target_filepath 545 | 546 | # 547 | # if we made it this far, we assume the file on disk was patched 548 | # setting __saved_successfully ensures that we start showing the 549 | # 'quick apply' right click context menu going forward 550 | # 551 | # this is to help cut down on crowding the right click menu only 552 | # until the user explicitly starts using the patching plugin, but 553 | # also applying their patches to a a binary 554 | # 555 | 556 | if self.prefer_quick_apply: 557 | self.__saved_successfully = True 558 | 559 | def quick_apply(self): 560 | """ 561 | Apply the current patches using the last-known settings. 562 | """ 563 | 564 | try: 565 | self.apply_patches(self.patched_filepath, self.prefer_patch_cleanly) 566 | except Exception as e: 567 | return (False, e) 568 | 569 | return (True, None) 570 | 571 | #-------------------------------------------------------------------------- 572 | # Plugin Internals 573 | #-------------------------------------------------------------------------- 574 | 575 | def _ensure_clean_backup(self, target_filepath): 576 | """ 577 | Return True if a clean executable matching the open IDB is available on disk. 578 | """ 579 | 580 | # 581 | # TODO: what do we do if one/both of these are invalid or blank? 582 | # such as a blank or tmp IDB? what do they return in this case? 583 | # 584 | 585 | input_md5 = ida_nalt.retrieve_input_file_md5() 586 | input_filepath = ida_nalt.get_input_file_path() 587 | 588 | # 589 | # we will search this list of filepaths for an executable / source 590 | # file that matches the reported hash of the file used to generate 591 | # this IDA database 592 | # 593 | 594 | filepaths = [target_filepath, self.backup_filepath, input_filepath] 595 | filepaths = list(filter(None, filepaths)) 596 | 597 | # search the list of filepaths for a clean file 598 | while filepaths: 599 | 600 | # get the next filepath to evaluate 601 | filepath = filepaths.pop(0) 602 | 603 | # 604 | # if the given filepath does not end with a '.bak', push a version 605 | # of the current filepath with that extension to make for a more 606 | # comprehensive search of a clean backup file 607 | # 608 | # we insert this at the front of the list because it should be 609 | # searched next (the list is kind of ordered by relevance already) 610 | # 611 | 612 | if not filepath.endswith('.bak'): 613 | filepaths.insert(0, filepath + '.bak') 614 | 615 | # 616 | # attempt to read (and then hash) each file that is being 617 | # considered as a possible source for our clean backup 618 | # 619 | 620 | try: 621 | disk_data = open(filepath, 'rb').read() 622 | except Exception as e: 623 | #print(" - Failed to read '%s' -- Reason: %s" % (filepath, str(e))) 624 | continue 625 | 626 | disk_md5 = hashlib.md5(disk_data).digest() 627 | 628 | # 629 | # MD5 of the tested file does not match the ORIGINAL (clean) file 630 | # so we simply ignore it cuz it is useless for our purposes 631 | # 632 | 633 | if disk_md5 != input_md5: 634 | #print(" - MD5: '%s' -- does not match IDB (probably previously patched)" % filepath) 635 | continue 636 | 637 | # 638 | # the MD5 matches between the original executable hash provided by 639 | # IDA and a hashed file on disk. use this as the source filepath 640 | # for our dialog 641 | # 642 | 643 | clean_filepath = filepath 644 | #print(" - Found unpatched binary! '%s'" % filepath) 645 | break 646 | 647 | # 648 | # if we did not break from the loop above, that means we could not 649 | # find an executable with a hash that is deemed valid to cleanly 650 | # patch from, so there is nothing else we can do 651 | # 652 | 653 | else: 654 | raise PatchBackupError("Failed to locate a clean executable") 655 | 656 | # 657 | # we have verified that a clean version of the executable matching 658 | # this database exists on-disk. 659 | # 660 | # in the case below, the clean file (presumably a '.bak' file that 661 | # was previously created) is not at risk of getting overwritten as 662 | # target_filepath is where the resulting / patched binary is going 663 | # to be written by the ongoing save action 664 | # 665 | # nothing else to do but return success 666 | # 667 | 668 | if clean_filepath != target_filepath: 669 | return clean_filepath 670 | 671 | # 672 | # if the clean filepath does not match the target (output) path, we 673 | # make a copy of the file and add a '.bak' extension to it as we don't 674 | # want to overwrite potentially the only clean copy of the file 675 | # 676 | # in this case, the user is probably patching foo.exe for the first 677 | # time, so we are going to be creating foo.exe.bak here 678 | # 679 | 680 | clean_filepath += '.bak' 681 | 682 | # 683 | # before attempting to make a clean file backup, we can try checking 684 | # the hash of the existing file (if there is one) ... 685 | # 686 | # if the hash matches what we expect of the clean backup, then the 687 | # file appears to be readable and sufficient to use as a backup as-is 688 | # 689 | 690 | try: 691 | clean_md5 = hashlib.md5(open(clean_filepath, 'rb').read()).digest() 692 | if clean_md5 == input_md5: 693 | return clean_filepath 694 | 695 | # 696 | # failed to read/hash file? maybe it doesn't exist... or it's not 697 | # readable/writable (locked?) in which case the next action will 698 | # fail and throw the necessary exception for us instead 699 | # 700 | 701 | except: 702 | pass 703 | 704 | # 705 | # finally, attempt to make the backup of our patch target, as it 706 | # doesn't seem to exist yet (... or we can't seem to read the file, 707 | # in which case we're trying a last ditch attempt at overwriting it) 708 | # 709 | 710 | try: 711 | shutil.copyfile(target_filepath, clean_filepath) 712 | 713 | # 714 | # if we failed to write (overwrite?) the desired file for our clean 715 | # backup, then we cannot ensure that a clean backup exists 716 | # 717 | 718 | except Exception as e: 719 | raise PatchBackupError("Failed to write backup executable", clean_filepath) 720 | 721 | # all done 722 | return clean_filepath 723 | 724 | def _refresh_patches(self): 725 | """ 726 | Refresh the list of patched addresses directly from the database. 727 | """ 728 | addresses = set() 729 | 730 | def visitor(ea, file_offset, original_value, patched_value): 731 | addresses.add(ea) 732 | return 0 733 | 734 | ida_bytes.visit_patched_bytes(0, ida_idaapi.BADADDR, visitor) 735 | self.patched_addresses = addresses 736 | ida_kernwin.execute_sync(self._notify_patches_changed, ida_kernwin.MFF_NOWAIT|ida_kernwin.MFF_WRITE) 737 | 738 | def __deferred_refresh_callback(self): 739 | """ 740 | A deferred callback to refresh the list of patched addresses. 741 | """ 742 | self._refresh_timer = None 743 | self._refresh_patches() 744 | return -1 # unregisters the timer 745 | 746 | #-------------------------------------------------------------------------- 747 | # Plugin Events 748 | #-------------------------------------------------------------------------- 749 | 750 | def patches_changed(self, callback): 751 | """ 752 | Subscribe a callback for patch change events. 753 | """ 754 | register_callback(self._patches_changed_callbacks, callback) 755 | 756 | def _notify_patches_changed(self): 757 | """ 758 | Notify listeners that the patches changed. 759 | """ 760 | 761 | # 762 | # this function is supposed to notify the plugin components (such as 763 | # UI) that they should refresh because their data may be stale. 764 | # 765 | # currently, the plugin calls this function via async (MFF_FAST) 766 | # callbacks queued with execute_sync(). 767 | # 768 | # the reason we do this is because we need to give IDA some time to 769 | # process pending actions/events/analysis/ui (etc.) after patching 770 | # or reverting bytes. 771 | # 772 | # if we don't execute 'later' (MFF_FAST), some things like generating 773 | # disassembly text for a patched instruction may be ... wrong or 774 | # incomplete (eg ) 775 | # 776 | 777 | notify_callback(self._patches_changed_callbacks) 778 | 779 | # ensure the IDA views are refreshed so highlights are updated 780 | ida_kernwin.refresh_idaview_anyway() 781 | 782 | # for execute_sync(...) 783 | return 1 784 | 785 | #-------------------------------------------------------------------------- 786 | # IDA Events 787 | #-------------------------------------------------------------------------- 788 | 789 | def _populating_widget_popup(self, widget, popup, ctx): 790 | """ 791 | IDA is populating the context menu for a widget. 792 | """ 793 | is_idaview = False 794 | 795 | # IDA disassembly view 796 | if ida_kernwin.get_widget_type(widget) == ida_kernwin.BWN_DISASM: 797 | is_idaview = True 798 | 799 | # custom / interactive patching view 800 | elif ida_kernwin.get_widget_title(widget) == 'PatchingCodeViewer': 801 | pass 802 | 803 | # other IDA views that we don't care to inject actions into 804 | else: 805 | return 806 | 807 | # fetch the 'right clicked' instruction address 808 | clicked_ea = get_current_ea(ctx) 809 | 810 | # 811 | # check if the user has 'selected' any amount of text in the widget. 812 | # 813 | # it is important we use this method/API so that we can best position 814 | # our patching actions within the right click context menu (by 815 | # predicting what else will be visible in the menu). 816 | # 817 | 818 | p0, p1 = ida_kernwin.twinpos_t(), ida_kernwin.twinpos_t() 819 | range_selected = ida_kernwin.read_selection(widget, p0, p1) 820 | 821 | valid_ea, start_ea, end_ea = read_range_selection(ctx) 822 | if not valid_ea: 823 | start_ea = clicked_ea 824 | 825 | # determine if the user selection or right click covers a patch 826 | if (range_selected and valid_ea): 827 | #print("User range: 0x%08X --> 0x%08X" % (start_ea, end_ea)) 828 | show_revert = self.is_range_patched(start_ea, end_ea) 829 | else: 830 | #print("User click: 0x%08X" % clicked_ea) 831 | show_revert = self.is_item_patched(clicked_ea) 832 | 833 | # determine if the user right clicked code 834 | is_code = ida_bytes.is_code(ida_bytes.get_flags(clicked_ea)) 835 | 836 | # 837 | # attempt to 'pin' the patching actions towards the top of the right 838 | # click context menu. we do this by 'appending' our 'NOP' action after 839 | # a built-in action that we expect to be near the top of the menu. 840 | # 841 | # NOTE: IDA shows 'different' commands based on the context and state 842 | # during the right click. that is why we try to aggressively identify 843 | # what will be in the right click menu so that we can consistently 844 | # pin our actions in the desired location 845 | # 846 | 847 | if range_selected: 848 | 849 | if ida_segment.segtype(start_ea) == ida_segment.SEG_CODE: 850 | ida_kernwin.attach_action_to_popup(widget, popup, NopAction.NAME, "Analyze selected area", ida_kernwin.SETMENU_APP) 851 | else: 852 | ida_kernwin.attach_action_to_popup(widget, popup, NopAction.NAME, "Abort selection", ida_kernwin.SETMENU_APP) 853 | 854 | # 855 | # TODO: lol there's probably a better way to do this, but I'm 856 | # writing this fix a little bit late. we basically are trying to 857 | # check if the user has a visual selection spanning multiple lines 858 | # 859 | # if multiple lines are selected, we don't want to show the 860 | # 'Assemble' command. as it is unlikely that the user right 861 | # right clicking a selected range to explicitly assemble 862 | # 863 | # that said, if the user only selected a few chars on the SAME 864 | # line it may have been an unintentional 'range selection' in 865 | # in which case we DO want to show 'Assemble' 866 | # 867 | 868 | p0s = p0.place_as_simpleline_place_t() 869 | p1s = p1.place_as_simpleline_place_t() 870 | multi_line_selection = p0s.n != p1s.n 871 | 872 | else: 873 | ida_kernwin.attach_action_to_popup(widget, popup, NopAction.NAME, "Rename", ida_kernwin.SETMENU_APP) 874 | multi_line_selection = False 875 | 876 | # 877 | # PREV_ACTION will hold the 'most recent' action we appended to the 878 | # menu. this is done to simplify the remaining code while appending 879 | # our subsequent patching actions. 880 | # 881 | 882 | PREV_ACTION = NopAction.TEXT 883 | 884 | # if the user right clicked a single instruction... 885 | if is_code and not (range_selected and multi_line_selection): 886 | 887 | # inject the 'assemble' action (but not in the patching dialog) 888 | if is_idaview: 889 | ida_kernwin.attach_action_to_popup(widget, popup, AssembleAction.NAME, PREV_ACTION, ida_kernwin.SETMENU_APP) 890 | PREV_ACTION = AssembleAction.TEXT 891 | 892 | # inject the 'force jump' action if a conditional jump was right clicked 893 | mnemonic = ida_ua.print_insn_mnem(clicked_ea) 894 | if self.assembler.is_conditional_jump(mnemonic): 895 | ida_kernwin.attach_action_to_popup(widget, popup, ForceJumpAction.NAME, PREV_ACTION, ida_kernwin.SETMENU_APP) 896 | PREV_ACTION = ForceJumpAction.TEXT 897 | 898 | # if the user selected some patched bytes, show the 'revert' action 899 | if show_revert: 900 | ida_kernwin.attach_action_to_popup(widget, popup, RevertAction.NAME, PREV_ACTION, ida_kernwin.SETMENU_APP) 901 | PREV_ACTION = RevertAction.TEXT 902 | 903 | # 904 | # if the user has 'saved' patches at any point this session, we should 905 | # show them the quick save option as they are likely going to save 906 | # patches again at some point... 907 | # 908 | 909 | if self.__saved_successfully: 910 | ida_kernwin.attach_action_to_popup(widget, popup, QuickApplyAction.NAME, PREV_ACTION, ida_kernwin.SETMENU_APP) 911 | PREV_ACTION = QuickApplyAction.TEXT 912 | 913 | # 914 | # TODO/Hex-Rays: is there no way to define/append a submenu with my 915 | # action group??? I want to put 'Patching --> ...' after my last action 916 | # and not at the *very end* of the right click menu... 917 | # 918 | # e.g. +---------------------+ 919 | # | Rename... | 920 | # |---------------------+ 921 | # | NOP | 922 | # | Assemble... | 923 | # | Patching --------------->-+-----------------+ 924 | # +---------------------+ | Change bytes... | 925 | # | Jump to operand | | ... | 926 | # | Jump in a new ... | ' ' 927 | # | ... | 928 | # 929 | # for now, we use the following 'HACK' API to create a submenu at the 930 | # preferred location in the right click context menu 931 | # 932 | 933 | self._patching_submenu = attach_submenu_to_popup(popup, "Patching", PREV_ACTION) 934 | 935 | # extended list of 'less common' actions saved under a patching submenu 936 | ida_kernwin.attach_action_to_popup(widget, popup, "PatchByte", "Patching/") 937 | ida_kernwin.attach_action_to_popup(widget, popup, "PatchedBytes", "Patching/") 938 | ida_kernwin.attach_action_to_popup(widget, popup, ApplyAction.NAME, "Patching/") 939 | 940 | # insert start spacer before / after our action group 941 | ida_kernwin.attach_action_to_popup(widget, popup, "-", NopAction.TEXT, ida_kernwin.SETMENU_INS) 942 | ida_kernwin.attach_action_to_popup(widget, popup, "-", "Patching/", ida_kernwin.SETMENU_APP) 943 | 944 | def _highlight_lines(self, out, widget, rin): 945 | """ 946 | IDA is drawing disassembly lines and requesting highlighting info. 947 | """ 948 | 949 | # if there are no patches, there is nothing to highlight 950 | if not self.patched_addresses: 951 | return 952 | 953 | # ignore line highlight events that are not for a disassembly view 954 | if ida_kernwin.get_widget_type(widget) != ida_kernwin.BWN_DISASM: 955 | return 956 | 957 | # cache item heads that have been checked for patches 958 | ignore_item_ea = set() 959 | highlight_item_ea = set() 960 | 961 | # highlight lines/addresses that have been patched by the user 962 | for section_lines in rin.sections_lines: 963 | for line in section_lines: 964 | line_ea = line.at.toea() 965 | 966 | # 967 | # fast path to ignore entire items that have not been patched 968 | # but may span multiple lines in the disassembly view 969 | # 970 | 971 | item_head = ida_bytes.get_item_head(line_ea) 972 | if item_head in ignore_item_ea: 973 | continue 974 | 975 | # 976 | # this is a fast-path to avoid having to re-check an entire 977 | # item if the current line address has already been checked 978 | # and determined to contain an applied patch. 979 | # 980 | 981 | if line_ea in highlight_item_ea: 982 | 983 | # highlight the line if it is patched in some way 984 | e = ida_kernwin.line_rendering_output_entry_t(line) 985 | e.bg_color = ida_kernwin.CK_EXTRA2 986 | e.flags = ida_kernwin.LROEF_FULL_LINE 987 | 988 | # save the highlight to the output line highlight list 989 | out.entries.push_back(e) 990 | continue 991 | 992 | # 993 | # for lines of IDA disas that normally have a small number of 994 | # backing bytes (such as an instruction or simple data item) 995 | # we explode it out to its individual addresses and use sets 996 | # to check if any bytes within it have been patched 997 | # 998 | # this scales well to an infinite number of patched bytes 999 | # 1000 | 1001 | item_len = ida_bytes.get_item_size(line_ea) 1002 | end_ea = line_ea + item_len 1003 | 1004 | if item_len <= 256: 1005 | line_addresses = set(range(line_ea, end_ea)) 1006 | if not(line_addresses & self.patched_addresses): 1007 | ignore_item_ea.add(line_ea) 1008 | continue 1009 | 1010 | # 1011 | # for lines with items that are reportedly quite 'large' (maybe 1012 | # a struct, array, alignment directive, etc.) where a line may 1013 | # contribute to an item that's tens of thousands of bytes... 1014 | # 1015 | # we will instead loop through all of the patched addresses 1016 | # to see if any of them fall within the range of the line. 1017 | # 1018 | # it seems unlikely that the user will ever have very many 1019 | # patched bytes (maybe hundreds?) versus generating a large 1020 | # set and checking potentially tens of thousands of addresses 1021 | # that make up an item, like the above condition would 1022 | # 1023 | # NOTE: this was a added during a slight re-factor of this 1024 | # function / logic to help minimize the chance of notable lag 1025 | # when scrolling past large data structures in the disas view 1026 | # 1027 | 1028 | elif not any(line_ea <= ea < end_ea for ea in self.patched_addresses): 1029 | ignore_item_ea.add(line_ea) 1030 | continue 1031 | 1032 | # highlight the line if it is patched in some way 1033 | e = ida_kernwin.line_rendering_output_entry_t(line) 1034 | e.bg_color = ida_kernwin.CK_EXTRA2 1035 | e.flags = ida_kernwin.LROEF_FULL_LINE 1036 | 1037 | # save the highlight to the output line highlight list 1038 | out.entries.push_back(e) 1039 | highlight_item_ea.add(line_ea) 1040 | 1041 | def _ida_undo_occurred(self, action_name, is_undo): 1042 | """ 1043 | IDA completed an Undo / Redo action. 1044 | """ 1045 | 1046 | # 1047 | # if the user happens to use IDA's native UNDO or REDO functionality 1048 | # we will completely discard our tracked set of patched addresses and 1049 | # query IDA for the true, current set of patches 1050 | # 1051 | 1052 | self._refresh_patches() 1053 | return 0 1054 | 1055 | def _ida_byte_patched(self, ea, old_value): 1056 | """ 1057 | IDA is reporting a byte has been patched. 1058 | """ 1059 | 1060 | # 1061 | # if a timer already exists, unregister it so that we can register a 1062 | # new one. this is to effectively resest the timer as patched bytes 1063 | # are coming in 'rapidly' (eg. externally scripted patches, etc) 1064 | # 1065 | 1066 | if self._refresh_timer: 1067 | ida_kernwin.unregister_timer(self._refresh_timer) 1068 | 1069 | # 1070 | # register a timer to wait 200ms before doing a full reset of the 1071 | # patched addresses. this is to help 'batch' the changes 1072 | # 1073 | 1074 | self._refresh_timer = ida_kernwin.register_timer(200, self.__deferred_refresh_callback) 1075 | 1076 | #-------------------------------------------------------------------------- 1077 | # Temp / DEV / Tests 1078 | #-------------------------------------------------------------------------- 1079 | 1080 | # 1081 | # HACKER'S SECRET 1082 | # 1083 | # this section is purely for testing / development / profiling. it may be 1084 | # messy, out of place, transient, incomplete, broken, unsupported etc. 1085 | # 1086 | # if you want to hack on this plugin or are trying to edit / dev on the 1087 | # codebase, you can quickly 'reload' the plugin without actually having 1088 | # to restart IDA to test your changes in *most* cases. 1089 | # 1090 | # in the IDA console, you can use: 1091 | # 1092 | # patching.reload() 1093 | # 1094 | # additionally, you can call into parts of the loaded plugin instance 1095 | # from the IDA console for testing certain parts: 1096 | # 1097 | # patching.core.nop_item(here()) 1098 | # 1099 | # finally, to 'test' assembling all of the instructions in your IDB (to 1100 | # try and identify assembly issues or unsupported instructions) you can 1101 | # run the following command: 1102 | # 1103 | # patching.core.assemble_all() 1104 | # 1105 | # this may be slow and take several minutes (sometimes much longer) to 1106 | # run depending on the size of the IDB 1107 | # 1108 | 1109 | def profile(self): 1110 | """ 1111 | Profile assemble_all(...) to 1112 | 1113 | NOTE: you should probably only call this in 'small' databases. 1114 | """ 1115 | import pprofile 1116 | prof = pprofile.Profile() 1117 | with prof(): 1118 | self.assemble_all() 1119 | prof.print_stats() 1120 | 1121 | def parse_all(self): 1122 | for ea in all_instruction_addresses(0): 1123 | ida_auto.show_addr(ea) 1124 | comps = get_disassembly_components(ea) 1125 | if comps[0]: 1126 | print("%08X: %s" % (ea, str(comps))) 1127 | 1128 | def assemble_all(self): 1129 | """ 1130 | Attempt to re-assemble every instruction in the IDB, byte-for-byte. 1131 | 1132 | TODO: build out some actual dedicated tests 1133 | """ 1134 | import time, datetime 1135 | start_time = time.time() 1136 | start = 0 1137 | 1138 | headless = ida_kernwin.cvar.batch 1139 | 1140 | # the number of correctly re-assembled instructions 1141 | good = 0 1142 | total = 0 1143 | fallback = 0 1144 | unsupported = 0 1145 | unsupported_map = collections.defaultdict(int) 1146 | 1147 | slow_limit = -1 1148 | asm_threshold = 0.1 1149 | 1150 | # track failures 1151 | fail_addrs = collections.defaultdict(list) 1152 | fail_bytes = collections.defaultdict(set) 1153 | alternates = set() 1154 | 1155 | # unhook so the plugin doesn't try to handle a billion 'patch' events 1156 | self._idb_hooks.unhook() 1157 | 1158 | for ea in all_instruction_addresses(start): 1159 | 1160 | # update the navbar cursor based on progress (only when in UI) 1161 | if not headless: 1162 | ida_auto.show_addr(ea) 1163 | 1164 | # 1165 | # skip some instructions to cut down on noise (lots of noise / 1166 | # false positives with NOP) 1167 | # 1168 | 1169 | mnemonic = ida_ua.print_insn_mnem(ea) 1170 | 1171 | # probably undefined data in code / can't be disas / bad instructions 1172 | if not mnemonic: 1173 | continue 1174 | 1175 | mnemonic = mnemonic.upper() 1176 | 1177 | # ignore instructions that can decode a wild number of ways 1178 | if mnemonic in ['NOP', 'XCHG']: 1179 | continue 1180 | 1181 | # keep track of how many instructions we care to 'assemble' 1182 | total += 1 1183 | 1184 | # ignore instructions that simply aren't supported yet 1185 | if mnemonic in self.assembler.UNSUPPORTED_MNEMONICS: 1186 | unsupported += 1 1187 | unsupported_map[mnemonic] += 1 1188 | continue 1189 | 1190 | # fetch raw info about the instruction 1191 | disas_raw = self.assembler.format_assembly(ea) 1192 | disas_size = ida_bytes.get_item_size(ea) 1193 | disas_bytes = ida_bytes.get_bytes(ea, disas_size) 1194 | 1195 | #print("0x%08X: ASSEMBLING '%s'" % (ea, disas_raw)) 1196 | start_asm = time.time() 1197 | asm_bytes = self.assembler.asm(disas_raw, ea) 1198 | end_asm = time.time() 1199 | asm_time = end_asm - start_asm 1200 | 1201 | if asm_time > asm_threshold: 1202 | print("%08X: SLOW %0.2fs - %s" % (ea, asm_time, disas_raw)) 1203 | slow_limit -= 1 1204 | if slow_limit == 0: 1205 | break 1206 | 1207 | # assembled vs expected 1208 | byte_tuple = (asm_bytes, disas_bytes) 1209 | 1210 | # assembled bytes match what is in the database 1211 | if asm_bytes == disas_bytes or byte_tuple in alternates: 1212 | good += 1 1213 | continue 1214 | 1215 | asm_bytes = self.assembler.asm(disas_raw, ea) 1216 | 1217 | byte_tuple = (asm_bytes, disas_bytes) 1218 | 1219 | # assembled bytes match what is in the database 1220 | if asm_bytes == disas_bytes or byte_tuple in alternates: 1221 | good += 1 1222 | fallback += 1 1223 | continue 1224 | 1225 | known_text = disas_raw in fail_addrs 1226 | known_bytes = byte_tuple in fail_bytes[disas_raw] 1227 | 1228 | if not known_bytes and len(asm_bytes): 1229 | 1230 | # the assembled patch is the same size, or smaller than the og 1231 | if len(asm_bytes) <= len(disas_bytes): 1232 | ida_before = ida_lines.tag_remove(ida_lines.generate_disasm_line(ea)) 1233 | ida_after = disassemble_bytes(asm_bytes, ea) 1234 | 1235 | ida_after = ida_after.split(';')[0] 1236 | ida_after = ida_after.replace(' short ', ' ') 1237 | ida_before = ida_before.split(';')[0] 1238 | 1239 | okay = False 1240 | if ida_after == ida_before: 1241 | okay = True 1242 | 1243 | # 1244 | # BEFORE: 'add [rax+rax+0], ch' 1245 | # AFTER: 'add [rax+rax], ch 1246 | # 0x18004830B: NEW FAILURE 'add [rax+rax+0], ch' 1247 | # - IDA: 00 6C 00 00 1248 | # - ASM: 00 2C 00 1249 | # 1250 | 1251 | elif ida_before.replace('+0]', ']') == ida_after: 1252 | okay = True 1253 | 1254 | elif '$+5' in ida_before: 1255 | okay = True 1256 | 1257 | if okay: 1258 | alternates.add(byte_tuple) 1259 | good += 1 1260 | continue 1261 | 1262 | print("BEFORE: '%s'\n AFTER: '%s" % (ida_before, ida_after)) 1263 | 1264 | fail_addrs[disas_raw].append(ea) 1265 | fail_bytes[disas_raw].add(byte_tuple) 1266 | 1267 | if known_text and known_bytes: 1268 | continue 1269 | 1270 | if not known_text: 1271 | print("0x%08X: NEW FAILURE '%s'" % (ea, disas_raw)) 1272 | else: 1273 | print("0x%08X: NEW BYTES '%s'" % (ea, disas_raw)) 1274 | 1275 | disas_hex = ' '.join(['%02X' % x for x in disas_bytes]) 1276 | asm_hex = ' '.join(['%02X' % x for x in asm_bytes]) 1277 | print(" - IDA: %s\n - ASM: %s" % (disas_hex, asm_hex)) 1278 | #break 1279 | 1280 | # re-hook the to re-enable the plugin's ability to see patch events 1281 | self._idb_hooks.hook() 1282 | 1283 | print("-"*50) 1284 | print("RESULTS") 1285 | print("-"*50) 1286 | 1287 | for disas_raw in sorted(fail_addrs, key=lambda k: len(fail_addrs[k]), reverse=True): 1288 | print("%-5u Fails -- %-40s -- (%u unique patterns)" % (len(fail_addrs[disas_raw]), disas_raw, len(fail_bytes[disas_raw]))) 1289 | 1290 | if False: 1291 | 1292 | print("-"*50) 1293 | print("ALTERNATE MAPPINGS") 1294 | print("-"*50) 1295 | 1296 | for x, y in alternates: 1297 | print('%-20s\t%s' % (' '.join(['%02X' % z for z in x]), ' '.join(['%02X' % z for z in y]))) 1298 | 1299 | if unsupported_map: 1300 | 1301 | print("-"*50) 1302 | print("(KNOWN) Unsupported Mnemonics") 1303 | print("-"*50) 1304 | 1305 | for mnem, hits in unsupported_map.items(): 1306 | print(" - %s - hits %u" % (mnem.ljust(10), hits)) 1307 | 1308 | if total: 1309 | percent = str((good/total)*100) 1310 | else: 1311 | percent = "100.0" 1312 | 1313 | percent_truncated = percent[:percent.index('.')+3] # truncate! don't round this float... 1314 | 1315 | arch_name = ida_ida.inf_get_procname() 1316 | 1317 | total_failed = total - good 1318 | unknown_fails = total_failed - unsupported 1319 | print("-"*50) 1320 | 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( 1321 | percent=percent_truncated.rjust(6, ' '), 1322 | good=good, 1323 | total=total, 1324 | fallback=fallback, 1325 | total_failed=total_failed, 1326 | unsupported=unsupported, 1327 | unknown_fails=unknown_fails, 1328 | arch_name=arch_name, 1329 | input_path=ida_nalt.get_input_file_path() 1330 | ) 1331 | ) 1332 | 1333 | total_time = int(time.time() - start_time) 1334 | print(" - Took %s %s..." % (datetime.timedelta(seconds=total_time), 'minutes' if total_time >= 60 else 'seconds')) 1335 | --------------------------------------------------------------------------------