From dab72b9084dee6d1ecec8ee62927142b8a3cf755 Mon Sep 17 00:00:00 2001 From: DC3-TSD <12175126+DC3-DCCI@users.noreply.github.com> Date: Tue, 23 Apr 2024 11:35:08 -0400 Subject: [PATCH] 1.1.0 Release --- CHANGELOG.md | 10 ++++- README.md | 20 ++++++++- pyhidra/__init__.py | 2 +- pyhidra/__main__.py | 14 ------ pyhidra/core.py | 58 +++++++++++++++++++----- pyhidra/gui.py | 87 +++++++++++++++++++++++------------- pyhidra/install_desktop.py | 32 +++++++++++++ pyhidra/launcher.py | 4 +- pyhidra/linux_shortcut.py | 63 ++++++++++++++++++++++++++ pyhidra/uninstall_desktop.py | 13 ++++++ pyhidra/win_shortcut.py | 21 ++++++--- tests/test_core.py | 38 ++++++++++++++++ 12 files changed, 297 insertions(+), 65 deletions(-) create mode 100644 pyhidra/install_desktop.py create mode 100644 pyhidra/linux_shortcut.py create mode 100644 pyhidra/uninstall_desktop.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e9bc87..6e55d2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.1.0] - 2024-04-23 +- Improved `pyhidraw` compatibility on Mac. +- Added loader parameter to `open_program` and `run_script` (#37). +- Added script to install a desktop launcher for Windows and Linux. (see [docs](./README.md#desktop-entry)) +- Removed `--shortcut` option on `pyhidra` command. + + ## [1.0.2] - 2024-02-14 - Added `--debug` switch to `pyhidra` command line to set the `pyhidra` logging level to `DEBUG`. - Warnings when compiling Java code are now logged at the `INFO` logging level. @@ -96,7 +103,8 @@ ## 0.1.0 - 2021-06-14 - Initial release -[Unreleased]: https://github.com/dod-cyber-crime-center/pyhidra/compare/1.0.2...HEAD +[Unreleased]: https://github.com/dod-cyber-crime-center/pyhidra/compare/1.1.0...HEAD +[1.1.0]: https://github.com/dod-cyber-crime-center/pyhidra/compare/1.0.2...1.1.0 [1.0.2]: https://github.com/dod-cyber-crime-center/pyhidra/compare/1.0.1...1.0.2 [1.0.1]: https://github.com/dod-cyber-crime-center/pyhidra/compare/1.0.0...1.0.1 [1.0.0]: https://github.com/dod-cyber-crime-center/pyhidra/compare/0.5.4...1.0.0 diff --git a/README.md b/README.md index 3076193..189cdf9 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Pyhidra was initially developed for use with Dragodis and is designed to be inst 1. Install pyhidra. ```console -> pip install pyhidra +pip install pyhidra ``` ### Enabling the Ghidra User Interface Plugin @@ -27,6 +27,24 @@ Pyhidra was initially developed for use with Dragodis and is designed to be inst 5. Check and enable Pyhidra as seen in the image below. ![](https://raw.githubusercontent.com/Defense-Cyber-Crime-Center/pyhidra/master/images/image-20220111154120531.png) +### Desktop Entry + +If on linux or windows, a desktop entry can be created to launch an instance of Ghidra with pyhidra attached. + +```console +python -m pyhidra.install_desktop +``` + +On windows, this will install a shortcut file on the user's desktop. On linux, this will create an entry +that can be found in the applications launcher. + + +To remove, run the following: + +```console +python -m pyhidra.uninstall_desktop +``` + ### Manual Plugin Installation If pyhidra is planned to be used in a multiprocessing deployed server, the following must be run to allow the Ghidra plugins to be compiled and installed before use. diff --git a/pyhidra/__init__.py b/pyhidra/__init__.py index 1f201db..99bedfd 100644 --- a/pyhidra/__init__.py +++ b/pyhidra/__init__.py @@ -1,5 +1,5 @@ -__version__ = "1.0.2" +__version__ = "1.1.0" # Expose API from .core import run_script, start, started, open_program diff --git a/pyhidra/__main__.py b/pyhidra/__main__.py index a59e74b..527be17 100644 --- a/pyhidra/__main__.py +++ b/pyhidra/__main__.py @@ -14,11 +14,6 @@ logger = logging.getLogger("pyhidra") -def _create_shortcut(): - from pyhidra.win_shortcut import create_shortcut - create_shortcut(Path(sys.argv[-1])) - - def _interpreter(interpreter_globals: dict): from ghidra.framework import Application version = Application.getApplicationVersion() @@ -182,15 +177,6 @@ def _get_parser(): dest="gui", help="Start Ghidra GUI" ) - if is_win32: - parser.add_argument( - "-s", - "--shortcut", - action="store_const", - dest="func", - const=_create_shortcut, - help="Creates a shortcut that can be pinned to the taskbar (Windows only)" - ) parser.add_argument( "--install-dir", type=Path, diff --git a/pyhidra/core.py b/pyhidra/core.py index 9bddf2c..99f19e8 100644 --- a/pyhidra/core.py +++ b/pyhidra/core.py @@ -65,9 +65,11 @@ def _setup_project( project_location: Union[str, Path] = None, project_name: str = None, language: str = None, - compiler: str = None + compiler: str = None, + loader: Union[str, JClass] = None ) -> Tuple["GhidraProject", "Program"]: from ghidra.base.project import GhidraProject + from java.lang import ClassLoader from java.io import IOException if binary_path is not None: binary_path = Path(binary_path) @@ -80,6 +82,19 @@ def _setup_project( project_location /= project_name project_location.mkdir(exist_ok=True, parents=True) + if isinstance(loader, str): + from java.lang import ClassNotFoundException + try: + gcl = ClassLoader.getSystemClassLoader() + loader = JClass(loader, gcl) + except (TypeError, ClassNotFoundException) as e: + raise ValueError from e + + if isinstance(loader, JClass): + from ghidra.app.util.opinion import Loader + if not Loader.class_.isAssignableFrom(loader): + raise TypeError(f"{loader} does not implement ghidra.app.util.opinion.Loader") + # Open/Create project program: "Program" = None try: @@ -90,15 +105,24 @@ def _setup_project( except IOException: project = GhidraProject.createProject(project_location, project_name, False) + # NOTE: GhidraProject.importProgram behaves differently when a loader is provided + # loaderClass may not be null so we must use the correct method override + if binary_path is not None and program is None: if language is None: - program = project.importProgram(binary_path) + if loader is None: + program = project.importProgram(binary_path) + else: + program = project.importProgram(binary_path, loader) if program is None: raise RuntimeError(f"Ghidra failed to import '{binary_path}'. Try providing a language manually.") else: lang = _get_language(language) comp = _get_compiler_spec(lang, compiler) - program = project.importProgram(binary_path, lang, comp) + if loader is None: + program = project.importProgram(binary_path, lang, comp) + else: + program = project.importProgram(binary_path, loader, lang, comp) if program is None: message = f"Ghidra failed to import '{binary_path}'. " if compiler: @@ -158,7 +182,8 @@ def open_program( analyze=True, language: str = None, compiler: str = None, -) -> ContextManager["FlatProgramAPI"]: + loader: Union[str, JClass] = None +) -> ContextManager["FlatProgramAPI"]: # type: ignore """ Opens given binary path in Ghidra and returns FlatProgramAPI object. @@ -172,8 +197,11 @@ def open_program( (Defaults to Ghidra's detected LanguageID) :param compiler: The CompilerSpecID to use for the program. Requires a provided language. (Defaults to the Language's default compiler) + :param loader: The `ghidra.app.util.opinion.Loader` class to use when importing the program. + This may be either a Java class or its path. (Defaults to None) :return: A Ghidra FlatProgramAPI object. - :raises ValueError: If the provided language or compiler is invalid. + :raises ValueError: If the provided language, compiler or loader is invalid. + :raises TypeError: If the provided loader does not implement `ghidra.app.util.opinion.Loader`. """ from pyhidra.launcher import PyhidraLauncher, HeadlessPyhidraLauncher @@ -189,7 +217,8 @@ def open_program( project_location, project_name, language, - compiler + compiler, + loader ) GhidraScriptUtil.acquireBundleHostReference() @@ -215,6 +244,7 @@ def _flat_api( analyze=True, language: str = None, compiler: str = None, + loader: Union[str, JClass] = None, *, install_dir: Path = None ): @@ -234,10 +264,13 @@ def _flat_api( (Defaults to Ghidra's detected LanguageID) :param compiler: The CompilerSpecID to use for the program. Requires a provided language. (Defaults to the Language's default compiler) + :param loader: The `ghidra.app.util.opinion.Loader` class to use when importing the program. + This may be either a Java class or its path. (Defaults to None) :param install_dir: The path to the Ghidra installation directory. This parameter is only used if Ghidra has not been started yet. (Defaults to the GHIDRA_INSTALL_DIR environment variable) - :raises ValueError: If the provided language or compiler is invalid. + :raises ValueError: If the provided language, compiler or loader is invalid. + :raises TypeError: If the provided loader does not implement `ghidra.app.util.opinion.Loader`. """ from pyhidra.launcher import PyhidraLauncher, HeadlessPyhidraLauncher @@ -251,7 +284,8 @@ def _flat_api( project_location, project_name, language, - compiler + compiler, + loader ) from ghidra.app.script import GhidraScriptUtil @@ -282,6 +316,7 @@ def run_script( analyze=True, lang: str = None, compiler: str = None, + loader: Union[str, JClass] = None, *, install_dir: Path = None ): @@ -301,12 +336,15 @@ def run_script( (Defaults to Ghidra's detected LanguageID) :param compiler: The CompilerSpecID to use for the program. Requires a provided language. (Defaults to the Language's default compiler) + :param loader: The `ghidra.app.util.opinion.Loader` class to use when importing the program. + This may be either a Java class or its path. (Defaults to None) :param install_dir: The path to the Ghidra installation directory. This parameter is only used if Ghidra has not been started yet. (Defaults to the GHIDRA_INSTALL_DIR environment variable) - :raises ValueError: If the provided language or compiler is invalid. + :raises ValueError: If the provided language, compiler or loader is invalid. + :raises TypeError: If the provided loader does not implement `ghidra.app.util.opinion.Loader`. """ script_path = str(script_path) - args = binary_path, project_location, project_name, verbose, analyze, lang, compiler + args = binary_path, project_location, project_name, verbose, analyze, lang, compiler, loader with _flat_api(*args, install_dir=install_dir) as script: script.run(script_path, script_args) diff --git a/pyhidra/gui.py b/pyhidra/gui.py index 059c0c9..261b6bc 100644 --- a/pyhidra/gui.py +++ b/pyhidra/gui.py @@ -5,6 +5,7 @@ import platform import sys import traceback +from typing import NoReturn import warnings import pyhidra @@ -37,20 +38,68 @@ def print_help(self, file=None): self._print_message(self.format_help(), file) +def _gui_mac() -> NoReturn: + args = _parse_args() + install_dir = args.install_dir + path = Path(sys.base_exec_prefix) / "Resources/Python.app/Contents/MacOS/Python" + if path.exists(): + # the python launcher app will correctly start the venv if sys.executable is in a venv + argv = [sys.executable, "-m", "pyhidra", "-g"] + if install_dir is not None: + argv += ["--install-dir", str(install_dir)] + actions = ((os.POSIX_SPAWN_CLOSE, 0), (os.POSIX_SPAWN_CLOSE, 1), (os.POSIX_SPAWN_CLOSE, 2)) + os.posix_spawn(str(path), argv, os.environ, file_actions=actions) + else: + print("could not find the Python.app path, launch failed") + sys.exit(0) + + +def _parse_args(): + parser = _GuiArgumentParser(prog="pyhidraw") + parser.add_argument( + "--install-dir", + type=Path, + default=None, + dest="install_dir", + metavar="", + help="Path to Ghidra installation. "\ + "(defaults to the GHIDRA_INSTALL_DIR environment variable)" + ) + return parser.parse_args() + + +def _gui_default(install_dir: Path): + pid = os.fork() + if pid != 0: + # original process can exit + return + + fd = os.open(os.devnull, os.O_RDWR) + # redirect stdin, stdout and stderr to /dev/null so the jvm can't use the terminal + # this also prevents errors from attempting to write to a closed sys.stdout #21 + os.dup2(fd, sys.stdin.fileno(), inheritable=False) + os.dup2(fd, sys.stdout.fileno(), inheritable=False) + os.dup2(fd, sys.stderr.fileno(), inheritable=False) + + # run the application + gui(install_dir) + + def _gui(): # this is the entry from the gui script # there may or may not be an attached terminal # depending on the current operating system + if platform.system() == "Darwin": + _gui_mac() + # This check handles the edge case of having a corrupt Python installation # where tkinter can't be imported. Since there may not be an attached # terminal, the problem still needs to be reported somehow. try: - # This import creates problems for macOS - if platform.system() != 'Darwin': - import tkinter.messagebox as _ + import tkinter.messagebox as _ except ImportError as e: - if platform.system() == 'Windows': + if platform.system() == "Windows": # there is no console/terminal to report the error import ctypes MessageBox = ctypes.windll.user32.MessageBoxW @@ -61,17 +110,7 @@ def _gui(): raise try: - parser = _GuiArgumentParser(prog="pyhidraw") - parser.add_argument( - "--install-dir", - type=Path, - default=None, - dest="install_dir", - metavar="", - help="Path to Ghidra installation. "\ - "(defaults to the GHIDRA_INSTALL_DIR environment variable)" - ) - args = parser.parse_args() + args = _parse_args() install_dir = args.install_dir except Exception as e: import tkinter.messagebox @@ -82,22 +121,8 @@ def _gui(): if platform.system() == 'Windows': # gui_script works like it is supposed to on windows gui(install_dir) - return - - pid = os.fork() - if pid != 0: - # original process can exit - return - - fd = os.open(os.devnull, os.O_RDWR) - # redirect stdin, stdout and stderr to /dev/null so the jvm can't use the terminal - # this also prevents errors from attempting to write to a closed sys.stdout #21 - os.dup2(fd, sys.stdin.fileno(), inheritable=False) - os.dup2(fd, sys.stdout.fileno(), inheritable=False) - os.dup2(fd, sys.stderr.fileno(), inheritable=False) - - # run the application - gui(install_dir) + else: + _gui_default(install_dir) def gui(install_dir: Path = None): diff --git a/pyhidra/install_desktop.py b/pyhidra/install_desktop.py new file mode 100644 index 0000000..b7a6723 --- /dev/null +++ b/pyhidra/install_desktop.py @@ -0,0 +1,32 @@ +""" +Script to install Ghidra (pyhidra) desktop shortcut. +""" + +import argparse +import sys +from pathlib import Path +import logging + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(message)s") + parser = argparse.ArgumentParser("Install Pyhidra launcher on desktop") + parser.add_argument( + "--install-dir", + type=Path, + default=None, + dest="install_dir", + metavar="", + help="Path to Ghidra installation. "\ + "(defaults to the GHIDRA_INSTALL_DIR environment variable)" + ) + args = parser.parse_args() + + if sys.platform == "win32": + from pyhidra.win_shortcut import create_shortcut + elif sys.platform == "linux": + from pyhidra.linux_shortcut import create_shortcut + else: + sys.exit("Unsupported platform") + + create_shortcut(args.install_dir) diff --git a/pyhidra/launcher.py b/pyhidra/launcher.py index 11b8cc9..cd3d306 100644 --- a/pyhidra/launcher.py +++ b/pyhidra/launcher.py @@ -555,7 +555,7 @@ def _get_thread(name: str): def _launch(self): import ctypes - from ghidra import GhidraRun + from ghidra import Ghidra from java.lang import Runtime, Thread if sys.platform == "win32": @@ -565,7 +565,7 @@ def _launch(self): stdout = _PyhidraStdOut(sys.stdout) stderr = _PyhidraStdOut(sys.stderr) with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): - jpype.setupGuiEnvironment(lambda: GhidraRun().launch(self._layout, self.args)) + jpype.setupGuiEnvironment(lambda: Ghidra.main(["ghidra.GhidraRun", *self.args])) is_exiting = threading.Event() Runtime.getRuntime().addShutdownHook(Thread(is_exiting.set)) try: diff --git a/pyhidra/linux_shortcut.py b/pyhidra/linux_shortcut.py new file mode 100644 index 0000000..6db5736 --- /dev/null +++ b/pyhidra/linux_shortcut.py @@ -0,0 +1,63 @@ +import os +import shlex +import sys +import sysconfig +from pathlib import Path + +desktop_entry = """\ +[Desktop Entry] +Name=Ghidra (pyhidra) +Comment=Ghidra Software Reverse Engineering Suite (pyhidra launcher) +Type=Application +Categories=Application;Development; +Terminal=false +StartupNotify=true +StartupWMClass=ghidra-Ghidra +Icon={icon} +Exec={exec} +""" + +desktop_path = Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser() / "applications" / "pyhidra.desktop" + + +def extract_png(install_dir: Path) -> Path: + """Extracts the png image from the given install path.""" + ico_path = install_dir / "support" / "ghidra.ico" + png_path = ico_path.with_suffix(".png") + if png_path.exists(): + return png_path + + data = ico_path.read_bytes() + magic = data.find(b"\x89PNG") + if magic == -1: + sys.exit("Could not find magic number") + png_path.write_bytes(data[magic:]) + return png_path + + +def create_shortcut(install_dir: Path = None): + """Install a desktop entry on Linux machine.""" + pyhidra_exec = Path(sysconfig.get_path("scripts")) / "pyhidra" + if not pyhidra_exec.exists(): + sys.exit("pyhidra executable is not installed.") + + command = [str(pyhidra_exec), "--gui"] + if install_dir: + command += ["--install-dir", str(install_dir.expanduser())] + elif install_dir := os.environ.get("GHIDRA_INSTALL_DIR"): + install_dir = Path(install_dir) + else: + sys.exit( + "Unable to determine Ghidra installation directory. " + "Please set the GHIDRA_INSTALL_DIR environment variable." + ) + + icon = extract_png(install_dir) + desktop_path.write_text(desktop_entry.format(icon=icon, exec=shlex.join(command))) + print(f"Installed {desktop_path}") + + +def remove_shortcut(): + if desktop_path.exists(): + desktop_path.unlink() + print(f"Removed {desktop_path}") diff --git a/pyhidra/uninstall_desktop.py b/pyhidra/uninstall_desktop.py new file mode 100644 index 0000000..15e7fcd --- /dev/null +++ b/pyhidra/uninstall_desktop.py @@ -0,0 +1,13 @@ + +import sys + + +if __name__ == "__main__": + if sys.platform == "win32": + from pyhidra.win_shortcut import remove_shortcut + elif sys.platform == "linux": + from pyhidra.linux_shortcut import remove_shortcut + else: + sys.exit("Unsupported platform") + + remove_shortcut() diff --git a/pyhidra/win_shortcut.py b/pyhidra/win_shortcut.py index 1b7a728..1b1c428 100644 --- a/pyhidra/win_shortcut.py +++ b/pyhidra/win_shortcut.py @@ -8,10 +8,12 @@ # creating a shortcut with the winapi to have a set app id is trivial right? -def create_shortcut(link: Path): - if not link.is_absolute(): - link = link.absolute() - link = link.with_suffix(".lnk") + + +def create_shortcut(install_dir: Path = None): + """Creates a shortcut to Ghidra (with pyhidra) on the desktop.""" + + link = Path("~/Desktop/Ghidra (pyhidra).lnk").expanduser() if link.exists(): sys.exit(f"{link} already exists") @@ -27,7 +29,7 @@ def __init__(self, key: str, pid: int) -> None: ctypes.oledll.ole32.IIDFromString(key, ctypes.byref(self)) self[-1] = pid - launcher = DeferredPyhidraLauncher() + launcher = DeferredPyhidraLauncher(install_dir=install_dir) _PropertyVariant = struct.Struct(f"B7xP{ctypes.sizeof(ctypes.c_void_p())}x") _AppUserModelId = _PROPERTYKEY("{9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3}", 5) @@ -80,3 +82,12 @@ def __init__(self, key: str, pid: int) -> None: if p_store: _Release(p_store) ctypes.oledll.ole32.CoUninitialize() + + print(f"Installed {link}") + + +def remove_shortcut(): + link = Path("~/Desktop/Ghidra (pyhidra).lnk").expanduser() + if link.exists(): + link.unlink() + print(f"Removed {link}") diff --git a/tests/test_core.py b/tests/test_core.py index d0f4b40..9b19f82 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -86,6 +86,44 @@ def test_no_language_with_compiler(shared_datadir: Path): pass +def test_loader(shared_datadir: Path): + strings_exe = shared_datadir / EXE_NAME + with pyhidra.open_program( + strings_exe, + analyze=False, + language="DATA:LE:64:default", + compiler="pointer32", + loader="ghidra.app.util.opinion.BinaryLoader" + ) as flat_api: + assert bytes(flat_api.getBytes(flat_api.toAddr(0), 2)) == b"MZ" + + +def test_invalid_loader(shared_datadir: Path): + strings_exe = shared_datadir / EXE_NAME + with pytest.raises(ValueError): + with pyhidra.open_program( + strings_exe, + analyze=False, + language="DATA:LE:64:default", + compiler="pointer32", + loader="notaclass" + ) as flat_api: + pass + + +def test_invalid_loader_type(shared_datadir: Path): + strings_exe = shared_datadir / EXE_NAME + with pytest.raises(TypeError): + with pyhidra.open_program( + strings_exe, + analyze=False, + language="DATA:LE:64:default", + compiler="pointer32", + loader="ghidra.app.util.demangler.gnu.GnuDemangler" + ) as flat_api: + pass + + def test_no_project(capsys, shared_datadir: Path): pyhidra.run_script(None, shared_datadir / "projectless_script.py") captured = capsys.readouterr()