├── .github └── workflows │ └── package-plugin.yaml ├── .gitignore ├── LICENSE ├── README.md ├── install.py ├── plugins ├── patching.py └── patching │ ├── __init__.py │ ├── actions.py │ ├── asm.py │ ├── core.py │ ├── exceptions.py │ ├── keystone │ └── README.md │ ├── ui │ ├── preview.py │ ├── preview_ui.py │ ├── resources │ │ ├── assemble.png │ │ ├── forcejump.png │ │ ├── nop.png │ │ ├── revert.png │ │ └── save.png │ ├── save.py │ └── save_ui.py │ └── util │ ├── __init__.py │ ├── ida.py │ ├── misc.py │ ├── python.py │ └── qt.py └── screenshots ├── assemble.gif ├── clobber.png ├── forcejump.gif ├── nop.gif ├── revert.gif ├── save.gif ├── title.png └── usage.gif /.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 -------------------------------------------------------------------------------- /.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/* -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Patching - Interactive Binary Patching for IDA Pro 2 | 3 |

Patching Plugin

4 | 5 | ## Overview 6 | 7 | Patching assembly code to change the behavior of an existing program is not uncommon in malware analysis, software reverse engineering, and broader domains of security research. This project extends the popular [IDA Pro](https://www.hex-rays.com/products/ida/) disassembler to create a more robust interactive binary patching workflow designed for rapid iteration. 8 | 9 | This project is currently powered by a minor [fork](https://github.com/gaasedelen/keystone) of the ubiquitous [Keystone Engine](https://github.com/keystone-engine/keystone), supporting x86/x64 and Arm/Arm64 patching with plans to enable the remaining Keystone architectures in a future release. 10 | 11 | Special thanks to [Hex-Rays](https://hex-rays.com/) for supporting the development of this plugin. 12 | 13 | ## Releases 14 | 15 | * v0.2 -- Important bugfixes, IDA 9 compatibility 16 | * v0.1 -- Initial release 17 | 18 | # Installation 19 | 20 | This plugin requires IDA 7.6 and Python 3. It supports Windows, Linux, and macOS. 21 | 22 | *Please note, older versions of IDA (8.2 and below) are [not compatible](https://hex-rays.com/products/ida/news/8_2sp1/) with Python 3.11 and above.* 23 | 24 | ## Easy Install 25 | 26 | Run the following line in the IDA console to automatically install the plugin: 27 | 28 | ### Windows / Linux 29 | 30 | ```python 31 | import urllib.request as r; exec(r.urlopen('https://github.com/gaasedelen/patching/raw/main/install.py').read()) 32 | ``` 33 | 34 | ### macOS 35 | 36 | ```python 37 | import urllib.request as r; exec(r.urlopen('https://github.com/gaasedelen/patching/raw/main/install.py', cafile='/etc/ssl/cert.pem').read()) 38 | ``` 39 | 40 | ## Manual Install 41 | 42 | Alternatively, the plugin can be manually installed by downloading the distributable plugin package for your respective platform from the [releases](https://github.com/gaasedelen/patching/releases) page and unzipping it to your plugins folder. 43 | 44 | It is __*strongly*__ recommended you install this plugin into IDA's user plugin directory: 45 | 46 | ```python 47 | import ida_diskio, os; print(os.path.join(ida_diskio.get_user_idadir(), "plugins")) 48 | ``` 49 | 50 | # Usage 51 | 52 | The patching plugin will automatically load for supported architectures (x86/x64/Arm/Arm64) and inject relevant patching actions into the right click context menu of the IDA disassembly views: 53 | 54 |

Patching plugin right click context menu

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

The interactive patching dialog

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

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

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

Right click NOP instruction

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

Forcing a conditional jump

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

Applying patches to the original executable

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

Reverting patches

101 | 102 | While it is 'easy' to revert bytes back to their original value, it can be 'hard' to restore analysis to its previous state. Reverting a patch may *occasionally* require additional human fixups. 103 | 104 | # Known Bugs 105 | 106 | * Further improve ARM / ARM64 / THUMB correctness 107 | * Define 'better' behavior for cpp::like::symbols(...) / IDBs (very sketchy right now) 108 | * Adding / Updating / Modifying / Showing / Warning about Relocation Entries?? 109 | * Handle renamed registers (like against dwarf annotated idb)? 110 | * A number of new instructions (circa 2017 and later) are not supported by Keystone 111 | * A few problematic instruction encodings by Keystone 112 | 113 | # Future Work 114 | 115 | Time and motivation permitting, future work may include: 116 | 117 | * Enable the remaining major architectures supported by Keystone: 118 | * PPC32 / PPC64 / MIPS32 / MIPS64 / SPARC / SystemZ 119 | * Multi instruction assembly (eg. `xor eax, eax; ret;`) 120 | * Multi line assembly (eg. shellcode / asm labels) 121 | * Interactive byte / data / string editing 122 | * Symbol hinting / auto-complete / fuzzy-matching 123 | * Syntax highlighting the editable assembly line 124 | * Better hinting of errors, syntax issues, etc 125 | * NOP / Force Jump from Hex-Rays view (sounds easy, but probably pretty hard!) 126 | * radio button toggle between 'pretty print' mode vs 'raw' mode? or display both? 127 | ``` 128 | Pretty: mov [rsp+48h+dwCreationDisposition], 3 129 | Raw: mov [rsp+20h], 3 130 | ``` 131 | 132 | I welcome external contributions, issues, and feature requests. Please make any pull requests to the `develop` branch of this repository if you would like them to be considered for a future release. 133 | 134 | # Authors 135 | 136 | * Markus Gaasedelen ([@gaasedelen](https://twitter.com/gaasedelen)) 137 | -------------------------------------------------------------------------------- /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.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/__init__.py: -------------------------------------------------------------------------------- 1 | from patching.core import PatchingCore -------------------------------------------------------------------------------- /plugins/patching/actions.py: -------------------------------------------------------------------------------- 1 | import ida_idaapi 2 | import ida_kernwin 3 | 4 | from patching.ui.save import SaveController 5 | from patching.ui.preview import PatchingController 6 | from patching.util.ida import get_current_ea, read_range_selection 7 | 8 | #----------------------------------------------------------------------------- 9 | # IDA Plugin Actions 10 | #----------------------------------------------------------------------------- 11 | 12 | class NopAction(ida_kernwin.action_handler_t): 13 | NAME = 'patching:nop' 14 | ICON = 'nop.png' 15 | TEXT = "NOP" 16 | TOOLTIP = "NOP the selected instructions (or bytes)" 17 | HOTKEY = 'CTRL-N' 18 | 19 | def __init__(self, core): 20 | ida_kernwin.action_handler_t.__init__(self) 21 | self.core = core 22 | 23 | def activate(self, ctx): 24 | 25 | # fetch the address range selected by the user 26 | valid_selection, start_ea, end_ea = read_range_selection(ctx) 27 | 28 | # do a range-based NOP if the selection is valid 29 | if valid_selection: 30 | print("%08X --> %08X: NOP'd range" % (start_ea, end_ea)) 31 | self.core.nop_range(start_ea, end_ea) 32 | return 1 33 | 34 | # NOP a single instruction / item 35 | cur_ea = get_current_ea(ctx) 36 | if cur_ea == ida_idaapi.BADADDR: 37 | print("Cannot use NOP here... (Invalid Address)") 38 | return 0 39 | 40 | print("%08X: NOP'd item" % cur_ea) 41 | self.core.nop_item(cur_ea) 42 | 43 | # return 1 to refresh the IDA views 44 | return 1 45 | 46 | def update(self, ctx): 47 | 48 | # the NOP action should only be allowed to execute in the following views 49 | if ida_kernwin.get_widget_type(ctx.widget) == ida_kernwin.BWN_DISASM: 50 | return ida_kernwin.AST_ENABLE_FOR_WIDGET 51 | elif ida_kernwin.get_widget_title(ctx.widget) == 'PatchingCodeViewer': 52 | return ida_kernwin.AST_ENABLE_FOR_WIDGET 53 | 54 | # unknown context / widget, do NOT allow the NOP action to be used here 55 | return ida_kernwin.AST_DISABLE_FOR_WIDGET 56 | 57 | class RevertAction(ida_kernwin.action_handler_t): 58 | NAME = 'patching:revert' 59 | ICON = 'revert.png' 60 | TEXT = "Revert patch" 61 | TOOLTIP = "Revert patched bytes at the selected address" 62 | HOTKEY = None 63 | 64 | def __init__(self, core): 65 | ida_kernwin.action_handler_t.__init__(self) 66 | self.core = core 67 | 68 | def activate(self, ctx): 69 | 70 | # fetch the address range selected by the user 71 | valid_selection, start_ea, end_ea = read_range_selection(ctx) 72 | 73 | if valid_selection: 74 | print("%08X --> %08X: Reverted range" % (start_ea, end_ea)) 75 | self.core.revert_range(start_ea, end_ea) 76 | else: 77 | cur_ea = get_current_ea(ctx) 78 | print("%08X: Reverted patch" % cur_ea) 79 | self.core.revert_patch(cur_ea) 80 | 81 | # return 1 to refresh the IDA views 82 | return 1 83 | 84 | def update(self, ctx): 85 | return ida_kernwin.AST_ENABLE_ALWAYS 86 | 87 | class ForceJumpAction(ida_kernwin.action_handler_t): 88 | NAME = 'patching:forcejump' 89 | ICON = 'forcejump.png' 90 | TEXT = "Force jump" 91 | TOOLTIP = "Patch the selected jump into an unconditional jump" 92 | HOTKEY = None 93 | 94 | def __init__(self, core): 95 | ida_kernwin.action_handler_t.__init__(self) 96 | self.core = core 97 | 98 | def activate(self, ctx): 99 | cur_ea = get_current_ea(ctx) 100 | 101 | print("%08X: Forced conditional jump" % cur_ea) 102 | self.core.force_jump(cur_ea) 103 | 104 | # return 1 to refresh the IDA views 105 | return 1 106 | 107 | def update(self, ctx): 108 | return ida_kernwin.AST_ENABLE_ALWAYS 109 | 110 | class AssembleAction(ida_kernwin.action_handler_t): 111 | NAME = 'patching:assemble' 112 | ICON = 'assemble.png' 113 | TEXT = "~A~ssemble..." 114 | TOOLTIP = "Assemble new instructions at the selected address" 115 | HOTKEY = None 116 | 117 | def __init__(self, core): 118 | ida_kernwin.action_handler_t.__init__(self) 119 | self.core = core 120 | 121 | def activate(self, ctx): 122 | 123 | # do not create a new patching dialog if one is already active 124 | if ida_kernwin.find_widget(PatchingController.WINDOW_TITLE): 125 | return 1 126 | 127 | wid = PatchingController(self.core, get_current_ea(ctx)) 128 | 129 | # return 1 to refresh the IDA views 130 | return 1 131 | 132 | def update(self, ctx): 133 | return ida_kernwin.AST_ENABLE_ALWAYS 134 | 135 | class ApplyAction(ida_kernwin.action_handler_t): 136 | NAME = 'patching:apply' 137 | ICON = 'save.png' 138 | TEXT = "A~p~ply patches to..." 139 | TOOLTIP = "Select where to save the patched binary" 140 | HOTKEY = None 141 | 142 | def __init__(self, core): 143 | ida_kernwin.action_handler_t.__init__(self) 144 | self.core = core 145 | 146 | def activate(self, ctx): 147 | 148 | controller = SaveController(self.core) 149 | 150 | if controller.interactive(): 151 | print("Patch successful: %s" % self.core.patched_filepath) 152 | else: 153 | print("Patching cancelled...") 154 | 155 | # return 1 to refresh the IDA views 156 | return 1 157 | 158 | def update(self, ctx): 159 | return ida_kernwin.AST_ENABLE_ALWAYS 160 | 161 | class QuickApplyAction(ida_kernwin.action_handler_t): 162 | NAME = 'patching:quickapply' 163 | ICON = 'save.png' 164 | TEXT = "Quick apply patches" 165 | TOOLTIP = "Apply patches using the previously selected patch settings" 166 | HOTKEY = None 167 | 168 | def __init__(self, core): 169 | ida_kernwin.action_handler_t.__init__(self) 170 | self.core = core 171 | 172 | def activate(self, ctx): 173 | 174 | # attempt to perform a quick patch (save), per the user's request 175 | success, error = self.core.quick_apply() 176 | if success: 177 | print("Quick patch successful: %s" % self.core.patched_filepath) 178 | return 1 179 | 180 | # 181 | # since the quickpatch FAILED, fallback to popping the interactive 182 | # patch saving dialog to let the user sort out the issue 183 | # 184 | 185 | print("Quick patch failed...") 186 | controller = SaveController(self.core, error) 187 | 188 | if controller.interactive(): 189 | print("Patch successful: %s" % self.core.patched_filepath) 190 | else: 191 | print("Patching cancelled...") 192 | 193 | # return 1 to refresh the IDA views 194 | return 1 195 | 196 | def update(self, ctx): 197 | return ida_kernwin.AST_ENABLE_ALWAYS 198 | 199 | #----------------------------------------------------------------------------- 200 | # All Actions 201 | #----------------------------------------------------------------------------- 202 | 203 | PLUGIN_ACTIONS = \ 204 | [ 205 | NopAction, 206 | RevertAction, 207 | ForceJumpAction, 208 | AssembleAction, 209 | ApplyAction, 210 | QuickApplyAction 211 | ] 212 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 ;-) -------------------------------------------------------------------------------- /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/ui/resources/assemble.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/f7902033f9c2be9ea71017ce9eb13691906cc858/plugins/patching/ui/resources/assemble.png -------------------------------------------------------------------------------- /plugins/patching/ui/resources/forcejump.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/f7902033f9c2be9ea71017ce9eb13691906cc858/plugins/patching/ui/resources/forcejump.png -------------------------------------------------------------------------------- /plugins/patching/ui/resources/nop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/f7902033f9c2be9ea71017ce9eb13691906cc858/plugins/patching/ui/resources/nop.png -------------------------------------------------------------------------------- /plugins/patching/ui/resources/revert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/f7902033f9c2be9ea71017ce9eb13691906cc858/plugins/patching/ui/resources/revert.png -------------------------------------------------------------------------------- /plugins/patching/ui/resources/save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/f7902033f9c2be9ea71017ce9eb13691906cc858/plugins/patching/ui/resources/save.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /plugins/patching/ui/save_ui.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from patching.util.qt import * 4 | 5 | class SaveDialog(QtWidgets.QDialog): 6 | """ 7 | The UI components of the Patch Saving dialog. 8 | """ 9 | 10 | def __init__(self, controller): 11 | super().__init__() 12 | self.controller = controller 13 | self._ui_init() 14 | 15 | #-------------------------------------------------------------------------- 16 | # Initialization - UI 17 | #-------------------------------------------------------------------------- 18 | 19 | def _ui_init(self): 20 | """ 21 | Initialize UI elements. 22 | """ 23 | self.setWindowTitle(self.controller.WINDOW_TITLE) 24 | 25 | # remove auxillary buttons (such as '?') from window title bar 26 | remove_flags = ~( 27 | QtCore.Qt.WindowSystemMenuHint | 28 | QtCore.Qt.WindowContextHelpButtonHint 29 | ) 30 | self.setWindowFlags(self.windowFlags() & remove_flags) 31 | self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) 32 | 33 | # make dialog fixed size (no size grip, etc) 34 | #self.setWindowFlags(self.windowFlags() | QtCore.Qt.MSWindowsFixedSizeDialogHint) 35 | #self.setSizeGripEnabled(False) 36 | 37 | # make dialog modal, so users can't click around IDA / change more stuff 38 | #self.setModal(True) 39 | 40 | # initialize our ui elements 41 | self._ui_init_fields() 42 | self._ui_init_options() 43 | 44 | # layout the populated ui just before showing it 45 | self._ui_layout() 46 | 47 | # connect signals 48 | self._btn_target.clicked.connect(self.select_target_file) 49 | self._btn_apply.clicked.connect(self._attempt_patch) 50 | self._chk_clean.stateChanged.connect(self._checkboxes_changed) 51 | self._chk_quick.stateChanged.connect(self._checkboxes_changed) 52 | 53 | def _ui_init_fields(self): 54 | """ 55 | Initialize the interactive text fields for this UI control. 56 | """ 57 | self._label_target = QtWidgets.QLabel("Patch Target:") 58 | self._label_target.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) 59 | self._line_target = QtWidgets.QLineEdit() 60 | self._line_target.setText(self.controller.target_filepath) 61 | self._line_target.setMinimumWidth(360) 62 | self._btn_target = QtWidgets.QPushButton(" ... ") 63 | 64 | # warning / status message 65 | self._label_status = QtWidgets.QLabel() 66 | self._label_status.setWordWrap(True) 67 | self._label_status.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter) 68 | self._refresh_status_message() 69 | 70 | # apply patches button 71 | self._btn_apply = QtWidgets.QPushButton("Apply patches") 72 | 73 | def _ui_init_options(self): 74 | """ 75 | Initialize the interactive options for this UI control. 76 | """ 77 | self._group_options = QtWidgets.QGroupBox("Options") 78 | 79 | # checkbox options 80 | self._chk_clean = QtWidgets.QCheckBox("Patch cleanly") 81 | self._chk_clean.setChecked(self.controller.patch_cleanly) 82 | self._chk_clean.setToolTip("Maintain a clean (.bak) input file to clone and apply patches to each time") 83 | self._chk_quick = QtWidgets.QCheckBox("Show quick save") 84 | self._chk_quick.setChecked(self.controller.quick_apply) 85 | self._chk_quick.setToolTip("Use the current target filepath for future patch applications") 86 | 87 | # layout the groupbox 88 | layout = QtWidgets.QVBoxLayout(self._group_options) 89 | layout.addWidget(self._chk_clean) 90 | layout.addWidget(self._chk_quick) 91 | self._group_options.setLayout(layout) 92 | 93 | def _ui_layout(self): 94 | """ 95 | Layout the major UI elements of the widget. 96 | """ 97 | layout = QtWidgets.QGridLayout(self) 98 | 99 | # arrange the widgets in a 'grid' row col row span col span 100 | layout.addWidget(self._line_target, 0, 1, 1, 1) 101 | layout.addWidget(self._btn_target, 0, 2, 1, 1) 102 | layout.addWidget(self._group_options, 0, 0, 2, 1) 103 | layout.addWidget(self._label_status, 1, 1, 2, 1) 104 | layout.addWidget(self._btn_apply, 1, 2, 1, 1) 105 | #layout.setSizeConstraint(QtWidgets.QLayout.SetFixedSize) 106 | 107 | # apply the layout to the widget 108 | self.setLayout(layout) 109 | 110 | #-------------------------------------------------------------------------- 111 | # Events 112 | #-------------------------------------------------------------------------- 113 | 114 | def showEvent(self, e): 115 | """ 116 | Overload the showEvent to center the save dialog over the IDA main window. 117 | """ 118 | center_widget(self) 119 | return super().showEvent(e) 120 | 121 | def select_target_file(self): 122 | """ 123 | The user pressed the '...' button to select a file to patch. 124 | """ 125 | starting_directory = os.path.dirname(self.controller.target_filepath) 126 | 127 | # prompt the user to select a patch target / output file 128 | dialog = QtWidgets.QFileDialog() 129 | filepath, _ = dialog.getSaveFileName(caption="Select patch target...", directory=starting_directory) 130 | 131 | # user did not select a file or closed the file dialog 132 | if not filepath: 133 | return 134 | 135 | # save the selected patch target 136 | self.controller.update_target(filepath) 137 | self._line_target.setText(filepath) 138 | 139 | # 140 | # update the status text, in-case the controller has something 141 | # important to tell the user (eg, hinting them to turn clean 142 | # patching on, if it thinks it will succeed) 143 | # 144 | 145 | self._refresh_status_message() 146 | 147 | def _attempt_patch(self): 148 | """ 149 | The user clicked the Apply Patches button. 150 | """ 151 | target_filepath = self._line_target.text() 152 | apply_clean = self._chk_clean.isChecked() 153 | 154 | # if patching succeeds, we're all done! close the dialog 155 | if self.controller.attempt_patch(target_filepath, apply_clean): 156 | self.accept() 157 | return 158 | 159 | # patching must have failed, attempt to update the status / error message 160 | self._refresh_status_message() 161 | 162 | def _checkboxes_changed(self): 163 | """ 164 | The status of the checkboxes changed. 165 | """ 166 | self.controller.patch_cleanly = self._chk_clean.isChecked() 167 | self.controller.quick_apply = self._chk_quick.isChecked() 168 | 169 | #-------------------------------------------------------------------------- 170 | # Refresh 171 | #-------------------------------------------------------------------------- 172 | 173 | def _refresh_status_message(self): 174 | """ 175 | Refresh the status / error message text based on the underlying UI state. 176 | """ 177 | self._label_status.setText(self.controller.status_message) 178 | if self.controller.status_color: 179 | self._label_status.setStyleSheet("QLabel { font-weight: bold; color: %s; }" % (self.controller.status_color)) 180 | else: 181 | self._label_status.setStyleSheet(None) 182 | -------------------------------------------------------------------------------- /plugins/patching/util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/f7902033f9c2be9ea71017ce9eb13691906cc858/plugins/patching/util/__init__.py -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /plugins/patching/util/python.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import struct 3 | import weakref 4 | 5 | from types import ModuleType 6 | from importlib import reload 7 | 8 | #------------------------------------------------------------------------------ 9 | # Python helpers 10 | #------------------------------------------------------------------------------ 11 | 12 | def hexdump(data, wrap=0): 13 | """ 14 | Return a spaced string of printed hex bytes for the given data. 15 | """ 16 | wrap = wrap if wrap else len(data) 17 | if not data: 18 | return '' 19 | 20 | lines = [] 21 | for i in range(0, len(data), wrap): 22 | lines.append(' '.join(['%02X' % x for x in data[i:i+wrap]])) 23 | 24 | return '\n'.join(lines) 25 | 26 | def swap_value(value, size): 27 | """ 28 | Swap endianness of a given value in memory. (size width in bytes) 29 | """ 30 | if size == 1: 31 | return value 32 | if size == 2: 33 | return struct.unpack("H", value))[0] 34 | if size == 4: 35 | return struct.unpack("I", value))[0] 36 | if size == 8: 37 | return struct.unpack("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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /screenshots/assemble.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/f7902033f9c2be9ea71017ce9eb13691906cc858/screenshots/assemble.gif -------------------------------------------------------------------------------- /screenshots/clobber.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/f7902033f9c2be9ea71017ce9eb13691906cc858/screenshots/clobber.png -------------------------------------------------------------------------------- /screenshots/forcejump.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/f7902033f9c2be9ea71017ce9eb13691906cc858/screenshots/forcejump.gif -------------------------------------------------------------------------------- /screenshots/nop.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/f7902033f9c2be9ea71017ce9eb13691906cc858/screenshots/nop.gif -------------------------------------------------------------------------------- /screenshots/revert.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/f7902033f9c2be9ea71017ce9eb13691906cc858/screenshots/revert.gif -------------------------------------------------------------------------------- /screenshots/save.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/f7902033f9c2be9ea71017ce9eb13691906cc858/screenshots/save.gif -------------------------------------------------------------------------------- /screenshots/title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/f7902033f9c2be9ea71017ce9eb13691906cc858/screenshots/title.png -------------------------------------------------------------------------------- /screenshots/usage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaasedelen/patching/f7902033f9c2be9ea71017ce9eb13691906cc858/screenshots/usage.gif --------------------------------------------------------------------------------