├── .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 |

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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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
--------------------------------------------------------------------------------