├── .gitignore ├── setup.py ├── .github ├── dependabot.yaml └── workflows │ └── python-build-publish.yaml ├── fishhook ├── __init__.py ├── asm.py ├── tests.py ├── _asmmodule.c ├── jit.py └── fishhook.py ├── pyproject.toml ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .eggs/ 3 | dist/ 4 | build/ 5 | fishhook/__pycache__ 6 | fishhook.egg-info/ 7 | push.sh 8 | **/.DS_Store 9 | .vscode/ 10 | fishhook/_asm.*.so 11 | wheelhouse/ -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup( 4 | ext_modules=[ 5 | setuptools.Extension("fishhook._asm", sources=["fishhook/_asmmodule.c"]) 6 | ], 7 | ) 8 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | ci-dependencies: 9 | patterns: 10 | - "*" 11 | -------------------------------------------------------------------------------- /fishhook/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This module allows for swapping out the slot pointers contained in static 3 | classes with the `generic` slot pointers used by python for heap classes. 4 | This allows for assigning arbitrary python functions to static class dunders 5 | using `hook` and `hook_cls` and for applying new functionality to previously 6 | unused dunders. A hooked static dunder can be restored to original 7 | functionality using the `unhook` function 8 | ''' 9 | 10 | __all__ = ['orig', 'hook', 'unhook'] 11 | 12 | from importlib.metadata import version 13 | try: 14 | __version__ = version('fishhook') 15 | except: 16 | __version__ = 'unknown' 17 | 18 | from .fishhook import * 19 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fishhook" 3 | version = "0.3.5" 4 | description = "Allows for runtime hooking of static class functions" 5 | authors = [ 6 | { name = "chilaxan", email = "chilaxan@gmail.com" }, 7 | ] 8 | license = { text = "MIT" } 9 | readme = "README.md" 10 | classifiers = [ 11 | "Programming Language :: Python :: 3", 12 | "License :: OSI Approved :: MIT License", 13 | "Operating System :: OS Independent", 14 | ] 15 | requires-python = ">=3.8" 16 | dependencies = [ 17 | "capstone; os_name != 'nt'", 18 | "keystone-engine; os_name != 'nt'", 19 | ] 20 | 21 | [project.urls] 22 | repository = "https://github.com/chilaxan/fishhook" 23 | 24 | [build-system] 25 | requires = ["setuptools"] 26 | build-backend = "setuptools.build_meta" 27 | 28 | [tool.setuptools] 29 | packages = ["fishhook"] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 chilaxan 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 | # Fishhook 2 | 3 | This module allows for swapping out the slot pointers contained in static 4 | classes with the **generic** slot pointers used by python for heap classes. 5 | This allows for assigning arbitrary python functions to static class dunders 6 | using *hook* and *hook_cls* and for applying new functionality to previously 7 | unused dunders. A hooked static dunder can be restored to original 8 | functionality using the *unhook* function 9 | 10 | it is possible to hook descriptors using *hook.property*, and an example can be seen below 11 | 12 | # Calling original methods 13 | `orig(self, *args, **kwargs)` is a special function that looks up the original implementation of a hooked dunder in the methods cache. It will only work properly when used inside a hooked method where an original implementation existed 14 | 15 | ### hooking single methods 16 | ```py 17 | @hook(int) 18 | def __add__(self, other): 19 | ... 20 | return orig(self, other) 21 | ``` 22 | 23 | ### hooking multiple methods 24 | ```py 25 | @hook.cls(int) 26 | class int_hook: 27 | attr = ... 28 | 29 | def __add__(self, other): 30 | ... 31 | ``` 32 | 33 | ### hooking descriptors 34 | ```py 35 | @hook.property(int) 36 | def imag(self): 37 | ... 38 | return orig.imag 39 | ``` 40 | 41 | # fishhook.asm 42 | 43 | This submodule allows for more in-depth C level hooks. 44 | For obvious reasons, this is vastly unstable, mostly provided as an experiment. 45 | Originally created as a way to grab a reference to the Interned strings dictionary. 46 | 47 | ```py 48 | from fishhook import asm 49 | from ctypes import py_object, pythonapi 50 | 51 | @asm.hook(pythonapi.PyDict_SetDefault, restype=py_object, argtypes=[py_object, py_object, py_object]) 52 | def setdefault(self, key, value): 53 | if key == 'MAGICVAL': 54 | return self 55 | return pythonapi.PyDict_SetDefault(self, key, value) 56 | 57 | pythonapi.PyUnicode_InternFromString.restype = py_object 58 | interned = pythonapi.PyUnicode_InternFromString(b'MAGICVAL') 59 | ``` 60 | 61 | #### Links 62 | 63 | [Github](https://github.com/chilaxan/fishhook) 64 | 65 | [PyPi](https://pypi.org/project/fishhook/) 66 | -------------------------------------------------------------------------------- /.github/workflows/python-build-publish.yaml: -------------------------------------------------------------------------------- 1 | name: Build and upload to PyPI 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | build_wheels: 10 | name: Build wheels on ${{ matrix.os }} 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, windows-latest, macos-latest] 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 19 | 20 | - name: Build wheels 21 | uses: pypa/cibuildwheel@d04cacbc9866d432033b1d09142936e6a0e2121a # v2.23.2 22 | env: 23 | CIBW_ENABLE: cpython-prerelease cpython-freethreading 24 | 25 | - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 26 | with: 27 | name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }} 28 | path: ./wheelhouse/*.whl 29 | 30 | build_sdist: 31 | name: Build source distribution 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Checkout repository 35 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 36 | 37 | - name: Build sdist 38 | run: pipx run build --sdist 39 | 40 | - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 41 | with: 42 | name: cibw-sdist 43 | path: dist/*.tar.gz 44 | 45 | upload_pypi: 46 | needs: [build_wheels, build_sdist] 47 | runs-on: ubuntu-latest 48 | environment: 49 | name: pypi 50 | url: https://pypi.org/project/fishhook/ 51 | permissions: 52 | id-token: write 53 | if: github.event_name == 'release' && github.event.action == 'published' 54 | steps: 55 | - name: Download artifacts 56 | uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 57 | with: 58 | # unpacks all CIBW artifacts into dist/ 59 | pattern: cibw-* 60 | path: dist 61 | merge-multiple: true 62 | 63 | - name: "Publish distribution 📦 to PyPI" 64 | uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 65 | with: 66 | skip-existing: true 67 | verbose: true 68 | print-hash: true 69 | -------------------------------------------------------------------------------- /fishhook/asm.py: -------------------------------------------------------------------------------- 1 | from ctypes import * 2 | import platform 3 | import weakref 4 | 5 | if platform.system() == 'Windows': 6 | raise RuntimeError('fishhook.asm does not currently work on Windows') 7 | 8 | from ._asm import writeExecutableMemory 9 | from .fishhook import getmem 10 | from .jit import TRAMPOLINE 11 | 12 | ''' 13 | Allows for hooking internal C funtions and redirecting them to python code 14 | Originally used for grabbing a reference to the CPython interened strings table 15 | ''' 16 | 17 | def addr(cfunc): 18 | ptr = c_void_p.from_address(addressof(cfunc)) 19 | return ptr.value 20 | 21 | def make_storage(**registers): 22 | class _RegisterStorage(Structure): 23 | pass 24 | _RegisterStorage._fields_ = [ 25 | (register, typ) for register, typ in registers.items() 26 | ] 27 | return _RegisterStorage() 28 | 29 | def hook(cfunc, restype=c_int, argtypes=(), registers=None): 30 | cfunctype = PYFUNCTYPE(restype, *argtypes) 31 | cfunc.restype, cfunc.argtypes = restype, argtypes 32 | o_ptr = addr(cfunc) 33 | def wrapper(func): 34 | if registers: 35 | storage = make_storage(**registers) 36 | regs = list(registers) 37 | else: 38 | storage = None 39 | regs = () 40 | @cfunctype 41 | def injected(*args, **kwargs): 42 | try: 43 | writeExecutableMemory(mem, default) 44 | if storage: 45 | kwargs['registers'] = storage 46 | return func(*args, **kwargs) 47 | finally: 48 | writeExecutableMemory(mem, trampoline) 49 | n_ptr = addr(injected) 50 | trampoline = TRAMPOLINE(n_ptr, storage, regs) 51 | mem = getmem(o_ptr, len(trampoline), 'c') 52 | default = mem.tobytes() 53 | writeExecutableMemory(mem, trampoline) 54 | def unhook(): 55 | writeExecutableMemory(mem, default) 56 | # reset memory back to default if hook is deallocated 57 | weakref.finalize(injected, unhook) 58 | injected.unhook = unhook 59 | return injected 60 | return wrapper 61 | 62 | def get_interned_strings_dict(): 63 | @hook(pythonapi.PyDict_SetDefault, restype=py_object, argtypes=[py_object, py_object, py_object]) 64 | def setdefault(self, key, value): 65 | if key == 'MAGICVAL': 66 | return self 67 | return pythonapi.PyDict_SetDefault(self, key, value) 68 | 69 | pythonapi.PyUnicode_InternFromString.restype = py_object 70 | interned = pythonapi.PyUnicode_InternFromString(b'MAGICVAL') 71 | return interned 72 | -------------------------------------------------------------------------------- /fishhook/tests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from . import hook, unhook, orig, force_delattr 3 | 4 | class TestFishhook(unittest.TestCase): 5 | def test_hook_dunder(self): 6 | SENTINEL = object() 7 | @hook(int) 8 | def __matmul__(self, other): 9 | return SENTINEL 10 | 11 | self.assertIs(1 @ 2, SENTINEL) 12 | unhook(int, '__matmul__') 13 | with self.assertRaises(AttributeError): 14 | int.__matmul__ 15 | 16 | def test_hook_unhook(self): 17 | orig_val = int.__add__ 18 | @hook(int) 19 | def __add__(self, other): 20 | return orig(self, other) 21 | 22 | self.assertNotEqual(int.__add__, orig_val) 23 | unhook(int, '__add__') 24 | self.assertEqual(int.__add__, orig_val) 25 | 26 | def test_orig(self): 27 | HOOK_RAN = False 28 | @hook(int) 29 | def __add__(self, other): 30 | nonlocal HOOK_RAN 31 | HOOK_RAN = True 32 | return orig(self, other) 33 | 34 | a = 1 # prevents add from being optimized away 35 | self.assertEqual(a + 2, 3) 36 | self.assertTrue(HOOK_RAN) 37 | unhook(int, '__add__') 38 | 39 | def test_nested_hooks(self): 40 | HOOK_1_RAN = None 41 | HOOK_2_RAN = None 42 | 43 | @hook(int, name='__add__') 44 | def hook1(self, other): 45 | nonlocal HOOK_1_RAN 46 | HOOK_1_RAN = True 47 | return orig(self, other) 48 | 49 | @hook(int, name='__add__') 50 | def hook2(self, other): 51 | nonlocal HOOK_2_RAN 52 | HOOK_2_RAN = True 53 | return orig(self, other) 54 | 55 | HOOK_1_RAN = HOOK_2_RAN = False 56 | 57 | a = 1 58 | self.assertEqual(a + 2, 3) 59 | self.assertTrue(HOOK_1_RAN) 60 | self.assertTrue(HOOK_2_RAN) 61 | self.assertEqual(int.__add__.__name__, 'hook2') 62 | unhook(int, '__add__') 63 | self.assertEqual(int.__add__.__name__, 'hook1') 64 | unhook(int, '__add__') 65 | 66 | def test_nested_orig(self): 67 | def call(f, *args, **kwargs): 68 | return f(*args, **kwargs) 69 | 70 | @hook(int) 71 | def __add__(self, other): 72 | return call(orig, self, other) 73 | 74 | a = 1 75 | self.assertEqual(a + 2, 3) 76 | unhook(int, '__add__') 77 | 78 | def test_property_hook(self): 79 | orig_imag = int.imag 80 | SENTINEL = object() 81 | @hook.property(int) 82 | def imag(self): 83 | return SENTINEL 84 | 85 | self.assertIs(1 .imag, SENTINEL) 86 | self.assertEqual(orig_imag.__set__, int.imag.fset) 87 | unhook(int, 'imag') 88 | self.assertEqual(int.imag, orig_imag) 89 | 90 | def test_property_orig(self): 91 | orig_1_numerator = (1).numerator 92 | HOOK_RAN = False 93 | 94 | @hook.property(int) 95 | def numerator(self): 96 | nonlocal HOOK_RAN 97 | HOOK_RAN = True 98 | return orig.numerator 99 | 100 | HOOK_RAN = False 101 | self.assertEqual(orig_1_numerator, (1).numerator) 102 | self.assertTrue(HOOK_RAN) 103 | unhook(int, 'numerator') 104 | 105 | def test_hook_class(self): 106 | SENTINEL = object() 107 | @hook.cls(int) 108 | class int_hooks: 109 | attr = SENTINEL 110 | 111 | @property 112 | def imag(self): 113 | return (SENTINEL, SENTINEL) 114 | 115 | def __matmul__(self, other): 116 | return SENTINEL 117 | 118 | self.assertIs(int.attr, SENTINEL) 119 | self.assertEqual(1 .imag, (SENTINEL, SENTINEL)) 120 | self.assertIs(1 @ 1, SENTINEL) 121 | 122 | unhook(int, 'imag') 123 | self.assertNotEqual(1 .imag, (SENTINEL, SENTINEL)) 124 | force_delattr(int, 'attr') 125 | with self.assertRaises(AttributeError): 126 | int.attr 127 | unhook(int, '__matmul__') 128 | with self.assertRaises(TypeError): 129 | 1 @ 1 130 | 131 | 132 | if __name__ == '__main__': 133 | unittest.main() 134 | -------------------------------------------------------------------------------- /fishhook/_asmmodule.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #if !defined(_WIN32) 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #else 10 | #include 11 | #include 12 | #endif 13 | 14 | #if !defined(_WIN32) 15 | int PREAD = PROT_READ; 16 | int PWRITE = PROT_WRITE; 17 | int PEXEC = PROT_EXEC; 18 | #else 19 | int PREAD = 1 << 1; 20 | int PWRITE = 1 << 2; 21 | int PEXEC = 1 << 3; 22 | #endif 23 | 24 | void changeProts(Py_buffer buffer, int prots) { 25 | unsigned long long address = (unsigned long long)buffer.buf; 26 | size_t length = buffer.len; 27 | #if !defined(_WIN32) 28 | int pagesize = sysconf(_SC_PAGE_SIZE); 29 | unsigned long long addr_align = address & ~(pagesize - 1); 30 | unsigned long long mem_end = (address + length) & ~(pagesize - 1); 31 | if ((address + length) > mem_end) { 32 | mem_end += pagesize; 33 | } 34 | size_t memlen = mem_end - addr_align; 35 | mprotect((void *)addr_align, memlen, prots); 36 | #else 37 | int old; 38 | int flags; 39 | if (prots & PREAD && prots & PWRITE && prots & PEXEC) { 40 | flags = PAGE_EXECUTE_READWRITE; 41 | } else if (prots & PREAD && prots & PWRITE) { 42 | flags = PAGE_READWRITE; 43 | } else if (prots & PREAD && prots & PEXEC) { 44 | flags = PAGE_EXECUTE_READ; 45 | } else { 46 | flags = PAGE_READONLY; 47 | } 48 | VirtualProtect((LPVOID)address, length, flags, &old); 49 | #endif 50 | } 51 | 52 | void invalidateInstructionCache(void *addr, size_t length) { 53 | #if defined(_WIN32) 54 | FlushInstructionCache(GetCurrentProcess(), (unsigned char*)addr, length); 55 | #elif defined(__has_builtin) 56 | #if __has_builtin(__builtin___clear_cache) 57 | __builtin___clear_cache((char*)addr, (char*)(addr + length)); 58 | #endif 59 | #else 60 | // cannot determine way to clear instruction cache 61 | // things might work, and might not 62 | // If you get weird crashes, make an issue: https://github.com/chilaxan/fishhook 63 | #endif 64 | } 65 | 66 | static PyObject *method_writeExecutableMemory(PyObject *self, PyObject *args, PyObject *kwargs) { 67 | PyObject* target = NULL; 68 | PyObject* src = NULL; 69 | int prot_after = PREAD | PEXEC; 70 | 71 | Py_buffer target_buf, src_buf; 72 | 73 | static char *kwlist[] = { "target", "src", "prot_after", NULL }; 74 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, "OO|i", kwlist, &target, &src, &prot_after)) { 75 | return NULL; 76 | } 77 | 78 | if ( 79 | PyObject_GetBuffer(target, &target_buf, PyBUF_FULL_RO) == -1 80 | || PyObject_GetBuffer(src, &src_buf, PyBUF_FULL_RO) == -1 81 | ) { 82 | return NULL; 83 | } 84 | 85 | if (target_buf.len != src_buf.len) { 86 | PyErr_SetString(PyExc_ValueError, "target and src must be the same length"); 87 | return NULL; 88 | } 89 | 90 | changeProts(target_buf, PREAD | PWRITE); 91 | memcpy(target_buf.buf, src_buf.buf, target_buf.len); 92 | changeProts(target_buf, prot_after); 93 | invalidateInstructionCache(target_buf.buf, target_buf.len); 94 | 95 | PyBuffer_Release(&target_buf); 96 | PyBuffer_Release(&src_buf); 97 | Py_RETURN_NONE; 98 | } 99 | 100 | static PyMethodDef AsmMethods[] = { 101 | {"writeExecutableMemory", (PyCFunction) method_writeExecutableMemory, METH_VARARGS | METH_KEYWORDS, "write src into target executable memory"}, 102 | {NULL, NULL, 0, NULL} 103 | }; 104 | 105 | 106 | static struct PyModuleDef asmmodule = { 107 | PyModuleDef_HEAD_INIT, 108 | "fishhook._asm", 109 | "provides writeExecutableMemory function", 110 | -1, 111 | AsmMethods 112 | }; 113 | 114 | PyMODINIT_FUNC PyInit__asm(void) { 115 | PyObject* module = PyModule_Create(&asmmodule); 116 | PyModule_AddIntConstant(module, "PREAD", PREAD); 117 | PyModule_AddIntConstant(module, "PWRITE", PWRITE); 118 | PyModule_AddIntConstant(module, "PEXEC", PEXEC); 119 | return module; 120 | } -------------------------------------------------------------------------------- /fishhook/jit.py: -------------------------------------------------------------------------------- 1 | from ctypes import * 2 | import sys 3 | import platform 4 | 5 | try: 6 | import capstone as CS 7 | except ModuleNotFoundError as err: 8 | if 'distutils' in err.name: 9 | # capstone depends on distutils and pkg_resources, but in most configs does not need them 10 | # 3.12+ has deprecated these modules 11 | # we can stub them out and re-attempt the import 12 | class stub: 13 | def __getattr__(self, attr): 14 | return self 15 | def __call__(self, *args): 16 | return '' 17 | 18 | sys.modules['distutils'] = stub() 19 | sys.modules['distutils.sysconfig'] = stub() 20 | sys.modules['pkg_resources'] = stub() 21 | import capstone as CS 22 | 23 | try: 24 | import keystone as KS 25 | except ModuleNotFoundError as err: 26 | if 'distutils' in err.name: 27 | # keystone depends on distutils and pkg_resources, but in most configs does not need them 28 | # 3.12+ has deprecated these modules 29 | # we can stub them out and re-attempt the import 30 | class stub: 31 | def __getattr__(self, attr): 32 | return self 33 | def __call__(self, *args): 34 | return '' 35 | 36 | sys.modules['distutils'] = stub() 37 | sys.modules['distutils.sysconfig'] = stub() 38 | sys.modules['pkg_resources'] = stub() 39 | import keystone as KS 40 | 41 | ENDIAN = 'LITTLE' if memoryview(b'\1\0').cast('h')[0]==1 else 'BIG' 42 | BIT_SIZE = sys.maxsize.bit_length() + 1 43 | ARCH = platform.machine().upper() 44 | if ARCH == 'AMD64' or 'X86' in ARCH: 45 | ARCH = 'X86' 46 | 47 | if ARCH == 'AARCH64': 48 | ARCH = 'ARM64' 49 | 50 | assert ARCH in ['ARM64', 'X86'], f'Unsupported/Untested Architecture: {ARCH}' 51 | 52 | def maketools(): 53 | cs_arch = getattr(CS, f'CS_ARCH_{ARCH}') 54 | cs_mode = getattr(CS, f'CS_MODE_{ENDIAN}_ENDIAN') 55 | ks_arch = getattr(KS, f'KS_ARCH_{ARCH}') 56 | ks_mode = getattr(KS, f'KS_MODE_{ENDIAN}_ENDIAN') 57 | if ARCH == 'X86': 58 | cs_mode += getattr(CS, f'CS_MODE_{BIT_SIZE}') 59 | ks_mode += getattr(KS, f'KS_MODE_{BIT_SIZE}') 60 | 61 | return CS.Cs(cs_arch, cs_mode), KS.Ks(ks_arch, ks_mode) 62 | 63 | DECOMPILER, COMPILER = maketools() 64 | 65 | fragements = { 66 | 'ARM64': { 67 | 'push': 'str {0}, [sp, #-16]!;', 68 | 'pop': 'ldr {0}, [sp], #16;', 69 | 'load_const': 'ldr {0}, =0x{1:x};', 70 | 'call': 'blr {0};', 71 | 'ret': 'ret;', 72 | 73 | 'store_mem': 'str {0}, [{1}, #0x{2:x}];', 74 | 'read_mem': 'ldr {0}, [{1}, #0x{2:x}];' 75 | }, 76 | 'X86': { 77 | 'push': 'push {0};', 78 | 'pop': 'pop {0};', 79 | 'load_const': 'mov {0}, 0x{1:x};', 80 | 'call': 'call {0};', 81 | 'ret': 'ret;', 82 | 83 | 'store_mem': 'mov {3} ptr [{1} + 0x{2:x}], {0};', 84 | 'read_mem': 'mov {0}, {3} ptr [{1} + 0x{2:x}];' 85 | } 86 | } 87 | 88 | def size_to_typ(n): 89 | if ARCH == 'X86': 90 | if n == 1: 91 | return 'BYTE' 92 | elif n == 2: 93 | return 'WORD' 94 | elif n == 4: 95 | return 'DWORD' 96 | elif n == 8: 97 | return 'QWORD' 98 | else: 99 | raise RuntimeError(f'x86 register memory size {n} is unsupported') 100 | 101 | def inst(n, *args): 102 | return fragements[ARCH][n].format(*args) 103 | 104 | TMP_REG = 'lr' if ARCH == 'ARM64' else 'r15' 105 | 106 | def TRAMPOLINE(address, storage=None, registers=()): 107 | if storage: 108 | header = footer = inst('load_const', TMP_REG, addressof(storage)) 109 | for register in registers: 110 | field = getattr(type(storage), register) 111 | offset = field.offset 112 | typ = size_to_typ(field.size) 113 | header += inst('store_mem', register, TMP_REG, offset, typ) 114 | footer += inst('read_mem', register, TMP_REG, offset, typ) 115 | else: 116 | header = footer = '' 117 | 118 | payload = '\n'.join([ 119 | inst('push', TMP_REG), 120 | header, 121 | inst('load_const', TMP_REG, address), 122 | inst('call', TMP_REG), 123 | footer, 124 | inst('pop', TMP_REG), 125 | inst('ret') 126 | ]) 127 | 128 | payload, _ = COMPILER.asm(payload) 129 | if payload is None: 130 | raise RuntimeError('unable to build payload') 131 | return bytes(payload) -------------------------------------------------------------------------------- /fishhook/fishhook.py: -------------------------------------------------------------------------------- 1 | from ctypes import c_char, pythonapi, py_object 2 | import sys, dis, types 3 | 4 | BYTES_HEADER = bytes.__basicsize__ - 1 5 | 6 | Py_TPFLAGS_IMMUTABLE = 1 << 8 7 | Py_TPFLAGS_HEAPTYPE = 1 << 9 8 | Py_TPFLAGS_READY = 1 << 12 9 | 10 | def sizeof(obj): 11 | return type(obj).__sizeof__(obj) 12 | 13 | TYPE_BASICSIZE = sizeof(type) 14 | 15 | def getmem(obj_or_addr, size=None, fmt='P'): 16 | if size is None: 17 | if isinstance(obj_or_addr, type): 18 | # type(cls).__sizeof__(cls) calls a function with a heaptype member 19 | # if cls is currently unlocked and member happens to overlap with other data 20 | # crash occurs 21 | # we avoid this by hardcoding TYPE_BASICSIZE 22 | size = TYPE_BASICSIZE 23 | else: 24 | size = sizeof(obj_or_addr) 25 | addr = id(obj_or_addr) 26 | else: 27 | addr = obj_or_addr 28 | return memoryview((c_char*size).from_address(addr)).cast('c').cast(fmt) 29 | 30 | def alloc(size, _storage=[]): 31 | _storage.append(bytes(size)) 32 | return id(_storage[-1]) + BYTES_HEADER 33 | 34 | def get_structs(htc=type('',(),{'__slots__':()})): 35 | htc_mem = getmem(htc) 36 | last = None 37 | for ptr, idx in sorted([(ptr, idx) for idx, ptr in enumerate(htc_mem) 38 | if id(htc) < ptr < id(htc) + sizeof(htc)]): 39 | if last: 40 | offset, lp = last 41 | yield offset, ptr - lp 42 | last = idx, ptr 43 | 44 | def allocate_structs(cls): 45 | cls_mem = getmem(cls) 46 | for subcls in type(cls).__subclasses__(cls): 47 | allocate_structs(subcls) 48 | for offset, size in get_structs(): 49 | cls_mem[offset] = cls_mem[offset] or alloc(size) 50 | return cls_mem 51 | 52 | def find_offset(mem, val): 53 | return [*mem].index(val) 54 | 55 | def assert_cls(o): 56 | if type(o).__flags__ & (1 << 31): 57 | return o 58 | else: 59 | raise RuntimeError('Invalid class or object') 60 | 61 | def build_unlock_lock(): 62 | flag_offset = find_offset(getmem(int), int.__flags__) 63 | 64 | def unlock(cls): 65 | cls_mem = allocate_structs(assert_cls(cls)) 66 | flags = cls.__flags__ 67 | try: 68 | return flags, cls_mem[tp_dict_offset] == 0 69 | finally: 70 | if sys.version_info[0:2] <= (3, 9): 71 | cls_mem[flag_offset] |= Py_TPFLAGS_HEAPTYPE 72 | elif sys.version_info[0:2] >= (3, 10): 73 | cls_mem[flag_offset] &= ~Py_TPFLAGS_IMMUTABLE 74 | 75 | def lock(cls, flags=None, should_have_null_tp_dict=False): 76 | cls_mem = getmem(assert_cls(cls)) 77 | if should_have_null_tp_dict: 78 | materialized_dict_addr = cls_mem[tp_dict_offset] 79 | if materialized_dict_addr: 80 | cls_mem[tp_dict_offset] = 0 81 | getmem(materialized_dict_addr, 8)[0] -= 1 # clear materialized dict 82 | if flags is None: 83 | if sys.version_info[0:2] <= (3, 9): 84 | cls_mem[flag_offset] &= ~Py_TPFLAGS_HEAPTYPE 85 | elif sys.version_info[0:2] >= (3, 10): 86 | cls_mem[flag_offset] |= Py_TPFLAGS_IMMUTABLE 87 | else: 88 | cls_mem[flag_offset] = flags 89 | 90 | return unlock, lock 91 | 92 | unlock, lock = build_unlock_lock() 93 | 94 | def getdict(cls, E=type('',(),{'__eq__':lambda s,o:o})()): 95 | ''' 96 | Obtains a writeable dictionary of a classes namespace 97 | Note that any modifications to this dictionary should be followed by a 98 | call to PyType_Modified(cls) 99 | ''' 100 | return cls.__dict__ == E 101 | 102 | def newref(obj): 103 | getmem(obj)[0] += 1 104 | return obj 105 | 106 | class Template:pass 107 | template_mem = getmem(Template) 108 | tp_base_offset = find_offset(template_mem, id(Template.__base__)) 109 | tp_basicsize_offset = find_offset(template_mem, Template.__basicsize__) 110 | tp_flags_offset = find_offset(template_mem, Template.__flags__) 111 | tp_dict_offset = find_offset(template_mem, id(getdict(Template))) 112 | tp_bases_offset = find_offset(template_mem, id(Template.__bases__)) 113 | 114 | def patch_object(): 115 | ''' 116 | adds fake class to inheritance chain so that object can be modified 117 | also patches type.__base__ to never return fake class 118 | in theory is safe, if not, possible alternative would be injecting a class 119 | into all lookups by modifying type.__bases__? 120 | ''' 121 | 122 | fake_addr = alloc(sizeof(object)) 123 | fake_mem = getmem(fake_addr, sizeof(object)) 124 | fake_mem[0] = 1 125 | fake_mem[1] = id(newref(type)) 126 | fake_mem[3] = alloc(0) 127 | fake_mem[tp_flags_offset] = Py_TPFLAGS_READY | Py_TPFLAGS_IMMUTABLE 128 | fake_mem[tp_dict_offset] = id(newref({})) 129 | fake_mem[tp_bases_offset] = id(newref(())) 130 | fake_mem[tp_basicsize_offset] = object.__basicsize__ 131 | getmem(object)[tp_base_offset] = fake_addr 132 | 133 | # custom __base__ to protect fake super class 134 | # also restores original `__base__` functionality 135 | @property 136 | def __base__(self, object=object, orig=vars(type)['__base__'].__get__): 137 | if self is object: 138 | return None 139 | return orig(self) 140 | 141 | getdict(type)['__base__'] = __base__ 142 | # call PyType_Modified to reload cache 143 | pythonapi.PyType_Modified(py_object(type)) 144 | 145 | # needed to allow for `unlock(object)` to be stable 146 | patch_object() 147 | 148 | def force_setattr(cls, attr, value): 149 | flags = None 150 | try: 151 | flags, should_have_null_tp_dict = unlock(cls) 152 | if should_have_null_tp_dict: # added for 3.12+ to ensure consistent state 153 | getdict(cls)[attr] = value 154 | setattr(cls, attr, value) 155 | finally: 156 | lock(cls, flags, should_have_null_tp_dict) 157 | pythonapi.PyType_Modified(py_object(cls)) 158 | 159 | def force_delattr(cls, attr): 160 | flags = None 161 | try: 162 | flags, should_have_null_tp_dict = unlock(cls) 163 | if should_have_null_tp_dict: # added for 3.12+ to ensure consistent state 164 | del getdict(cls)[attr] 165 | try: 166 | delattr(cls, attr) 167 | except AttributeError: 168 | # for some attributes that are not cached 169 | # delattr does not have a consistent state 170 | # luckily, seems like we can ignore it 171 | pass 172 | finally: 173 | lock(cls, flags, should_have_null_tp_dict) 174 | pythonapi.PyType_Modified(py_object(cls)) 175 | 176 | NULL = object() 177 | NOT_FOUND = object() 178 | def build_orig(): 179 | class Cache: 180 | __slots__ = ['key', 'value'] 181 | def __init__(self, key, value): 182 | self.key = key 183 | self.value = value 184 | 185 | def add_cache(func, **kwargs): 186 | code = func.__code__ 187 | func_copy = type(func)( 188 | code.replace(co_consts=code.co_consts+tuple(Cache(key, value) for key, value in kwargs.items())), 189 | func.__globals__, 190 | name=func.__name__, 191 | closure=func.__closure__ 192 | ) 193 | func_copy.__defaults__ = func.__defaults__ 194 | func_copy.__kwdefaults__ = func.__kwdefaults__ 195 | func_copy.__qualname__ = func.__qualname__ 196 | return func_copy 197 | 198 | getframe = sys._getframe 199 | frame_items = vars(type(sys._getframe())) 200 | get_code = frame_items['f_code'].__get__ 201 | get_back = frame_items['f_back'].__get__ 202 | get_locals = frame_items['f_locals'].__get__ 203 | code_items = vars(type((lambda:0).__code__)) 204 | get_consts = code_items['co_consts'].__get__ 205 | get_varnames = code_items['co_varnames'].__get__ 206 | get_argcount = code_items['co_argcount'].__get__ 207 | get_kwonlyargcount = code_items['co_kwonlyargcount'].__get__ 208 | get_flags = code_items['co_flags'].__get__ 209 | tuple_getitem = tuple.__getitem__ 210 | tuple_iter = tuple.__iter__ 211 | tuple_len = tuple.__len__ 212 | int_add = int.__add__ 213 | int_and = int.__and__ 214 | int_bool = int.__bool__ 215 | locals_get = type(get_locals(getframe())).get # this enables compat with >= 3.13 FrameLocalsProxy 216 | flags = {v: k for k, v in dis.COMPILER_FLAG_NAMES.items()}.get 217 | new_slice = slice.__call__ 218 | get_class = vars(object)['__class__'].__get__ 219 | str_equals = str.__eq__ 220 | def get_cache(code, key): 221 | consts = get_consts(code) 222 | for cache in tuple_iter(tuple_getitem(consts, new_slice(None, None, -1))): 223 | if cache is not None and get_class(cache) is Cache: 224 | if str_equals(cache.key, key): 225 | return cache.value 226 | else: 227 | break # caches are injected at end of consts array 228 | return NOT_FOUND 229 | 230 | def get_cache_trace(key, frame): 231 | while frame is not None: 232 | code = get_code(frame) 233 | if (val := get_cache(code, key)) is not NOT_FOUND: 234 | if val is NULL: 235 | raise RuntimeError('original implementation not found') 236 | return val 237 | frame = get_back(frame) 238 | raise RuntimeError('orig used incorrectly') 239 | 240 | def get_self(frame): 241 | co = get_code(frame) 242 | locals = get_locals(frame) 243 | names = get_varnames(co) 244 | nargs = get_argcount(co) 245 | nkwargs = get_kwonlyargcount(co) 246 | args = tuple_getitem(names, new_slice(None, nargs, None)) 247 | nargs = int_add(nargs, nkwargs) 248 | varargs = None 249 | if int_bool(int_and(get_flags(co), flags('VARARGS'))): 250 | varargs = tuple_getitem(names, nargs) 251 | argvals = (*(locals_get(locals, arg, NULL) for arg in tuple_iter(args)), *locals_get(locals, varargs, ())) 252 | if int_bool(tuple_len(argvals)) and (self := tuple_getitem(argvals, 0)) is not NULL: 253 | return self 254 | raise RuntimeError('unable to bind self') 255 | 256 | class Orig: 257 | ''' 258 | Inspects the callers frame to deduce the original implementation of a hooked function 259 | The original implementation is then called with all passed arguments 260 | Not intended to be used outside hooked functions 261 | ''' 262 | def __call__(self, *args, **kwargs): 263 | return get_cache_trace('orig', getframe(1))(*args, **kwargs) 264 | 265 | def __getattr__(self, attr): 266 | frame = getframe(1) 267 | orig_attr = get_cache_trace('orig', frame) 268 | attr_name = get_cache_trace('attr_name', frame) 269 | if attr_name != attr: 270 | raise AttributeError('attribute not currently bound to \'orig\'') 271 | return orig_attr.__get__(get_self(frame)) 272 | 273 | def __setattr__(self, attr, value): 274 | frame = getframe(1) 275 | orig_attr = get_cache_trace('orig', frame) 276 | attr_name = get_cache_trace('attr_name', frame) 277 | if attr_name != attr: 278 | raise AttributeError('attribute not currently bound to \'orig\'') 279 | return orig_attr.__set__(get_self(frame), value) 280 | 281 | def __delattr__(self, attr): 282 | frame = getframe(1) 283 | orig_attr = get_cache_trace('orig', frame) 284 | attr_name = get_cache_trace('attr_name', frame) 285 | if attr_name != attr: 286 | raise AttributeError('attribute not currently bound to \'orig\'') 287 | return orig_attr.__delete__(get_self(frame)) 288 | 289 | return Orig(), add_cache, get_cache 290 | 291 | orig, add_cache, get_cache = build_orig() 292 | del build_orig 293 | 294 | def reduce_classes(*cls): 295 | for c in cls: 296 | if hasattr(types, 'UnionType') and isinstance(c, types.UnionType): 297 | yield from reduce_classes(*c.__args__) 298 | elif hasattr(types, 'GenericAlias') and isinstance(c, types.GenericAlias): 299 | yield from reduce_classes(c.__origin__) 300 | else: 301 | yield c 302 | 303 | def hook(_cls, *more_classes, name=None, func=None): 304 | ''' 305 | Decorator, allows for the decoration of functions to hook a specified dunder on a static class 306 | ex: 307 | 308 | @hook(int) 309 | def __add__(self, other): 310 | ... 311 | 312 | would set the implementation of `int.__add__` to the `__add__` specified above 313 | ''' 314 | def wrapper(func): 315 | nonlocal name 316 | for cls in reduce_classes(_cls, *more_classes): 317 | if isinstance(func, (classmethod, staticmethod)): 318 | code = func.__func__.__code__ 319 | else: 320 | code = func.__code__ 321 | name = name or code.co_name 322 | orig_val = vars(cls).get(name, NULL) 323 | if isinstance(func, classmethod): 324 | new_func = classmethod(add_cache(func.__func__, orig=orig_val)) 325 | elif isinstance(func, staticmethod): 326 | new_func = staticmethod(add_cache(func.__func__, orig=orig_val)) 327 | else: 328 | new_func = add_cache(func, orig=orig_val) 329 | force_setattr(cls, name, new_func) 330 | return func 331 | if func: 332 | return wrapper(func) 333 | return wrapper 334 | 335 | def unhook(cls, name): 336 | ''' 337 | Removes new implementation on static dunder 338 | Restores the original implementation of a static dunder if it exists 339 | ''' 340 | current = vars(cls).get(name) 341 | if isinstance(current, (classmethod, staticmethod)): 342 | current = current.__func__ 343 | if isinstance(current, property): 344 | for func in [current.fget, current.fset, current.fdel]: 345 | if hasattr(func, '__code__'): 346 | current = func 347 | break 348 | if not hasattr(current, '__code__'): 349 | raise RuntimeError(f'{cls.__name__}.{name} not hooked') 350 | orig_val = get_cache(current.__code__, 'orig') 351 | if orig_val is NOT_FOUND or name not in vars(cls): 352 | raise RuntimeError(f'{cls.__name__}.{name} not hooked') 353 | if orig_val is not NULL: 354 | force_setattr(cls, name, orig_val) 355 | else: 356 | force_delattr(cls, name) 357 | 358 | class hook_property: 359 | ''' 360 | Descriptor, allows for hooking a specified descriptor on a class 361 | ex: 362 | 363 | @hook.property(int) 364 | def imag(self): 365 | ... 366 | 367 | @imag.setter 368 | def imag_setter(self, value): 369 | ... 370 | 371 | would set the implementation of `int.imag.__get__` to the `imag` specified above 372 | and set `int.imag.__set__` to `imag_setter` 373 | ''' 374 | def __init__(self, cls, name=None, fget=None, fset=None, fdel=None): 375 | self.cls = cls 376 | self.prop = property() 377 | self.name = name 378 | self.__orig = NULL 379 | if fget: 380 | self.getter(fget) 381 | if fset: 382 | self.setter(fset) 383 | if fdel: 384 | self.deleter(fdel) 385 | 386 | def __prep(self, func): 387 | prop = self.prop 388 | names = [self.name] + [func.__name__] + [p.__name__ for p in [prop.fget, prop.fset, prop.fdel] if p] 389 | for name in names: 390 | if name is not None: 391 | self.name = name 392 | break 393 | orig = vars(self.cls).get(self.name, NULL) 394 | if self.__orig is NULL and orig is not prop and orig is not NULL: 395 | self.__orig = orig 396 | return add_cache(func, orig=self.__orig, attr_name=self.name) 397 | 398 | def __set_prop(self, prop): 399 | if self.name is None: 400 | raise RuntimeError('Invalid Hook') 401 | if self.__orig is not NULL: 402 | if prop.fget is None: 403 | prop = prop.getter(self.__orig.__get__) 404 | if prop.fset is None: 405 | prop = prop.setter(self.__orig.__set__) 406 | if prop.fdel is None: 407 | prop = prop.deleter(self.__orig.__delete__) 408 | force_setattr(self.cls, self.name, prop) 409 | self.prop = prop 410 | 411 | def __call__(self, func): 412 | return self.getter(func) 413 | 414 | def getter(self, func): 415 | self.fget = self.__prep(func) 416 | self.__set_prop(self.prop.getter(self.fget)) 417 | return self 418 | 419 | def setter(self, func): 420 | self.fset = self.__prep(func) 421 | self.__set_prop(self.prop.setter(self.fset)) 422 | return self 423 | 424 | def deleter(self, func): 425 | self.fdel = self.__prep(func) 426 | self.__set_prop(self.prop.deleter(self.fdel)) 427 | return self 428 | 429 | hook.property = hook_property 430 | 431 | class classproperty(property): 432 | def __get__(self, owner_self, owner_cls): 433 | return self.fget(owner_cls) 434 | 435 | def hook_var(cls, name, value): 436 | ''' 437 | Allows for easy hooking of static class variables 438 | ''' 439 | def prop(_): 440 | return value 441 | prop = add_cache(prop, orig=vars(cls).get(name, NULL)) 442 | force_setattr(cls, name, classproperty(prop)) 443 | 444 | hook.var = hook_var 445 | 446 | def hook_cls(cls, ncls=None): 447 | ''' 448 | Decorator, allows for the decoration of classes to hook static classes 449 | ex: 450 | 451 | @hook.cls(int) 452 | class int_hook: 453 | attr = ... 454 | 455 | def __add__(self, other): 456 | ... 457 | 458 | would apply all of the attributes specified in `int_hook` to `int` 459 | ''' 460 | def wrapper(ncls): 461 | key_blacklist = vars(type('',(),{})).keys() 462 | for (attr, value) in sorted(vars(ncls).items(), key=lambda v:callable(v[1]), reverse=True): 463 | if attr in key_blacklist: 464 | continue 465 | elif isinstance(value, property): 466 | setattr(ncls, attr, hook_property(cls, name=attr, fget=value.fget, fset=value.fset, fdel=value.fdel)) 467 | elif isinstance(value, (type(lambda:0), classmethod, staticmethod)): 468 | hook(cls, name=attr, func=value) 469 | else: 470 | hook_var(cls, attr, value) 471 | return ncls 472 | if ncls: 473 | return wrapper(ncls) 474 | return wrapper 475 | 476 | hook.cls = hook_cls 477 | --------------------------------------------------------------------------------