diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 50f762c..d3dc210 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,6 @@ on: type: boolean release: types: [ created ] - push: pull_request: jobs: diff --git a/.gitmodules b/.gitmodules index 425c097..b933fa8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "injector"] path = injector - url = https://github.com/kubo/injector.git + url = https://github.com/kmaork/injector.git diff --git a/MANIFEST.in b/MANIFEST.in index 43a1ee8..fc616f8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,5 @@ +# Must include all injector headers and source files, so the source distribution could be installed on any platform, +# regardless of the platform where it was built. recursive-include injector *.h recursive-include injector/src *.c -include LICENSE \ No newline at end of file +include LICENSE diff --git a/injector b/injector index 3c11bf2..a558e87 160000 --- a/injector +++ b/injector @@ -1 +1 @@ -Subproject commit 3c11bf2bb93b2bd4df187c77b54d0191e9acf79a +Subproject commit a558e878a2a6795835bc1635a92ceeecc7059c35 diff --git a/libinjector.c b/libinjector.c deleted file mode 100644 index d58a045..0000000 --- a/libinjector.c +++ /dev/null @@ -1,2 +0,0 @@ -#include -PyMODINIT_FUNC PyInit_libinjector(void) { return NULL; } diff --git a/pyinjector/__init__.py b/pyinjector/__init__.py index 860dc0d..51d8c8b 100644 --- a/pyinjector/__init__.py +++ b/pyinjector/__init__.py @@ -1,4 +1,4 @@ -from .api import inject, LibraryNotFoundException, InjectorError +from .api import inject, attach, PyInjectorError, LibraryNotFoundException, InjectorError from types import ModuleType @@ -13,3 +13,5 @@ def legacy_pyinjector_import(): legacy_pyinjector_import() + +__all__ = ['inject', 'attach', 'PyInjectorError', 'LibraryNotFoundException', 'InjectorError'] diff --git a/pyinjector/__main__.py b/pyinjector/__main__.py index 878ba8e..8b967ac 100644 --- a/pyinjector/__main__.py +++ b/pyinjector/__main__.py @@ -9,15 +9,16 @@ def parse_args(args: Optional[List[str]]) -> Namespace: parser = ArgumentParser(description='Inject a dynamic library to a running process.') parser.add_argument('pid', type=int, help='pid of the process to inject the library into') parser.add_argument('library_path', type=str.encode, help='path of the library to inject') + parser.add_argument('-u', '--uninject', action='store_true', help='Whether to uninject the library after injection') return parser.parse_args(args) def main(args: Optional[List[str]] = None) -> None: parsed_args = parse_args(args) try: - handle = inject(parsed_args.pid, parsed_args.library_path) + handle = inject(parsed_args.pid, parsed_args.library_path, parsed_args.uninject) except PyInjectorError as e: - print("pyinjector failed to inject: {}".format(e), file=sys.stderr) + print(f"pyinjector failed to inject: {e}", file=sys.stderr) else: print(f"Handle: {handle}") diff --git a/pyinjector/api.py b/pyinjector/api.py index 83fa2db..7b74337 100644 --- a/pyinjector/api.py +++ b/pyinjector/api.py @@ -1,24 +1,10 @@ +from __future__ import annotations import os -from importlib.util import find_spec -from ctypes import CDLL, Structure, POINTER, c_int32, byref, c_char_p, c_void_p, pointer -from typing import AnyStr, Callable, Any, Mapping, Type, Optional, Tuple +from contextlib import contextmanager +from typing import AnyStr, Optional from sys import platform -libinjector_path = find_spec('.libinjector', __package__).origin -libinjector = CDLL(libinjector_path) - -injector_t = type('injector_t', (Structure,), {}) -injector_pointer_t = POINTER(injector_t) -pid_t = c_int32 - -libinjector.injector_attach.argtypes = POINTER(injector_pointer_t), pid_t -libinjector.injector_attach.restype = c_int32 -libinjector.injector_inject.argtypes = injector_pointer_t, c_char_p, POINTER(c_void_p) -libinjector.injector_inject.restype = c_int32 -libinjector.injector_detach.argtypes = injector_pointer_t, -libinjector.injector_detach.restype = c_int32 -libinjector.injector_error.argtypes = () -libinjector.injector_error.restype = c_char_p +from .injector import Injector, InjectorException class PyInjectorError(Exception): @@ -34,10 +20,10 @@ def __str__(self): class InjectorError(PyInjectorError): - def __init__(self, func_name: str, ret_val: int, error_str: Optional[bytes]): + def __init__(self, func_name: str, ret_val: int, error_str: Optional[str]): self.func_name = func_name self.ret_val = ret_val - self.error_str = error_str.decode() + self.error_str = error_str def _get_extra_explanation(self): return None @@ -46,8 +32,8 @@ def __str__(self): extra = self._get_extra_explanation() explanation = \ 'see error code definition in injector/include/injector.h' if self.error_str is None else \ - (self.error_str if extra is None else '{}\n{}'.format(self.error_str, extra)) - return '{} returned {}: {}'.format(self.func_name, self.ret_val, explanation) + (self.error_str if extra is None else f'{self.error_str}\n{extra}') + return f'Injector failed with {self.ret_val} calling {self.func_name}: {explanation}' class LinuxInjectorPermissionError(InjectorError): @@ -66,59 +52,46 @@ class MacUnknownInjectorError(InjectorError): def _get_extra_explanation(self): issue_link = "https://github.com/kmaork/pyinjector/issues/26" return ( - """Mac restricts injection for security reasons. Please report this error in the issue: -{}""".format(issue_link) + f"Mac restricts injection for security reasons. Please report this error in the issue:\n{issue_link}" if os.geteuid() == 0 else - """Mac restricts injection for security reasons. Please try rerunning as root. + f"""Mac restricts injection for security reasons. Please try rerunning as root. If you need to inject without root permissions, please report here: -{}""".format(issue_link) +{issue_link}""" ) -def call_c_func(func: Callable[..., int], *args: Any, - exception_map: Mapping[Tuple[int, str], Type[InjectorError]] = None) -> None: - ret = func(*args) - if ret != 0: - exception_map = {} if exception_map is None else exception_map - exception_cls = exception_map.get((ret, platform), InjectorError) - raise exception_cls(func.__name__, ret, libinjector.injector_error()) - - -class Injector: - def __init__(self, injector_p: injector_pointer_t): - self.injector_p = injector_p - - @classmethod - def attach(cls, pid: int) -> 'Injector': - assert isinstance(pid, int) - injector_p = injector_pointer_t() - call_c_func(libinjector.injector_attach, byref(injector_p), pid, - exception_map={(-8, 'linux'): LinuxInjectorPermissionError, - (-1, 'darwin'): MacUnknownInjectorError}) - return cls(injector_p) - - def inject(self, library_path: AnyStr) -> int: - if isinstance(library_path, str): - library_path = library_path.encode() - assert isinstance(library_path, bytes) - assert os.path.isfile(library_path), f'Library not found at "{library_path.decode()}"' - handle = c_void_p() - call_c_func(libinjector.injector_inject, self.injector_p, library_path, pointer(handle)) - return handle.value - - def detach(self) -> None: - call_c_func(libinjector.injector_detach, self.injector_p) - - -def inject(pid: int, library_path: AnyStr) -> int: +@contextmanager +def attach(pid: int): + exception_map = {(-8, 'linux'): LinuxInjectorPermissionError, + (-1, 'darwin'): MacUnknownInjectorError} + injector = Injector() + try: + injector.attach(pid) + try: + yield injector + finally: + injector.detach() + except InjectorException as e: + func_name, ret_val, error_str = e.args + exception_cls = exception_map.get((ret_val, platform), InjectorError) + raise exception_cls(func_name, ret_val, error_str) from e + + +def inject(pid: int, library_path: AnyStr, uninject: bool = False) -> int: """ Inject the shared library at library_path to the process (or thread) with the given pid. - Return the handle to the loaded library. + If uninject is True, the library will be unloaded after injection. + Return the handle to the injected library. """ - if not os.path.isfile(library_path): - raise LibraryNotFoundException(library_path) - injector = Injector.attach(pid) - try: - return injector.inject(library_path) - finally: - injector.detach() + if isinstance(library_path, str): + encoded_library_path = library_path.encode() + else: + encoded_library_path = library_path + assert isinstance(encoded_library_path, bytes) + if not os.path.isfile(encoded_library_path): + raise LibraryNotFoundException(encoded_library_path) + with attach(pid) as injector: + handle = injector.inject(encoded_library_path) + if uninject: + injector.uninject(handle) + return handle diff --git a/pyinjector/injector.c b/pyinjector/injector.c new file mode 100644 index 0000000..f79c509 --- /dev/null +++ b/pyinjector/injector.c @@ -0,0 +1,176 @@ +#include +#include +#include "injector.h" +#include + +static PyObject *InjectorException; + +typedef struct { + PyObject_HEAD + injector_t *injector; +} Injector; + +void Injector_raise(char* func_name, int result) +{ + PyObject* error_args = Py_BuildValue("sis", func_name, result, injector_error()); + PyErr_SetObject(InjectorException, error_args); + Py_DECREF(error_args); +} + +static PyObject *Injector_new(PyTypeObject *type, PyObject *args, PyObject *kwds) +{ + Injector *self = (Injector *)type->tp_alloc(type, 0); + if (self == NULL) { + return NULL; + } + self->injector = NULL; + return (PyObject *)self; +} + +static PyObject *Injector_attach(Injector *self, PyObject *args) +{ + injector_pid_t pid; + int result; + + if (!PyArg_ParseTuple(args, "i", &pid)) { + return NULL; + } + + result = injector_attach(&(self->injector), pid); + if (result != INJERR_SUCCESS) { + Injector_raise("injector_attach", result); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject *Injector_inject(Injector *self, PyObject *args) +{ + Py_buffer path_buffer; + void *handle; + int result; + + if (!PyArg_ParseTuple(args, "y*", &path_buffer)) { + return NULL; + } + + result = injector_inject(self->injector, path_buffer.buf, &handle); + PyBuffer_Release(&path_buffer); + if (result != INJERR_SUCCESS) { + Injector_raise("injector_inject", result); + return NULL; + } + + return PyLong_FromVoidPtr(handle); +} + +#if defined(__APPLE__) || defined(__linux) +static PyObject *Injector_call(Injector *self, PyObject *args) +{ + void *handle; + const char *name; + int result; + + if (!PyArg_ParseTuple(args, "Ks", &handle, &name)) { + return NULL; + } + + result = injector_call(self->injector, handle, name); + if (result != INJERR_SUCCESS) { + Injector_raise("injector_call", result); + return NULL; + } + + Py_RETURN_NONE; +} +#endif + +static PyObject *Injector_uninject(Injector *self, PyObject *args) +{ + void *handle; + int result; + + if (!PyArg_ParseTuple(args, "K", &handle)) { + return NULL; + } + + result = injector_uninject(self->injector, handle); + if (result != INJERR_SUCCESS) { + Injector_raise("injector_uninject", result); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject *Injector_detach(Injector *self, PyObject *args) +{ + int result = injector_detach(self->injector); + if (result != INJERR_SUCCESS) { + Injector_raise("injector_detach", result); + return NULL; + } + // injector_detach frees the injector + self->injector = NULL; + Py_RETURN_NONE; +} + +static PyMethodDef Injector_methods[] = { + {"attach", (PyCFunction)Injector_attach, METH_VARARGS, "Attach the injector to a process."}, + {"inject", (PyCFunction)Injector_inject, METH_VARARGS, "Inject a shared library into the process."}, + #if defined(__APPLE__) || defined(__linux) + {"call", (PyCFunction)Injector_call, METH_VARARGS, NULL}, + #endif + {"uninject", (PyCFunction)Injector_uninject, METH_VARARGS, "Uninject a previously injected library."}, + {"detach", (PyCFunction)Injector_detach, METH_NOARGS, "Detach the injector from the process."}, + {NULL} /* Sentinel */ +}; + +static PyTypeObject InjectorType = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "injector.Injector", + .tp_doc = "Low level wrapper for injector functions", + .tp_basicsize = sizeof(Injector), + .tp_itemsize = 0, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_new = Injector_new, + .tp_methods = Injector_methods, +}; + +static PyModuleDef injectormodule = { + PyModuleDef_HEAD_INIT, + .m_name = "injector", + .m_doc = "Python wrapper for the injector library.", + .m_size = -1, +}; + +PyMODINIT_FUNC PyInit_injector(void) +{ + PyObject* m; + + if (PyType_Ready(&InjectorType) < 0) + return NULL; + + m = PyModule_Create(&injectormodule); + if (m == NULL) + return NULL; + + Py_INCREF(&InjectorType); + if (PyModule_AddObject(m, "Injector", (PyObject *)&InjectorType) < 0) { + Py_DECREF(&InjectorType); + Py_DECREF(m); + return NULL; + } + + InjectorException = PyErr_NewException("injector.InjectorException", NULL, NULL); + Py_INCREF(InjectorException); + if (PyModule_AddObject(m, "InjectorException", InjectorException) < 0) { + Py_DECREF(InjectorException); + Py_DECREF(&InjectorType); + Py_DECREF(m); + return NULL; + } + + return m; +} diff --git a/pyinjector/injector.pyi b/pyinjector/injector.pyi new file mode 100644 index 0000000..0912306 --- /dev/null +++ b/pyinjector/injector.pyi @@ -0,0 +1,16 @@ +from typing import Any, Optional, Tuple +from sys import platform + + +class Injector: + def __init__(self) -> None: ... + def attach(self, pid: int) -> None: ... + def inject(self, path: bytes) -> int: ... + def uninject(self, handle: int) -> None: ... + def detach(self) -> None: ... + if platform == "linux" or platform == "darwin": + def call(self, handle: Any, name: str) -> None: ... + + +class InjectorException(Exception): + args: Tuple[str, int, Optional[str]] diff --git a/setup.cfg b/setup.cfg index 8595c5e..2e0cd36 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,7 +5,7 @@ packages = pyinjector [metadata] name = pyinjector -version = 1.2.1 +version = 1.3.0 description = A tool/library allowing dynamic library injection into running processes author = Maor Kleinberger author_email = kmaork@gmail.com diff --git a/setup.py b/setup.py index c0b76d3..6d1c99d 100644 --- a/setup.py +++ b/setup.py @@ -3,25 +3,27 @@ from setuptools import setup, Extension PROJECT_ROOT = Path(__file__).parent.resolve() -LIBINJECTOR_DIR = PROJECT_ROOT / 'injector' +INJECTOR_DIR = PROJECT_ROOT / 'injector' DIR_MAPPING = { 'linux': 'linux', 'win32': 'windows', 'darwin': 'macos', } -LIBINJECTOR_SRC = LIBINJECTOR_DIR / 'src' / DIR_MAPPING[platform] -LIBINJECTOR_WRAPPER = PROJECT_ROOT / 'libinjector.c' +INJECTOR_SRC = INJECTOR_DIR / 'src' / DIR_MAPPING[platform] +INJECTOR_WRAPPER = PROJECT_ROOT / 'pyinjector' / 'injector.c' SOURCES = [str(c.relative_to(PROJECT_ROOT)) - for c in [LIBINJECTOR_WRAPPER, *LIBINJECTOR_SRC.iterdir()] + for c in [INJECTOR_WRAPPER, *INJECTOR_SRC.iterdir()] if c.suffix == '.c'] -libinjector = Extension('pyinjector.libinjector', - sources=SOURCES, - include_dirs=[str(LIBINJECTOR_DIR.relative_to(PROJECT_ROOT) / 'include')], - export_symbols=['injector_attach', 'injector_inject', 'injector_detach', 'injector_error'], - define_macros=[('EM_AARCH64', '183')]) # Needed on CentOS for some reason +injector_extension = Extension( + 'pyinjector.injector', + sources=SOURCES, + include_dirs=[str(INJECTOR_DIR.relative_to(PROJECT_ROOT) / 'include')], + export_symbols=['injector_attach', 'injector_inject', 'injector_detach', 'injector_error'], + define_macros=[('EM_AARCH64', '183')] # Needed on CentOS for some reason +) setup( - ext_modules=[libinjector], + ext_modules=[injector_extension], entry_points={"console_scripts": ["inject=pyinjector.__main__:main"]} ) diff --git a/tests/injection/injection.c b/tests/injection/injection.c index 996f868..7d0cf5c 100644 --- a/tests/injection/injection.c +++ b/tests/injection/injection.c @@ -1,24 +1,21 @@ #include PyMODINIT_FUNC PyInit_pyinjector_tests_injection(void) {return NULL;} +const char *MAGIC = "Let it be green\n"; + #ifdef _WIN32 #include #include - const char *MAGIC = "Hello, world!"; - BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpReserved) { if (fdwReason == DLL_PROCESS_ATTACH) { _write(1, MAGIC, strlen(MAGIC)); - _close(1); } return TRUE; } #else __attribute__((constructor)) static void init(void) { - const char *s = "Hello, world!"; - write(1, s, strlen(s)); - close(1); + write(1, MAGIC, strlen(MAGIC)); } #endif diff --git a/tests/test_pyinjector.py b/tests/test_pyinjector.py index a0685f5..c0d59bc 100644 --- a/tests/test_pyinjector.py +++ b/tests/test_pyinjector.py @@ -2,30 +2,51 @@ import time from subprocess import Popen, PIPE from importlib.util import find_spec -from pyinjector import inject, LibraryNotFoundException -from pytest import raises -INJECTION_LIB_PATH = find_spec('pyinjector_tests_injection').origin -STRING_PRINTED_FROM_LIB = b'Hello, world!' +from pyinjector import inject, LibraryNotFoundException, InjectorError +from pytest import raises, mark, xfail + +INJECTION_LIB_SPEC = find_spec('pyinjector_tests_injection') +assert INJECTION_LIB_SPEC is not None, 'Could not find pyinjector_tests_injection' +origin = INJECTION_LIB_SPEC.origin +assert isinstance(origin, str), 'Could not find pyinjector_tests_injection' +# mypy is weird +INJECTION_LIB_PATH = origin +STRING_PRINTED_FROM_LIB = b'Let it be green\n' TIME_TO_WAIT_FOR_PROCESS_TO_INIT = 1 TIME_TO_WAIT_FOR_INJECTION_TO_RUN = 1 -def test_inject(): +@mark.parametrize('uninject', [True, False]) +def test_inject(uninject: bool): + if uninject and sys.platform == 'linux' and 'musl' in open('/proc/self/maps').read(): + xfail("Can't uninject on musl") # In new virtualenv versions on Windows, python.exe invokes the original python.exe as a subprocess, so the # injection does not affect the target python process. python = getattr(sys, '_base_executable', sys.executable) with Popen([python, '-c', 'while True: pass'], stdout=PIPE) as process: + assert process.stdout is not None try: time.sleep(TIME_TO_WAIT_FOR_PROCESS_TO_INIT) - handle = inject(process.pid, INJECTION_LIB_PATH) + handle1 = inject(process.pid, INJECTION_LIB_PATH, uninject=uninject) + handle2 = inject(process.pid, INJECTION_LIB_PATH, uninject=uninject) time.sleep(TIME_TO_WAIT_FOR_INJECTION_TO_RUN) - assert process.stdout.read() == STRING_PRINTED_FROM_LIB + assert process.stdout.read(len(STRING_PRINTED_FROM_LIB)) == STRING_PRINTED_FROM_LIB + if uninject: + assert process.stdout.read(len(STRING_PRINTED_FROM_LIB)) == STRING_PRINTED_FROM_LIB finally: process.kill() - assert isinstance(handle, int) + assert process.stdout.read() == b'' + assert isinstance(handle1, int) + assert isinstance(handle2, int) def test_inject_no_such_lib(): with raises(LibraryNotFoundException): inject(-1, 'nosuchpath.so') + + +def test_inject_no_such_pid(): + with raises(InjectorError) as excinfo: + inject(-1, INJECTION_LIB_PATH) + assert excinfo.value.ret_val == -3 diff --git a/tox.ini b/tox.ini index 6a70133..ff86503 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,11 @@ [tox] -envlist = py37,py38,py39,py310,py311,sdist +envlist = py37,py38,py39,py310,py311,sdist,lint requires = tox>4 [gh] python = - 3.11 = py311, sdist + 3.11 = py311, sdist, lint 3.10 = py310 3.9 = py39 3.8 = py38 @@ -14,9 +14,16 @@ python = [testenv] package = wheel deps = - pytest + pytest==7.4.0 ./tests/injection commands = pytest tests {posargs} [testenv:sdist] package = sdist + +[testenv:lint] +deps = + mypy==1.4.1 + # For linting tests + pytest==7.4.0 +commands = mypy tests pyinjector --exclude tests/injection