From c4a3d349d64d11a091eaf66582fc4d7ce42ff66e Mon Sep 17 00:00:00 2001 From: peace-maker Date: Tue, 2 Jan 2024 21:11:57 +0100 Subject: [PATCH] Add support to start a process on Windows (#2310) * Add support to start a process on Windows This is heavily inspired by https://github.com/masthoon/pwintools Allows to use the `process` tube on a Windows host running local processes. I've tried to keep the changes at a minimum. Windows doesn't support non-blocking reads, so I've opted to handle the reading in a separate thread to simulate the non-blocking check. * Fix `misc.which()` on Windows `os.getuid()` is only supported on unix. * Support [read|write]mem, libs and cwd * Update CHANGELOG * Don't cache loaded modules * Wait output on stdout in `process.can_recv_raw` Instead of handling the timeout for reading poorly in `process.recv_raw`, wait for any bytes to be available in `process.can_recv_raw` like on linux. This prevents a 100% spin of one core in `tube.interactive()` since it expects the thread to block for `timeout` seconds while we would return immediately previously. * Remove PythonForWindows dependency This loses the `readmem` and `writemem` feature, but that's not necessary for this basic process startup support. * Fix cyclic imports * Ignore errors in reading thread This could cause a deadlock / fastfail on shutdown when stdout is closed, thus read(1) fails and the exception is tried to be printed to the console while stderr is in the process of getting closed. --- CHANGELOG.md | 2 + pwnlib/tubes/process.py | 125 ++++++++++++++++++++++++++++++---------- pwnlib/util/misc.py | 2 +- pwnlib/util/proc.py | 17 ++++++ 4 files changed, 115 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06d16837c..bf8ed4ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ The table below shows which release corresponds to each branch, and what date th - [#2309][2309] Detect challenge binary and libc in `pwn template` - [#2308][2308] Fix WinExec shellcraft to make sure it's 16 byte aligned - [#2279][2279] Make `pwn template` always set context.binary +- [#2310][2310] Add support to start a process on Windows [2242]: https://github.com/Gallopsled/pwntools/pull/2242 [2277]: https://github.com/Gallopsled/pwntools/pull/2277 @@ -89,6 +90,7 @@ The table below shows which release corresponds to each branch, and what date th [2309]: https://github.com/Gallopsled/pwntools/pull/2309 [2308]: https://github.com/Gallopsled/pwntools/pull/2308 [2279]: https://github.com/Gallopsled/pwntools/pull/2279 +[2310]: https://github.com/Gallopsled/pwntools/pull/2310 ## 4.12.0 (`beta`) diff --git a/pwnlib/tubes/process.py b/pwnlib/tubes/process.py index 8c44e7b7d..af9879bd9 100644 --- a/pwnlib/tubes/process.py +++ b/pwnlib/tubes/process.py @@ -6,16 +6,19 @@ import errno import logging import os -import platform import select import signal -import six import stat import subprocess import sys import time -if sys.platform != 'win32': +IS_WINDOWS = sys.platform.startswith('win') + +if IS_WINDOWS: + import queue + import threading +else: import fcntl import pty import resource @@ -116,6 +119,8 @@ class process(tube): List of arguments to display, instead of the main executable name. alarm(int): Set a SIGALRM alarm timeout on the process. + creationflags(int): + Windows only. Flags to pass to ``CreateProcess``. Examples: @@ -228,7 +233,7 @@ def __init__(self, argv = None, env = None, ignore_environ = None, stdin = PIPE, - stdout = PTY, + stdout = PTY if not IS_WINDOWS else PIPE, stderr = STDOUT, close_fds = True, preexec_fn = lambda: None, @@ -238,6 +243,7 @@ def __init__(self, argv = None, where = 'local', display = None, alarm = None, + creationflags = 0, *args, **kwargs ): @@ -250,6 +256,8 @@ def __init__(self, argv = None, else: raise TypeError('Must provide argv or set context.binary') + if IS_WINDOWS and PTY in (stdin, stdout, stderr): + raise NotImplementedError("ConPTY isn't implemented yet") #: :class:`subprocess.Popen` object that backs this process self.proc = None @@ -258,7 +266,12 @@ def __init__(self, argv = None, original_env = env if shell: - executable_val, argv_val, env_val = executable or '/bin/sh', argv, env + executable_val, argv_val, env_val = executable, argv, env + if executable is None: + if IS_WINDOWS: + executable_val = os.environ.get('ComSpec', 'cmd.exe') + else: + executable_val = '/bin/sh' else: executable_val, argv_val, env_val = self._validate(cwd, executable, argv, env) @@ -266,23 +279,34 @@ def __init__(self, argv = None, if stderr is STDOUT: stderr = stdout - # Determine which descriptors will be attached to a new PTY - handles = (stdin, stdout, stderr) + if IS_WINDOWS: + self.pty = None + self.raw = False + self.aslr = True + self._setuid = False + self.suid = self.uid = None + self.sgid = self.gid = None + internal_preexec_fn = None + else: + # Determine which descriptors will be attached to a new PTY + handles = (stdin, stdout, stderr) + + #: Which file descriptor is the controlling TTY + self.pty = handles.index(PTY) if PTY in handles else None - #: Which file descriptor is the controlling TTY - self.pty = handles.index(PTY) if PTY in handles else None + #: Whether the controlling TTY is set to raw mode + self.raw = raw - #: Whether the controlling TTY is set to raw mode - self.raw = raw + #: Whether ASLR should be left on + self.aslr = aslr if aslr is not None else context.aslr - #: Whether ASLR should be left on - self.aslr = aslr if aslr is not None else context.aslr + #: Whether setuid is permitted + self._setuid = setuid if setuid is None else bool(setuid) - #: Whether setuid is permitted - self._setuid = setuid if setuid is None else bool(setuid) + # Create the PTY if necessary + stdin, stdout, stderr, master, slave = self._handles(*handles) - # Create the PTY if necessary - stdin, stdout, stderr, master, slave = self._handles(*handles) + internal_preexec_fn = self.__preexec_fn #: Arguments passed on argv self.argv = argv_val @@ -341,7 +365,8 @@ def __init__(self, argv = None, stdout = stdout, stderr = stderr, close_fds = close_fds, - preexec_fn = self.__preexec_fn) + preexec_fn = internal_preexec_fn, + creationflags = creationflags) break except OSError as exception: if exception.errno != errno.ENOEXEC: @@ -350,6 +375,16 @@ def __init__(self, argv = None, p.success('pid %i' % self.pid) + if IS_WINDOWS: + self._read_thread = None + self._read_queue = queue.Queue() + if self.proc.stdout: + # Read from stdout in a thread + self._read_thread = threading.Thread(target=_read_in_thread, args=(self._read_queue, self.proc.stdout)) + self._read_thread.daemon = True + self._read_thread.start() + return + if self.pty is not None: if stdin is slave: self.proc.stdin = os.fdopen(os.dup(master), 'r+b', 0) @@ -503,7 +538,8 @@ def cwd(self): '/proc' """ try: - self._cwd = os.readlink('/proc/%i/cwd' % self.pid) + from pwnlib.util.proc import cwd + self._cwd = cwd(self.pid) except Exception: pass @@ -676,6 +712,17 @@ def recv_raw(self, numb): if not self.can_recv_raw(self.timeout): return '' + if IS_WINDOWS: + data = b'' + count = 0 + while count < numb: + if self._read_queue.empty(): + break + last_byte = self._read_queue.get(block=False) + data += last_byte + count += 1 + return data + # This will only be reached if we either have data, # or we have reached an EOF. In either case, it # should be safe to read without expecting it to block. @@ -713,6 +760,12 @@ def can_recv_raw(self, timeout): if not self.connected_raw('recv'): return False + if IS_WINDOWS: + with self.countdown(timeout=timeout): + while self.timeout and self._read_queue.empty(): + time.sleep(0.01) + return not self._read_queue.empty() + try: if timeout is None: return select.select([self.proc.stdout], [], []) == ([self.proc.stdout], [], []) @@ -751,7 +804,7 @@ def close(self): try: fd.close() except IOError as e: - if e.errno != errno.EPIPE: + if e.errno != errno.EPIPE and e.errno != errno.EINVAL: raise if not self._stop_noticed: @@ -833,10 +886,8 @@ def libs(self): by the process to the address it is loaded at in the process' address space. """ - try: - maps_raw = open('/proc/%d/maps' % self.pid).read() - except IOError: - maps_raw = None + from pwnlib.util.proc import memory_maps + maps_raw = memory_maps(self.pid) if not maps_raw: import pwnlib.elf.elf @@ -846,18 +897,18 @@ def libs(self): # Enumerate all of the libraries actually loaded right now. maps = {} - for line in maps_raw.splitlines(): - if '/' not in line: continue - path = line[line.index('/'):] + for mapping in maps_raw: + path = mapping.path + if os.sep not in path: continue path = os.path.realpath(path) if path not in maps: maps[path]=0 for lib in maps: path = os.path.realpath(lib) - for line in maps_raw.splitlines(): - if line.endswith(path): - address = line.split('-')[0] + for mapping in maps_raw: + if mapping.path == path: + address = mapping.addr.split('-')[0] maps[lib] = int(address, 16) break @@ -1041,3 +1092,17 @@ def stderr(self): See: :obj:`.process.proc` """ return self.proc.stderr + +# Keep reading the process's output in a separate thread, +# since there's no non-blocking read in python on Windows. +def _read_in_thread(recv_queue, proc_stdout): + try: + while True: + b = proc_stdout.read(1) + if b: + recv_queue.put(b) + else: + break + except: + # Ignore any errors during Python shutdown + pass diff --git a/pwnlib/util/misc.py b/pwnlib/util/misc.py index 7977f7a77..2ca9f31a5 100644 --- a/pwnlib/util/misc.py +++ b/pwnlib/util/misc.py @@ -160,7 +160,7 @@ def which(name, all = False, path=None): if os.path.sep in name: return name - isroot = os.getuid() == 0 + isroot = False if sys.platform == 'win32' else (os.getuid() == 0) out = set() try: path = path or os.environ['PATH'] diff --git a/pwnlib/util/proc.py b/pwnlib/util/proc.py index 895d8de13..9de9ac59e 100644 --- a/pwnlib/util/proc.py +++ b/pwnlib/util/proc.py @@ -223,6 +223,23 @@ def cmdline(pid): """ return psutil.Process(pid).cmdline() +def memory_maps(pid): + """memory_maps(pid) -> list + + Arguments: + pid (int): PID of the process. + + Returns: + A list of the memory mappings in the process. + + Example: + >>> maps = memory_maps(os.getpid()) + >>> [(m.path, m.perms) for m in maps if '[stack]' in m.path] + [('[stack]', 'rw-p')] + + """ + return psutil.Process(pid).memory_maps(grouped=False) + def stat(pid): """stat(pid) -> str list