diff --git a/pwncat/modules/linux/enumerate/software/cron.py b/pwncat/modules/linux/enumerate/software/cron.py index a0d6536..bd0001b 100644 --- a/pwncat/modules/linux/enumerate/software/cron.py +++ b/pwncat/modules/linux/enumerate/software/cron.py @@ -3,23 +3,27 @@ import dataclasses import os import re +import rich.markup + import pwncat +from pwncat.db import Fact from pwncat.platform.linux import Linux from pwncat.modules import Status from pwncat.modules.agnostic.enumerate import EnumerateModule, Schedule -@dataclasses.dataclass -class CronEntry: +class CronEntry(Fact): + def __init__(self, source, path, uid, command, datetime): + super().__init__(source=source, types=["software.cron.entry"]) - path: str - """ The path to the crontab where this was found """ - uid: int - """ The user ID this entry will run as """ - command: str - """ The command that will execute """ - datetime: str - """ The entire date/time specifier from the crontab entry """ + self.path: str = path + """ The path to the crontab where this was found """ + self.uid: int = uid + """ The user ID this entry will run as """ + self.command: str = command + """ The command that will execute """ + self.datetime: str = datetime + """ The entire date/time specifier from the crontab entry """ def __str__(self): return ( diff --git a/pwncat/modules/linux/enumerate/software/sudo/rules.py b/pwncat/modules/linux/enumerate/software/sudo/rules.py index 1297fbc..6d663fd 100644 --- a/pwncat/modules/linux/enumerate/software/sudo/rules.py +++ b/pwncat/modules/linux/enumerate/software/sudo/rules.py @@ -3,9 +3,12 @@ import dataclasses import re from typing import Generator, Optional, List +import rich.markup + import pwncat -from pwncat.platform.linux import Linux from pwncat import util +from pwncat.db import Fact +from pwncat.platform.linux import Linux from pwncat.modules.agnostic.enumerate import EnumerateModule, Schedule per_user = True @@ -19,30 +22,44 @@ sudo_pattern = re.compile( directives = ["Defaults", "User_Alias", "Runas_Alias", "Host_Alias", "Cmnd_Alias"] -@dataclasses.dataclass -class SudoSpec: +class SudoSpec(Fact): + def __init__( + self, + source, + line: str, + matched: bool = False, + user: Optional[str] = None, + group: Optional[str] = None, + host: Optional[str] = None, + runas_user: Optional[str] = None, + runas_group: Optional[str] = None, + options: List[str] = None, + hash: str = None, + commands: List[str] = None, + ): + super().__init__(source=source, types=["software.sudo.rule"]) - line: str - """ The full, unaltered line from the sudoers file """ - matched: bool = False - """ The regular expression match data. If this is None, all following fields - are invalid and should not be used. """ - user: Optional[str] = None - """ The user which this rule applies to. This is None if a group was specified """ - group: Optional[str] = None - """ The group this rule applies to. This is None if a user was specified. """ - host: Optional[str] = None - """ The host this rule applies to """ - runas_user: Optional[str] = None - """ The user we are allowed to run as """ - runas_group: Optional[str] = None - """ The GID we are allowed to run as (may be None)""" - options: List[str] = None - """ A list of options specified (e.g. NOPASSWD, SETENV, etc) """ - hash: str = None - """ A hash type and value which sudo will obey """ - commands: List[str] = None - """ The command specification """ + self.line: str + """ The full, unaltered line from the sudoers file """ + self.matched: bool = False + """ The regular expression match data. If this is None, all following fields + are invalid and should not be used. """ + self.user: Optional[str] = None + """ The user which this rule applies to. This is None if a group was specified """ + self.group: Optional[str] = None + """ The group this rule applies to. This is None if a user was specified. """ + self.host: Optional[str] = None + """ The host this rule applies to """ + self.runas_user: Optional[str] = None + """ The user we are allowed to run as """ + self.runas_group: Optional[str] = None + """ The GID we are allowed to run as (may be None)""" + self.options: List[str] = None + """ A list of options specified (e.g. NOPASSWD, SETENV, etc) """ + self.hash: str = None + """ A hash type and value which sudo will obey """ + self.commands: List[str] = None + """ The command specification """ def __str__(self): display = "" @@ -51,28 +68,32 @@ class SudoSpec: return self.line if self.user is not None: - display += f"User [blue]{self.user}[/blue]: " + display += f"User [blue]{rich.markup.escape(self.user)}[/blue]: " else: - display += f"Group [cyan]{self.group}[/cyan]: " + display += f"Group [cyan]{rich.markup.escape(self.group)}[/cyan]: " - display += f"[yellow]{'[/yellow], [yellow]'.join(self.commands)}[/yellow] as " + display += f"[yellow]{'[/yellow], [yellow]'.join((rich.markup.escape(x) for c in self.commands))}[/yellow] as " if self.runas_user == "root": display += f"[red]root[/red]" elif self.runas_user is not None: - display += f"[blue]{self.runas_user}[/blue]" + display += f"[blue]{rich.markup.escape(self.runas_user)}[/blue]" if self.runas_group == "root": display += f":[red]root[/red]" elif self.runas_group is not None: - display += f"[cyan]{self.runas_group}[/cyan]" + display += f"[cyan]{rich.markup.escape(self.runas_group)}[/cyan]" if self.host is not None: - display += f" on [magenta]{self.host}[/magenta]" + display += f" on [magenta]{rich.markup.escape(self.host)}[/magenta]" if self.options: display += ( - " (" + ",".join(f"[green]{x}[/green]" for x in self.options) + ")" + " (" + + ",".join( + f"[green]{rich.markup.escape(x)}[/green]" for x in self.options + ) + + ")" ) return display @@ -151,17 +172,17 @@ class Module(EnumerateModule): PLATFORM = [Linux] SCHEDULE = Schedule.PER_USER - def enumerate(self): + def enumerate(self, session): try: - with pwncat.victim.open("/etc/sudoers", "r") as filp: + with session.platform.open("/etc/sudoers", "r") as filp: for line in filp: line = line.strip() # Ignore comments and empty lines if line.startswith("#") or line == "": continue - yield "sudo", LineParser(line) + yield LineParser(line) # No need to parse `sudo -l`, since can read /etc/sudoers return @@ -170,10 +191,13 @@ class Module(EnumerateModule): # Check for our privileges try: - result = pwncat.victim.sudo("-nl", send_password=False).decode("utf-8") - if result.strip() == "sudo: a password is required": - result = pwncat.victim.sudo("-l").decode("utf-8") + + proc = session.platform.sudo(["sudo", "-nl"], as_is=True) + result = proc.stdout.read() + proc.wait() # ensure this closes properly + except PermissionError: + # if this asks for a password and we don't have one, bail return for line in result.split("\n"): @@ -191,6 +215,6 @@ class Module(EnumerateModule): continue # Build the beginning part of a normal spec - line = f"{pwncat.victim.current_user.name} local=" + line.strip() + line = f"{session.current_user()} local=" + line.strip() - yield "software.sudo.rule", LineParser(line) + yield LineParser(line) diff --git a/pwncat/modules/linux/enumerate/software/sudo/version.py b/pwncat/modules/linux/enumerate/software/sudo/version.py index 99dc19b..107079b 100644 --- a/pwncat/modules/linux/enumerate/software/sudo/version.py +++ b/pwncat/modules/linux/enumerate/software/sudo/version.py @@ -2,24 +2,30 @@ import dataclasses import re -from pwncat.modules.agnostic.enumerate import EnumerateModule, Schedule +import rich.markup + import pwncat +from pwncat.db import Fact from pwncat.platform.linux import Linux +from pwncat.subprocess import CalledProcessError +from pwncat.modules.agnostic.enumerate import EnumerateModule, Schedule -@dataclasses.dataclass -class SudoVersion: +class SudoVersion(Fact): """ Version of the installed sudo binary may be useful for exploitation """ - version: str - output: str - vulnerable: bool + def __init__(self, source, version, output, vulnerable): + super().__init__(source=source, types=["software.sudo.version"]) + + self.version: str = version + self.output: str = output + self.vulnerable: bool = vulnerable def __str__(self): - result = f"[yellow]sudo[/yellow] version [cyan]{self.version}[/cyan]" + result = f"[yellow]sudo[/yellow] version [cyan]{rich.markup.escape(self.version)}[/cyan]" if self.vulnerable: result += f" (may be [red]vulnerable[/red])" return result @@ -44,18 +50,23 @@ class Module(EnumerateModule): PLATFORM = [Linux] SCHEDULE = Schedule.ONCE - def enumerate(self): + def enumerate(self, session): """ - Enumerate kernel/OS version information + Enumerate the currently running version of sudo :return: """ try: # Check the sudo version number - result = pwncat.victim.env(["sudo", "--version"]).decode("utf-8").strip() - except FileNotFoundError: + result = session.platform.run( + ["sudo", "--version"], capture_output=True, check=True + ) + except CalledProcessError: + # Something went wrong with the sudo version return + version = result.stdout.decode("utf-8") + # Taken from here: # https://book.hacktricks.xyz/linux-unix/privilege-escalation#sudo-version known_vulnerable = [ @@ -76,7 +87,7 @@ class Module(EnumerateModule): # Can we match this output to a specific sudo version? match = re.search( - r"sudo version ([0-9]+\.[0-9]+\.[^\s]*)", result, re.IGNORECASE + r"sudo version ([0-9]+\.[0-9]+\.[^\s]*)", version, re.IGNORECASE ) if match is not None and match.group(1) is not None: vulnerable = False @@ -87,9 +98,9 @@ class Module(EnumerateModule): vulnerable = True break - yield "sudo.version", SudoVersion(match.group(1), result, vulnerable) + yield SudoVersion(self.name, match.group(1), version, vulnerable) return # We couldn't parse the version out, but at least give the full version # output in the long form/report of enumeration. - yield "software.sudo.version", SudoVersion("unknown", result, False) + yield SudoVersion(self.name, "unknown", version, False)