From 07be104dddb2dc67f4b8995feef7533559c633fb Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Wed, 16 Jun 2021 18:48:10 -0400 Subject: [PATCH 01/25] Added exception handling for state transition --- pwncat/manager.py | 61 +++++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/pwncat/manager.py b/pwncat/manager.py index 88d6997..a767d49 100644 --- a/pwncat/manager.py +++ b/pwncat/manager.py @@ -36,9 +36,9 @@ import pwncat.modules.enumerate from pwncat.util import RawModeExit, console from pwncat.config import Config from pwncat.target import Target -from pwncat.channel import Channel, ChannelClosed +from pwncat.channel import Channel, ChannelError, ChannelClosed from pwncat.commands import CommandParser -from pwncat.platform import Platform +from pwncat.platform import Platform, PlatformError class InteractiveExit(Exception): @@ -319,9 +319,13 @@ class Session: while self.layers: self.layers.pop()(self) - self.platform.exit() - - self.platform.channel.close() + try: + self.platform.exit() + self.platform.channel.close() + except (PlatformError, ChannelError) as exc: + self.log( + f"[yellow]warning[/yellow]: unexpected exception while closing: {exc}" + ) self.died() @@ -574,36 +578,41 @@ class Manager: interactive_complete = threading.Event() - def output_thread_main(): + def output_thread_main(target: Session): while not interactive_complete.is_set(): + try: + data = target.platform.channel.recv(4096) - data = self.target.platform.channel.recv(4096) + if data != b"" and data is not None: + try: + data = target.platform.process_output(data) + sys.stdout.buffer.write(data) + sys.stdout.buffer.flush() + except RawModeExit: + interactive_complete.set() + else: + interactive_complete.wait(timeout=0.1) - if data != b"" and data is not None: - try: - data = self.target.platform.process_output(data) - sys.stdout.buffer.write(data) - sys.stdout.buffer.flush() - except RawModeExit: - interactive_complete.set() - else: - interactive_complete.wait(timeout=0.1) + except ChannelError: + interactive_complete.set() - output_thread = threading.Thread(target=output_thread_main) + output_thread = threading.Thread( + target=output_thread_main, args=[self.target] + ) output_thread.start() - channel_closed = False - try: - self.target.platform.interactive_loop(interactive_complete) - except RawModeExit: - pass + try: + self.target.platform.interactive_loop(interactive_complete) + except RawModeExit: + pass + self.target.platform.interactive = False except ChannelClosed: - channel_closed = True self.log( f"[yellow]warning[/yellow]: {self.target.platform}: connection reset" ) + self.target.died() except Exception: pwncat.util.console.print_exception() @@ -611,12 +620,6 @@ class Manager: interactive_complete.set() output_thread.join() - # Exit interactive mode - if channel_closed: - self.target.died() - else: - self.target.platform.interactive = False - def create_session(self, platform: str, channel: Channel = None, **kwargs): """ Open a new session from a new or existing platform. If the platform From 5b2b11389e44007d2ab130a243201c2791cf9503 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Wed, 16 Jun 2021 18:49:56 -0400 Subject: [PATCH 02/25] Updated changelog for exception handling --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a76753f..15a58f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and simply didn't have the time to go back and retroactively create one. ### Changed - Changed session tracking so session IDs aren't reused - Changed zsh prompt to match CWD of other shell prompts +- Improved exception handling in `Manager.interactive` ## [0.4.2] - 2021-06-15 Quick patch release due to corrected bug in `ChannelFile` which caused command From 882f41626aa2a056b9ee1a3272372a0b8941f143 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Wed, 16 Jun 2021 18:51:45 -0400 Subject: [PATCH 03/25] Added issue link to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15a58f7..97fe2fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and simply didn't have the time to go back and retroactively create one. ### Changed - Changed session tracking so session IDs aren't reused - Changed zsh prompt to match CWD of other shell prompts -- Improved exception handling in `Manager.interactive` +- Improved exception handling in `Manager.interactive` ([#133](https://github.com/calebstewart/pwncat/issues/133)) ## [0.4.2] - 2021-06-15 Quick patch release due to corrected bug in `ChannelFile` which caused command From 8e40b1759d9f2e08da230e097cb623d6db87a51a Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Thu, 17 Jun 2021 13:29:14 -0400 Subject: [PATCH 04/25] Removed C-d loop in favor of static C-d count It appears that you need to send every C-d twice, but I can't figure out why. All manual testing only requires a single C-d, but double each seems to correctly behave with file IO. --- pwncat/commands/upload.py | 11 ----------- pwncat/platform/linux.py | 35 ++++++++++++++++++----------------- pwncat/util.py | 4 ++-- 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/pwncat/commands/upload.py b/pwncat/commands/upload.py index a2d3dc0..13e547d 100644 --- a/pwncat/commands/upload.py +++ b/pwncat/commands/upload.py @@ -45,17 +45,6 @@ class Command(CommandDefinition): if not args.destination: args.destination = f"./{os.path.basename(args.source)}" - # else: - # access = pwncat.victim.access(args.destination) - # if Access.DIRECTORY in access: - # args.destination = os.path.join( - # args.destination, os.path.basename(args.source) - # ) - # elif Access.PARENT_EXIST not in access: - # console.log( - # f"[cyan]{args.destination}[/cyan]: no such file or directory" - # ) - # return try: length = os.path.getsize(args.source) diff --git a/pwncat/platform/linux.py b/pwncat/platform/linux.py index 7abb5c9..63a6300 100644 --- a/pwncat/platform/linux.py +++ b/pwncat/platform/linux.py @@ -420,7 +420,7 @@ class LinuxWriter(BufferedIOBase): for idx, c in enumerate(b): # Track when the last new line was - if c == 0x0D: + if c == 0x0A: self.since_newline = 0 else: self.since_newline += 1 @@ -432,7 +432,7 @@ class LinuxWriter(BufferedIOBase): # Track all characters in translated buffer translated.append(c) - if self.since_newline >= 4095: + if self.since_newline >= 2048: # Flush read immediately to prevent truncation of line translated.append(0x04) self.since_newline = 0 @@ -463,21 +463,22 @@ class LinuxWriter(BufferedIOBase): self.detach() return - # The number of C-d's needed to trigger an EOF in - # the process and exit is inconsistent based on the - # previous input. So, instead of trying to be deterministic, - # we simply send one and check. We do this until we find - # the ending delimeter and then exit. If the `on_close` - # hook was setup properly, this should be fine. - while True: - try: - self.popen.stdin.write(b"\x04") - self.popen.stdin.flush() - # Check for completion - self.popen.wait(timeout=0.1) - break - except pwncat.subprocess.TimeoutExpired: - continue + # Trigger EOF in remote process + self.popen.platform.channel.send(b"\x04") + self.popen.platform.channel.send(b"\x04") + if self.since_newline != 0: + self.popen.platform.channel.send(b"\x04") + self.popen.platform.channel.send(b"\x04") + + # Wait for the process to exit + try: + self.popen.wait() + except KeyboardInterrupt: + # We *should* wait for the process to send the return codes. + # however, if the user wants to stop this process, we should + # at least attempt to do whatever cleanup we can. + self.popen.kill() + self.popen.wait() # Ensure we don't touch stdio again self.detach() diff --git a/pwncat/util.py b/pwncat/util.py index 1d8ec53..b07f3a6 100644 --- a/pwncat/util.py +++ b/pwncat/util.py @@ -118,9 +118,9 @@ def isprintable(data) -> bool: def human_readable_size(size, decimal_places=2): for unit in ["B", "KiB", "MiB", "GiB", "TiB"]: - if size < 1024.0: + if size < 1000.0: return f"{size:.{decimal_places}f}{unit}" - size /= 1024.0 + size /= 1000.0 return f"{size:.{decimal_places}f}{unit}" From af84d1a5a869b41ca3a0221983711b7ab0d53f24 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Thu, 17 Jun 2021 15:57:16 -0400 Subject: [PATCH 05/25] Added correct channel closing logic to Socket The recv method did not used to handle an empty result properly. It now raises a ChannelClosed exception properly. Also, odly, the `Manager.find_session_by_channel` method had never been implemented. --- pwncat/channel/socket.py | 7 ++++++- pwncat/manager.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/pwncat/channel/socket.py b/pwncat/channel/socket.py index befdc67..74f409a 100644 --- a/pwncat/channel/socket.py +++ b/pwncat/channel/socket.py @@ -122,7 +122,12 @@ class Socket(Channel): return data try: - data = data + self.client.recv(count) + new_data = self.client.recv(count) + if new_data == b"": + self._connected = False + raise ChannelClosed(self) + return data + new_data + except BlockingIOError as exc: return data except socket.error as exc: if exc.args[0] == errno.EAGAIN or exc.args[0] == errno.EWOULDBLOCK: diff --git a/pwncat/manager.py b/pwncat/manager.py index a767d49..dc3d129 100644 --- a/pwncat/manager.py +++ b/pwncat/manager.py @@ -641,6 +641,21 @@ class Manager: return session + def find_session_by_channel(self, channel: Channel): + """ + Locate a session by it's channel object. This is mainly used when a ChannelError + is raised in order to locate the misbehaving session object from the exception + data. + + :param channel: the channel you are looking for + :type channel: Channel + :rtype: Session + """ + + for session in self.sessions.values(): + if session.platform.channel is channel: + return session + def _process_input(self, data: bytes, has_prefix: bool): """Process stdin data from the user in raw mode""" From 65c3dd486448407070e1a5e62cbc5d91db88f4db Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Thu, 17 Jun 2021 16:31:52 -0400 Subject: [PATCH 06/25] Brought interactive enabling into exception handler --- pwncat/manager.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/pwncat/manager.py b/pwncat/manager.py index dc3d129..e50d7ea 100644 --- a/pwncat/manager.py +++ b/pwncat/manager.py @@ -574,9 +574,8 @@ class Manager: self.log("no active session, returning to local prompt") continue - self.target.platform.interactive = True - interactive_complete = threading.Event() + output_thread = None def output_thread_main(target: Session): @@ -597,12 +596,14 @@ class Manager: except ChannelError: interactive_complete.set() - output_thread = threading.Thread( - target=output_thread_main, args=[self.target] - ) - output_thread.start() - try: + self.target.platform.interactive = True + + output_thread = threading.Thread( + target=output_thread_main, args=[self.target] + ) + output_thread.start() + try: self.target.platform.interactive_loop(interactive_complete) except RawModeExit: @@ -615,10 +616,10 @@ class Manager: self.target.died() except Exception: pwncat.util.console.print_exception() - - # Trigger thread to exit - interactive_complete.set() - output_thread.join() + finally: + interactive_complete.set() + if output_thread is not None: + output_thread.join() def create_session(self, platform: str, channel: Channel = None, **kwargs): """ From 3c33d015e8e5907931b7543fc04e538cdd261567 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Thu, 17 Jun 2021 17:00:24 -0400 Subject: [PATCH 07/25] Added way for output thread to communicate channel error --- pwncat/manager.py | 21 ++++++++++++++++++--- pwncat/platform/__init__.py | 5 +++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/pwncat/manager.py b/pwncat/manager.py index e50d7ea..4ebb254 100644 --- a/pwncat/manager.py +++ b/pwncat/manager.py @@ -16,6 +16,8 @@ even if there was an uncaught exception. The normal method of creating a manager """ import os import sys +import queue +import signal import fnmatch import pkgutil import threading @@ -576,8 +578,9 @@ class Manager: interactive_complete = threading.Event() output_thread = None + channel_error = None - def output_thread_main(target: Session): + def output_thread_main(target: Session, exception_queue: queue.SimpleQueue): while not interactive_complete.is_set(): try: @@ -593,14 +596,19 @@ class Manager: else: interactive_complete.wait(timeout=0.1) - except ChannelError: + except ChannelError as exc: + exception_queue.put(exc) interactive_complete.set() + # This is a hack to get the interactive loop out of a blocking + # read call. The interactive loop will receive a KeyboardInterrupt + os.kill(os.getpid(), signal.SIGINT) try: self.target.platform.interactive = True + exception_queue = queue.Queue(maxsize=1) output_thread = threading.Thread( - target=output_thread_main, args=[self.target] + target=output_thread_main, args=[self.target, exception_queue] ) output_thread.start() @@ -608,6 +616,12 @@ class Manager: self.target.platform.interactive_loop(interactive_complete) except RawModeExit: pass + + try: + raise exception_queue.get(block=False) + except queue.Empty: + pass + self.target.platform.interactive = False except ChannelClosed: self.log( @@ -620,6 +634,7 @@ class Manager: interactive_complete.set() if output_thread is not None: output_thread.join() + output_thread.join() def create_session(self, platform: str, channel: Channel = None, **kwargs): """ diff --git a/pwncat/platform/__init__.py b/pwncat/platform/__init__.py index dc703b4..74b7a5e 100644 --- a/pwncat/platform/__init__.py +++ b/pwncat/platform/__init__.py @@ -549,6 +549,11 @@ class Platform(ABC): data = sys.stdin.buffer.read(64) has_prefix = self.session.manager._process_input(data, has_prefix) + except KeyboardInterrupt: + # This is a hack to allow the output thread to signal completion + # We are in raw mode so this couldn't have come from a user pressing + # C-d. + pass finally: pwncat.util.pop_term_state() sys.stdin.reconfigure(line_buffering=False) From a1499f1a381c4b0e62462a528ee3a944fcd3bd3d Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Thu, 17 Jun 2021 17:47:07 -0400 Subject: [PATCH 08/25] Added permission checks when opening files Also fixed a tangential problem which arose regarding the group enumerations which caused a recursive call the enumerate groups from within the group enumeration. --- CHANGELOG.md | 1 + pwncat/modules/linux/enumerate/user/group.py | 11 ++- pwncat/platform/linux.py | 87 ++++++++++++++++++-- 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97fe2fc..fb20059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and simply didn't have the time to go back and retroactively create one. - Changed session tracking so session IDs aren't reused - Changed zsh prompt to match CWD of other shell prompts - Improved exception handling in `Manager.interactive` ([#133](https://github.com/calebstewart/pwncat/issues/133)) +- Added explicit permission checks when opening files ## [0.4.2] - 2021-06-15 Quick patch release due to corrected bug in `ChannelFile` which caused command diff --git a/pwncat/modules/linux/enumerate/user/group.py b/pwncat/modules/linux/enumerate/user/group.py index 16734cd..56f373f 100644 --- a/pwncat/modules/linux/enumerate/user/group.py +++ b/pwncat/modules/linux/enumerate/user/group.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 import pwncat -from pwncat.modules import ModuleFailed +from pwncat.modules import Status, ModuleFailed from pwncat.facts.linux import LinuxGroup from pwncat.platform.linux import Linux from pwncat.modules.enumerate import Schedule, EnumerateModule @@ -20,6 +20,7 @@ class Module(EnumerateModule): users = {user.gid: user for user in session.run("enumerate", types=["user"])} group_file = session.platform.Path("/etc/group") + groups = [] try: with group_file.open("r") as filp: @@ -34,13 +35,17 @@ class Module(EnumerateModule): members.append(users[gid].name) # Build a group object - group = LinuxGroup(self.name, group_name, hash, gid, members) + groups.append( + LinuxGroup(self.name, group_name, hash, gid, members) + ) - yield group + yield Status(group_name) except (KeyError, ValueError, IndexError): # Bad group line continue + yield from groups + except (FileNotFoundError, PermissionError) as exc: raise ModuleFailed(str(exc)) from exc diff --git a/pwncat/platform/linux.py b/pwncat/platform/linux.py index 7abb5c9..72ae26a 100644 --- a/pwncat/platform/linux.py +++ b/pwncat/platform/linux.py @@ -11,6 +11,7 @@ Popen can be running at a time. It is imperative that you call to calling any other pwncat methods. """ import os +import stat import time import shlex import shutil @@ -414,6 +415,9 @@ class LinuxWriter(BufferedIOBase): if self.popen is None: raise UnsupportedOperation("writer is detached") + if self.popen.poll() is not None: + raise PermissionError("file write failed") + if self.popen.platform.has_pty: # Control sequences need escaping translated = [] @@ -483,6 +487,49 @@ class LinuxWriter(BufferedIOBase): self.detach() +class LinuxPath(pathlib.PurePosixPath): + """Special cases for Linux remote paths""" + + def readable(self): + """Test if a file is readable""" + + uid = self._target._id["euid"] + gid = self._target._id["egid"] + groups = self._target._id["groups"] + + file_uid = self.stat().st_uid + file_gid = self.stat().st_gid + file_mode = self.stat().st_mode + + if uid == file_uid and (file_mode & stat.S_IRUSR): + return True + elif (gid == file_gid or file_gid in groups) and (file_mode & stat.S_IRGRP): + return True + elif file_mode & stat.S_IROTH: + return True + + return False + + def writable(self): + + uid = self._target._id["euid"] + gid = self._target._id["egid"] + groups = self._target._id["groups"] + + file_uid = self.stat().st_uid + file_gid = self.stat().st_gid + file_mode = self.stat().st_mode + + if uid == file_uid and (file_mode & stat.S_IWUSR): + return True + elif (gid == file_gid or file_gid in groups) and (file_mode & stat.S_IWGRP): + return True + elif file_mode & stat.S_IWOTH: + return True + + return False + + class Linux(Platform): """ Concrete platform class abstracting interaction with a GNU/Linux remote @@ -491,7 +538,7 @@ class Linux(Platform): """ name = "linux" - PATH_TYPE = pathlib.PurePosixPath + PATH_TYPE = LinuxPath PROMPTS = { "sh": """'$(command printf "(remote) $(whoami)@$(hostname):$PWD\\$ ")'""", "dash": """'$(command printf "(remote) $(whoami)@$(hostname):$PWD\\$ ")'""", @@ -507,7 +554,7 @@ class Linux(Platform): self.name = "linux" self.command_running = None - self._uid = None + self._id = None # This causes an stty to be sent. # If we aren't in a pty, it doesn't matter. @@ -766,10 +813,20 @@ class Linux(Platform): while True: try: proc = self.run( - ["id", "-ru"], capture_output=True, text=True, check=True + "(id -ru;id -u;id -g;id -rg;id -G;)", + capture_output=True, + text=True, + check=True, ) - self._uid = int(proc.stdout.rstrip("\n")) - return self._uid + idents = proc.stdout.split("\n") + self._id = { + "ruid": int(idents[0].strip()), + "euid": int(idents[1].strip()), + "rgid": int(idents[2].strip()), + "egid": int(idents[3].strip()), + "groups": [int(g.strip()) for g in idents[4].split(" ")], + } + return self._id["ruid"] except ValueError: continue except CalledProcessError as exc: @@ -777,7 +834,7 @@ class Linux(Platform): def getuid(self): """Retrieve the current cached uid""" - return self._uid + return self._id["ruid"] def getenv(self, name: str): @@ -1153,6 +1210,24 @@ class Linux(Platform): if any(c not in "rwb" for c in mode): raise PlatformError(f"{mode}: unknown file mode") + if isinstance(path, str): + path = self.Path(path) + + if "r" in mode and not path.exists(): + raise FileNotFoundError(f"No such file or directory: {str(path)}") + if "r" in mode and not path.readable(): + raise PermissionError(f"Permission Denied: {str(path)}") + + if "w" in mode: + parent = path.parent + + if "w" in mode and path.exists() and not path.writable(): + raise PermissionError(f"Permission Denied: {str(path)}") + if "w" in mode and not path.exists() and not parent.writable(): + raise PermissionError(f"Permission Denied: {str(path)}") + if "w" in mode and not path.exists() and not parent.exists(): + raise FileNotFoundError(f"No such file or directory: {str(path)}") + # Save this just in case we are opening a text-mode stream line_buffering = buffering == -1 or buffering == 1 From a59857a2fc377a40767235c36a2668194ece4412 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Thu, 17 Jun 2021 17:58:30 -0400 Subject: [PATCH 09/25] Added bare except around entire interactive loop --- pwncat/channel/socket.py | 2 +- pwncat/manager.py | 138 ++++++++++++++++++++------------------- 2 files changed, 73 insertions(+), 67 deletions(-) diff --git a/pwncat/channel/socket.py b/pwncat/channel/socket.py index 74f409a..1c02566 100644 --- a/pwncat/channel/socket.py +++ b/pwncat/channel/socket.py @@ -127,7 +127,7 @@ class Socket(Channel): self._connected = False raise ChannelClosed(self) return data + new_data - except BlockingIOError as exc: + except BlockingIOError: return data except socket.error as exc: if exc.args[0] == errno.EAGAIN or exc.args[0] == errno.EWOULDBLOCK: diff --git a/pwncat/manager.py b/pwncat/manager.py index 4ebb254..20b1e82 100644 --- a/pwncat/manager.py +++ b/pwncat/manager.py @@ -557,84 +557,90 @@ class Manager: while self.interactive_running: - # This is it's own main loop that will continue until - # it catches a C-d sequence. try: - self.parser.run() - except InteractiveExit: - if self.sessions and not confirm( - "There are active sessions. Are you sure?" - ): + # This is it's own main loop that will continue until + # it catches a C-d sequence. + try: + self.parser.run() + except InteractiveExit: + + if self.sessions and not confirm( + "There are active sessions. Are you sure?" + ): + continue + + self.log("closing interactive prompt") + break + + # We can't enter raw mode without a session + if self.target is None: + self.log("no active session, returning to local prompt") continue - self.log("closing interactive prompt") - break + interactive_complete = threading.Event() + output_thread = None - # We can't enter raw mode without a session - if self.target is None: - self.log("no active session, returning to local prompt") - continue + def output_thread_main( + target: Session, exception_queue: queue.SimpleQueue + ): - interactive_complete = threading.Event() - output_thread = None - channel_error = None + while not interactive_complete.is_set(): + try: + data = target.platform.channel.recv(4096) - def output_thread_main(target: Session, exception_queue: queue.SimpleQueue): + if data != b"" and data is not None: + try: + data = target.platform.process_output(data) + sys.stdout.buffer.write(data) + sys.stdout.buffer.flush() + except RawModeExit: + interactive_complete.set() + else: + interactive_complete.wait(timeout=0.1) + + except ChannelError as exc: + exception_queue.put(exc) + interactive_complete.set() + # This is a hack to get the interactive loop out of a blocking + # read call. The interactive loop will receive a KeyboardInterrupt + os.kill(os.getpid(), signal.SIGINT) + + try: + self.target.platform.interactive = True + + exception_queue = queue.Queue(maxsize=1) + output_thread = threading.Thread( + target=output_thread_main, args=[self.target, exception_queue] + ) + output_thread.start() - while not interactive_complete.is_set(): try: - data = target.platform.channel.recv(4096) + self.target.platform.interactive_loop(interactive_complete) + except RawModeExit: + pass - if data != b"" and data is not None: - try: - data = target.platform.process_output(data) - sys.stdout.buffer.write(data) - sys.stdout.buffer.flush() - except RawModeExit: - interactive_complete.set() - else: - interactive_complete.wait(timeout=0.1) + try: + raise exception_queue.get(block=False) + except queue.Empty: + pass - except ChannelError as exc: - exception_queue.put(exc) - interactive_complete.set() - # This is a hack to get the interactive loop out of a blocking - # read call. The interactive loop will receive a KeyboardInterrupt - os.kill(os.getpid(), signal.SIGINT) - - try: - self.target.platform.interactive = True - - exception_queue = queue.Queue(maxsize=1) - output_thread = threading.Thread( - target=output_thread_main, args=[self.target, exception_queue] - ) - output_thread.start() - - try: - self.target.platform.interactive_loop(interactive_complete) - except RawModeExit: - pass - - try: - raise exception_queue.get(block=False) - except queue.Empty: - pass - - self.target.platform.interactive = False - except ChannelClosed: - self.log( - f"[yellow]warning[/yellow]: {self.target.platform}: connection reset" - ) - self.target.died() - except Exception: + self.target.platform.interactive = False + except ChannelClosed: + self.log( + f"[yellow]warning[/yellow]: {self.target.platform}: connection reset" + ) + self.target.died() + finally: + interactive_complete.set() + if output_thread is not None: + output_thread.join() + output_thread.join() + except: # noqa: E722 + # We don't want to die because of an uncaught exception, but + # at least let the user know something happened. This should + # probably be configurable somewhere. pwncat.util.console.print_exception() - finally: - interactive_complete.set() - if output_thread is not None: - output_thread.join() - output_thread.join() def create_session(self, platform: str, channel: Channel = None, **kwargs): """ From 89ad889977f0a902a4c14fad118207fe0bf2435e Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Thu, 17 Jun 2021 18:08:50 -0400 Subject: [PATCH 10/25] Changed ChannelError to ChannelClosed in Socket Socket-based channels now raise ChannelClosed if no connection is active and a recv/send method is called. Also, the close method no longer raises an exception if the channel is not active. It is silently ignored as a NOOP. --- pwncat/channel/socket.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pwncat/channel/socket.py b/pwncat/channel/socket.py index 1c02566..435a7ae 100644 --- a/pwncat/channel/socket.py +++ b/pwncat/channel/socket.py @@ -32,7 +32,7 @@ def connect_required(method): @functools.wraps(method) def _wrapper(self, *args, **kwargs): if not self.connected: - raise ChannelError(self, "channel not connected") + raise ChannelClosed(self) return method(self, *args, **kwargs) return _wrapper @@ -136,8 +136,10 @@ class Socket(Channel): self._connected = False raise ChannelClosed(self) from exc - @connect_required def close(self): + if not self._connected: + return + self._connected = False self.client.close() From 6b8a956c54237cead7a783082fe2ae028c9f4bf6 Mon Sep 17 00:00:00 2001 From: Mitul16 Date: Fri, 18 Jun 2021 16:17:53 +0530 Subject: [PATCH 11/25] Fixed ChannelError constructor calls There is a missing argument to the ChannelError constructor - ch (channel). Because of this, many explicitly passed error messages are simply rejected. There is a minor typo correction as well, 'writiers' -> 'writers'. --- pwncat/channel/connect.py | 12 +++++++++++- pwncat/channel/ssh.py | 14 +++++++------- pwncat/manager.py | 6 +++++- pwncat/platform/linux.py | 4 ++-- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/pwncat/channel/connect.py b/pwncat/channel/connect.py index a40214d..e839b4c 100644 --- a/pwncat/channel/connect.py +++ b/pwncat/channel/connect.py @@ -37,7 +37,17 @@ class Connect(Socket): ) as progress: progress.add_task("connecting", total=1, start=False) # Connect to the remote host - client = socket.create_connection((host, port)) + + # If we get an invalid host from the user, that cannot be resolved + # then we capture the GAI (getaddrinfo) exception and raise it as ChannelError + # so that it is handled properly by the parent methods + + try: + client = socket.create_connection((host, port)) + except socket.gaierror: + raise ChannelError(self, "invalid host provided") + except ConnectionRefusedError: + raise ChannelError(self, "connection refused, check your port") progress.log( f"connection to " diff --git a/pwncat/channel/ssh.py b/pwncat/channel/ssh.py index 0937571..8da056f 100644 --- a/pwncat/channel/ssh.py +++ b/pwncat/channel/ssh.py @@ -34,7 +34,7 @@ class Ssh(Channel): port = 22 if not user or user is None: - raise ChannelError("you must specify a user") + raise ChannelError(self, "you must specify a user") if password is None and identity is None: password = prompt("Password: ", is_password=True) @@ -43,7 +43,7 @@ class Ssh(Channel): # Connect to the remote host's ssh server sock = socket.create_connection((host, port)) except Exception as exc: - raise ChannelError(str(exc)) + raise ChannelError(self, str(exc)) # Create a paramiko SSH transport layer around the socket t = paramiko.Transport(sock) @@ -51,7 +51,7 @@ class Ssh(Channel): t.start_client() except paramiko.SSHException: sock.close() - raise ChannelError("ssh negotiation failed") + raise ChannelError(self, "ssh negotiation failed") if identity is not None: try: @@ -67,23 +67,23 @@ class Ssh(Channel): try: key = paramiko.RSAKey.from_private_key_file(identity, password) except paramiko.ssh_exception.SSHException: - raise ChannelError("invalid private key or passphrase") + raise ChannelError(self, "invalid private key or passphrase") # Attempt authentication try: t.auth_publickey(user, key) except paramiko.ssh_exception.AuthenticationException as exc: - raise ChannelError(str(exc)) + raise ChannelError(self, str(exc)) else: try: t.auth_password(user, password) except paramiko.ssh_exception.AuthenticationException as exc: - raise ChannelError(str(exc)) + raise ChannelError(self, str(exc)) if not t.is_authenticated(): t.close() sock.close() - raise ChannelError("authentication failed") + raise ChannelError(self, "authentication failed") # Open an interactive session chan = t.open_session() diff --git a/pwncat/manager.py b/pwncat/manager.py index 20b1e82..ea9da5e 100644 --- a/pwncat/manager.py +++ b/pwncat/manager.py @@ -653,7 +653,11 @@ class Manager: specified platform. """ - session = Session(self, platform, channel, **kwargs) + try: + session = Session(self, platform, channel, **kwargs) + except ChannelError as exc: + self.log(f"[red]error:[/red] {exc}") + return None # Increment the ``session_id`` variable upon adding a new session # Session constructor will automatically grab the current diff --git a/pwncat/platform/linux.py b/pwncat/platform/linux.py index 72ae26a..f604f85 100644 --- a/pwncat/platform/linux.py +++ b/pwncat/platform/linux.py @@ -1249,7 +1249,7 @@ class Linux(Platform): except MissingBinary: pass else: - raise PlatformError("no available gtfobins writiers") + raise PlatformError("no available gtfobins writers") popen = self.Popen( payload, @@ -1278,7 +1278,7 @@ class Linux(Platform): except MissingBinary: pass else: - raise PlatformError("no available gtfobins writiers") + raise PlatformError("no available gtfobins writers") popen = self.Popen( payload, From ffd7f80e0d119198e908be89b8e58333be9b5fcd Mon Sep 17 00:00:00 2001 From: Mitul16 Date: Fri, 18 Jun 2021 16:24:31 +0530 Subject: [PATCH 12/25] Fixed flake8 warning, one unused import --- pwncat/channel/socket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pwncat/channel/socket.py b/pwncat/channel/socket.py index 435a7ae..4017523 100644 --- a/pwncat/channel/socket.py +++ b/pwncat/channel/socket.py @@ -21,7 +21,7 @@ import socket import functools from typing import Optional -from pwncat.channel import Channel, ChannelError, ChannelClosed +from pwncat.channel import Channel, ChannelClosed def connect_required(method): From 231c76fcda32bbfc5f44e6fcad83a6b8241dfca1 Mon Sep 17 00:00:00 2001 From: Mitul16 Date: Fri, 18 Jun 2021 18:12:28 +0530 Subject: [PATCH 13/25] Added a comment about the fix This change was not committed, possibly due to the use of testing directory --- pwncat/channel/connect.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pwncat/channel/connect.py b/pwncat/channel/connect.py index e839b4c..1efee38 100644 --- a/pwncat/channel/connect.py +++ b/pwncat/channel/connect.py @@ -42,6 +42,9 @@ class Connect(Socket): # then we capture the GAI (getaddrinfo) exception and raise it as ChannelError # so that it is handled properly by the parent methods + # We also try to catch ConnectionRefusedError after it + # this is caused when a wrong port number is used + try: client = socket.create_connection((host, port)) except socket.gaierror: From 5544954852945f55091964afdb2f0d511197f413 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Fri, 18 Jun 2021 14:10:56 -0400 Subject: [PATCH 14/25] Added better file io tests which pass --- tests/test_fileio.py | 49 ++++++++++++++++++++++++++++++++++++++++++ tests/test_platform.py | 27 ++++------------------- 2 files changed, 53 insertions(+), 23 deletions(-) create mode 100644 tests/test_fileio.py diff --git a/tests/test_fileio.py b/tests/test_fileio.py new file mode 100644 index 0000000..e1b40a8 --- /dev/null +++ b/tests/test_fileio.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +from pwncat.util import random_string + + +def do_file_test(session, content): + """Do a generic file test""" + + name = random_string() + ".txt" + mode = "b" if isinstance(content, bytes) else "" + + with session.platform.open(name, mode + "w") as filp: + assert filp.write(content) == len(content) + + with session.platform.open(name, mode + "r") as filp: + assert filp.read() == content + + # In some cases, the act of reading/writing causes a shell to hang + # so double check that. + result = session.platform.run( + ["echo", "hello world"], capture_output=True, text=True + ) + assert result.stdout == "hello world\n" + + +def test_small_text(session): + """Test writing a small text-only file""" + + do_file_test(session, "hello world") + + +def test_large_text(session): + """Test writing and reading a large text file""" + + contents = ("A" * 1000 + "\n") * 10 + do_file_test(session, contents) + + +def test_small_binary(session): + """Test writing a small amount of binary data""" + + contents = bytes(list(range(32))) + do_file_test(session, contents) + + +def test_large_binary(session): + + contents = bytes(list(range(32))) * 400 + do_file_test(session, contents) diff --git a/tests/test_platform.py b/tests/test_platform.py index cc1ddcc..c81525e 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -10,27 +10,8 @@ from pwncat.util import random_string from pwncat.platform.windows import PowershellError -def test_platform_file_io(session): - """ Test file read/write of printable data """ - - # Generate random binary data - contents = os.urandom(1024) - - # Create a new temporary file - with session.platform.tempfile(mode="wb") as filp: - filp.write(contents) - path = filp.name - - # Ensure it exists - assert session.platform.Path(path).exists() - - # Read the data back and ensure it matches - with session.platform.open(path, "rb") as filp: - assert contents == filp.read() - - def test_platform_dir_io(session): - """ Test creating a directory and interacting with the contents """ + """Test creating a directory and interacting with the contents""" # Create a path object representing the new remote directory path = session.platform.Path(random_string()) @@ -61,7 +42,7 @@ def test_platform_run(session): def test_platform_su(session): - """ Test running `su` """ + """Test running `su`""" try: session.platform.su("john", "P@ssw0rd") @@ -77,7 +58,7 @@ def test_platform_su(session): def test_platform_sudo(session): - """ Testing running `sudo` """ + """Testing running `sudo`""" try: @@ -103,7 +84,7 @@ def test_platform_sudo(session): def test_windows_powershell(windows): - """ Test powershell execution """ + """Test powershell execution""" # Run a real powershell snippet r = windows.platform.powershell("$PSVersionTable.PSVersion") From 521550dc82dd0907ce70ce91cef2f20e1e25b709 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Fri, 18 Jun 2021 14:12:47 -0400 Subject: [PATCH 15/25] Added CHANGELOG entries for PR141 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a76753f..bf436e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ and simply didn't have the time to go back and retroactively create one. ### Changed - Changed session tracking so session IDs aren't reused - Changed zsh prompt to match CWD of other shell prompts +- Changed LinuxWriter close routine again to account for needed EOF signals ([#140](https://github.com/calebstewart/pwncat/issues/140)) +### Added +- Added better file io test cases ## [0.4.2] - 2021-06-15 Quick patch release due to corrected bug in `ChannelFile` which caused command From b8f53001d27bf8537f2c5f21332163e75d89baea Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Fri, 18 Jun 2021 14:31:45 -0400 Subject: [PATCH 16/25] Fixed exception handler in get_host_hash --- pwncat/platform/linux.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pwncat/platform/linux.py b/pwncat/platform/linux.py index 72ae26a..30c9ca6 100644 --- a/pwncat/platform/linux.py +++ b/pwncat/platform/linux.py @@ -710,7 +710,7 @@ class Linux(Platform): ) hostname = result.stdout.strip() except CalledProcessError: - hostname = self.channel.getpeername()[0] + hostname = self.channel.host try: self.session.update_task( From a33b9ebb563b9912a7bae2ece6e55e10f7de6cd8 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Fri, 18 Jun 2021 14:44:18 -0400 Subject: [PATCH 17/25] Improved create_session documentation, removed try-except --- pwncat/manager.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/pwncat/manager.py b/pwncat/manager.py index ea9da5e..2b9d1e7 100644 --- a/pwncat/manager.py +++ b/pwncat/manager.py @@ -644,20 +644,23 @@ class Manager: def create_session(self, platform: str, channel: Channel = None, **kwargs): """ - Open a new session from a new or existing platform. If the platform - is a string, a new platform is created using ``create_platform`` and - a session is built around the platform. In that case, the arguments - are the same as for ``create_platform``. + Create a new session from a new or existing channel. The platform specified + should be the name registered name (e.g. ``linux``) of a platform class. If + no existing channel is provided, the keyword arguments are used to construct + a new channel. - A new Session object is returned which contains the created or - specified platform. + :param platform: name of the platform to use + :type platform: str + :param channel: A pre-constructed channel (default: None) + :type channel: Optional[Channel] + :param \*\*kwargs: keyword arguments for constructing a new channel + :rtype: Session + :raises: + ChannelError: there was an error while constructing the new channel + PlatformError: construction of a platform around the channel failed """ - try: - session = Session(self, platform, channel, **kwargs) - except ChannelError as exc: - self.log(f"[red]error:[/red] {exc}") - return None + session = Session(self, platform, channel, **kwargs) # Increment the ``session_id`` variable upon adding a new session # Session constructor will automatically grab the current From d6bf81e473a96f82d67ba7c84c2942df472147b8 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Fri, 18 Jun 2021 14:46:53 -0400 Subject: [PATCH 18/25] Fixed invalid escape sequence in docstring --- pwncat/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pwncat/manager.py b/pwncat/manager.py index 2b9d1e7..d01f75a 100644 --- a/pwncat/manager.py +++ b/pwncat/manager.py @@ -643,7 +643,7 @@ class Manager: pwncat.util.console.print_exception() def create_session(self, platform: str, channel: Channel = None, **kwargs): - """ + r""" Create a new session from a new or existing channel. The platform specified should be the name registered name (e.g. ``linux``) of a platform class. If no existing channel is provided, the keyword arguments are used to construct From ccc83215dfeaa371e1dd5375ea8eb547109cc917 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Fri, 18 Jun 2021 15:03:16 -0400 Subject: [PATCH 19/25] Fixed back command exception --- pwncat/commands/back.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pwncat/commands/back.py b/pwncat/commands/back.py index 12e4dab..47964cd 100644 --- a/pwncat/commands/back.py +++ b/pwncat/commands/back.py @@ -11,4 +11,6 @@ class Command(CommandDefinition): ARGS = {} def run(self, manager: "pwncat.manager.Manager", args): - raise RawModeExit + # This is caught by ``CommandParser.run`` which interprets + # it as a `C-d` sequence, and returns to the remote prompt. + raise EOFError From fedf2c617edd9c3fbc7088f5c1e3b20aad7d4e06 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Fri, 18 Jun 2021 15:38:01 -0400 Subject: [PATCH 20/25] Fixed exception handler in connect command --- pwncat/commands/connect.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/pwncat/commands/connect.py b/pwncat/commands/connect.py index cd77bf7..aa7d3f1 100644 --- a/pwncat/commands/connect.py +++ b/pwncat/commands/connect.py @@ -7,8 +7,10 @@ from rich.progress import Progress import pwncat from pwncat.util import console +from pwncat.channel import ChannelError from pwncat.modules import ModuleFailed from pwncat.commands import Complete, Parameter, CommandDefinition +from pwncat.platform import PlatformError class Command(CommandDefinition): @@ -224,18 +226,21 @@ class Command(CommandDefinition): manager.target = session used_implant = implant break - except ModuleFailed: + except (ChannelError, PlatformError, ModuleFailed): continue if used_implant is not None: manager.target.log(f"connected via {used_implant.title(manager.target)}") else: - manager.create_session( - platform=args.platform, - protocol=protocol, - user=user, - password=password, - host=host, - port=port, - identity=args.identity, - ) + try: + manager.create_session( + platform=args.platform, + protocol=protocol, + user=user, + password=password, + host=host, + port=port, + identity=args.identity, + ) + except (ChannelError, PlatformError) as exc: + manager.log(f"connection failed: {exc}") From 650231f30a01892ad012abfe070d2671a52b39a0 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Fri, 18 Jun 2021 15:38:45 -0400 Subject: [PATCH 21/25] Removed unused import --- pwncat/commands/back.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pwncat/commands/back.py b/pwncat/commands/back.py index 47964cd..574a06e 100644 --- a/pwncat/commands/back.py +++ b/pwncat/commands/back.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 import pwncat -from pwncat.manager import RawModeExit from pwncat.commands import CommandDefinition From 6c26df12c111a3cd350bb0cedeb04a2daf19945e Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Fri, 18 Jun 2021 18:30:57 -0400 Subject: [PATCH 22/25] Re-added readline import after regression --- pwncat/platform/windows.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pwncat/platform/windows.py b/pwncat/platform/windows.py index 2221bf8..dfcf78e 100644 --- a/pwncat/platform/windows.py +++ b/pwncat/platform/windows.py @@ -26,6 +26,7 @@ import hashlib import pathlib import tarfile import binascii +import readline # noqa: F401 import functools import threading import subprocess From b680c1f276884f38417b96a9ead6d55147ce6459 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Fri, 18 Jun 2021 18:33:21 -0400 Subject: [PATCH 23/25] Added changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf436e4..4c30cfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and simply didn't have the time to go back and retroactively create one. ### Fixed - Pinned container base image to alpine 3.13.5 and installed to virtualenv ([#134](https://github.com/calebstewart/pwncat/issues/134)) - Fixed syntax for f-strings in escalation command +- Re-added `readline` import for windows platform after being accidentally removed ### Changed - Changed session tracking so session IDs aren't reused - Changed zsh prompt to match CWD of other shell prompts From cce789c99dff6da0e140a6b2c0227d193f9ee530 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Fri, 18 Jun 2021 18:44:23 -0400 Subject: [PATCH 24/25] Updated changelog There's no easy way to classify all the exception handling fixes implemented in this branch, so I'm just going with this... --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb20059..4f5a106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and simply didn't have the time to go back and retroactively create one. ### Changed - Changed session tracking so session IDs aren't reused - Changed zsh prompt to match CWD of other shell prompts -- Improved exception handling in `Manager.interactive` ([#133](https://github.com/calebstewart/pwncat/issues/133)) +- Improved exception handling throughout framework ([#133](https://github.com/calebstewart/pwncat/issues/133)) - Added explicit permission checks when opening files ## [0.4.2] - 2021-06-15 From a95f2df50c3009f8584ceec6ebfaa93f55f22ed9 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Fri, 18 Jun 2021 19:45:59 -0400 Subject: [PATCH 25/25] Fixed Windows platform transitions Exception handling in the output thread was cleaned up and had Windows platform raise the RawModeExit exception to trigger an exit when interactive end marker was observed. --- pwncat/manager.py | 12 ++++++------ pwncat/platform/windows.py | 14 ++++++-------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/pwncat/manager.py b/pwncat/manager.py index d01f75a..4354f9a 100644 --- a/pwncat/manager.py +++ b/pwncat/manager.py @@ -590,12 +590,9 @@ class Manager: data = target.platform.channel.recv(4096) if data != b"" and data is not None: - try: - data = target.platform.process_output(data) - sys.stdout.buffer.write(data) - sys.stdout.buffer.flush() - except RawModeExit: - interactive_complete.set() + data = target.platform.process_output(data) + sys.stdout.buffer.write(data) + sys.stdout.buffer.flush() else: interactive_complete.wait(timeout=0.1) @@ -605,6 +602,9 @@ class Manager: # This is a hack to get the interactive loop out of a blocking # read call. The interactive loop will receive a KeyboardInterrupt os.kill(os.getpid(), signal.SIGINT) + except RawModeExit: + interactive_complete.set() + os.kill(os.getpid(), signal.SIGINT) try: self.target.platform.interactive = True diff --git a/pwncat/platform/windows.py b/pwncat/platform/windows.py index 2221bf8..0e02c8b 100644 --- a/pwncat/platform/windows.py +++ b/pwncat/platform/windows.py @@ -13,7 +13,6 @@ processes and open multiple files with this platform. However, you should be careful to cleanup all processes and files prior to return from your method or code as the C2 will not attempt to garbage collect file or proces handles. """ -import os import sys import gzip import json @@ -46,7 +45,7 @@ PWNCAT_WINDOWS_C2_VERSION = "v0.2.1" PWNCAT_WINDOWS_C2_RELEASE_URL = "https://github.com/calebstewart/pwncat-windows-c2/releases/download/{version}/pwncat-windows-{version}.tar.gz" -class PowershellError(Exception): +class PowershellError(PlatformError): """Executing a powershell script caused an error""" def __init__(self, msg): @@ -55,7 +54,7 @@ class PowershellError(Exception): self.message = msg -class ProtocolError(Exception): +class ProtocolError(PlatformError): def __init__(self, code: int, message: str): self.code = code self.message = message @@ -908,7 +907,7 @@ function prompt { transformed = bytearray(b"") has_cr = False - for b in data: + for idx, b in enumerate(data): # Basically, we just transform bare \r to \r\n if has_cr and b != ord("\n"): @@ -924,10 +923,9 @@ function prompt { if INTERACTIVE_END_MARKER[self.interactive_tracker] == b: self.interactive_tracker += 1 if self.interactive_tracker == len(INTERACTIVE_END_MARKER): - # NOTE: this is a dirty hack to trigger the main input thread - # to leave interactive mode, because it's bound in an input call - os.kill(os.getpid(), signal.SIGINT) - raise pwncat.manager.RawModeExit + self.interactive_tracker = 0 + self.channel.unrecv(data[idx + 1 :]) + raise pwncat.util.RawModeExit else: self.interactive_tracker = 0