Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create get_gpos module, implement get-folder command, and add some module helper functions #320

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1409fea
feat(get-gpos): add new module to get GPOs from LDAP
Marshall-Hallenbeck May 16, 2024
3ec55a6
feat(modules): add module helper for logging and loot saving
Marshall-Hallenbeck May 16, 2024
e5151ff
feat(get_gpos): add in more options
Marshall-Hallenbeck May 16, 2024
83a4758
feat(get-gpos): add new module to get GPOs from LDAP
Marshall-Hallenbeck May 16, 2024
13bf6ee
feat(modules): add module helper for logging and loot saving
Marshall-Hallenbeck May 16, 2024
9c952be
feat(get_gpos): add in more options
Marshall-Hallenbeck May 16, 2024
785169f
Merge remote-tracking branch 'refs/remotes/remote/marshall-get-gpos' …
Marshall-Hallenbeck May 21, 2024
0d5daf6
feat(smb): add feature to download entire folders from target
Marshall-Hallenbeck May 22, 2024
b551d11
smb: clean up get-folder code
Marshall-Hallenbeck May 22, 2024
d17460d
add function to get loot data folder for modules
Marshall-Hallenbeck May 22, 2024
beb0885
get_gpos: remove download option as you can only retrieve certain inf…
Marshall-Hallenbeck May 22, 2024
d5883ae
Merge branch 'main' into marshall-get-gpos
Marshall-Hallenbeck Jun 18, 2024
8e1bc7b
feat: add all updated folder download stuff from #335 since that may …
Marshall-Hallenbeck Jun 18, 2024
a0fe150
Merge branch 'main' into marshall-get-gpos
Marshall-Hallenbeck Aug 30, 2024
89f03ed
Merge branch 'main' into marshall-get-gpos
Marshall-Hallenbeck Sep 7, 2024
e70955a
tests: remove options call for get_gpos
Marshall-Hallenbeck Sep 7, 2024
1c2131b
update example module to include new add loot function and fix the name
Marshall-Hallenbeck Sep 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions nxc/helpers/modules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from nxc.paths import NXC_PATH
from os import makedirs
import logging
import datetime


def create_log_dir(module_name):
makedirs(f"{NXC_PATH}/logs/{module_name}", exist_ok=True)

def create_loot_dir(module_name):
makedirs(f"{NXC_PATH}/loot/{module_name}", exist_ok=True)

def generate_module_log_file(module_name):
create_log_dir(module_name)
return f"{NXC_PATH}/logs/{module_name}/{datetime.now().strftime('%Y-%m-%d')}.log"

def create_module_logger(module_name):
create_log_dir(module_name)
log_file = generate_module_log_file(module_name)
module_logger = logging.getLogger(module_name)
module_logger.propagate = False
module_logger.setLevel(logging.INFO)
module_file_handler = logging.FileHandler(log_file)
module_file_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
module_logger.addHandler(module_file_handler)
return module_logger

def add_loot_data(module_name, filename, data):
Marshall-Hallenbeck marked this conversation as resolved.
Show resolved Hide resolved
create_loot_dir(module_name)
loot_file = get_loot_data_filepath(module_name, filename)
with open(loot_file, "a") as file:
file.write(data)

def get_loot_data_filepath(module_name, filename):
return f"{NXC_PATH}/loot/{module_name}/{filename}"

def get_loot_data_folder(module_name):
return f"{NXC_PATH}/loot/{module_name}"
8 changes: 5 additions & 3 deletions nxc/modules/example_module.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@

from nxc.helpers.modules import add_loot_data
class NXCModule:
"""
Example:
-------
Module by @yomama
"""

name = "example module"
name = "example_module" # Make sure this is unique and one word (no spaces)
description = "I do something"
supported_protocols = [] # Example: ['smb', 'mssql']
opsec_safe = True # Does the module touch disk?
Expand Down Expand Up @@ -46,8 +46,10 @@ def on_login(self, context, connection):
def on_admin_login(self, context, connection):
"""Concurrent.
Required if on_login is not present
This gets called on each authenticated connection with Administrative privileges
This gets called on each authenticated connection with Administrative privileges
"""
# Use this function to add loot data you want to save to $NXC_PATH/loot/$MODULE_NAME/$FILENAME
add_loot_data(self.name, "custom_loot_file.txt", "Data can be anything you want, passwords, hashes, or anything")

def on_request(self, context, request):
"""Optional.
Expand Down
66 changes: 66 additions & 0 deletions nxc/modules/get_gpos.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from impacket.ldap import ldapasn1 as ldapasn1_impacket

class NXCModule:
"""Module by @Marshall-Hallenbeck
Retrieves Group Policy Objects (GPOs) in Active Directory
"""

name = "get_gpos"
description = "Retrieves Group Policy Objects (GPOs) in Active Directory"
supported_protocols = ["ldap"]
opsec_safe = True
multiple_hosts = False

def __init__(self):
self.context = None
self.module_options = None
self.gpo_name = None
self.fuzzy_search = False
self.all_props = False

def options(self, context, module_options):
"""
NAME Name of the GPO (default retrieve all GPOs)
FUZZY Fuzzy search for name of GPOs (using wildcards)
ALL_PROPS Retrieve all properties of the GPO (default is name, guid, and sysfile path)
"""
self.gpo_name = module_options.get("NAME")
self.fuzzy_search = module_options.get("FUZZY")
self.all_props = module_options.get("ALL_PROPS")

def on_login(self, context, connection):
# name is actually the GUID of the GPO
attributes = ["*"] if self.all_props else ["displayName", "name", "gPCFileSysPath"]

if self.gpo_name:
context.log.display(f"Searching for GPO '{self.gpo_name}'")
self.gpo_name = f"*{self.gpo_name}*" if self.fuzzy_search else self.gpo_name
search_filter = f"(&(objectCategory=groupPolicyContainer)(displayname={self.gpo_name}))"
else:
context.log.display("Searching for all GPOs")
search_filter = "(objectCategory=groupPolicyContainer)"
context.log.debug(f"Search filter: '{search_filter}'")

results = connection.search(search_filter, attributes, 10000)
results = [r for r in results if isinstance(r, ldapasn1_impacket.SearchResultEntry)]
context.log.success(f"GPOs Found: {len(results)}")

if results:
for gpo in results:
gpo_values = {str(attr["type"]).lower(): str(attr["vals"][0]) for attr in gpo["attributes"]}
context.log.success(f"GPO Found: '{gpo_values['displayname']}'")
for k, v in gpo_values.items():
if self.gpo_name:
if k == "displayname":
context.log.highlight(f"Display Name: {v}")
elif k == "name":
context.log.highlight(f"GUID: {v}")
else:
context.log.highlight(f"{k}: {v}")
Comment on lines +53 to +59
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any specific reason you only "pretty print" the key&value pair if self.gpo_name is set and not always?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll double check but I believe because there's random (default?) GPOs?

else:
context.log.highlight(f"{k}: {v}")
else:
if self.gpo_name:
context.log.error(f"No GPO found with the name '{self.gpo_name}'")
else:
context.log.error("No GPOs found")
74 changes: 63 additions & 11 deletions nxc/protocols/smb.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from impacket.smbconnection import SMBConnection, SessionError
from impacket.smb import SMB_DIALECT
from impacket.smb3structs import FILE_READ_DATA, FILE_WRITE_DATA
from impacket.examples.secretsdump import (
RemoteOperations,
SAMHashes,
Expand Down Expand Up @@ -1399,24 +1400,75 @@ def put_file_single(self, src, dst):
def put_file(self):
for src, dest in self.args.put_file:
self.put_file_single(src, dest)

def get_file_single(self, remote_path, download_path):

def download_file(self, share_name, remote_path, dest_file, access_mode=FILE_READ_DATA):
try:
self.logger.debug(f"Getting file from {share_name}:{remote_path} with access mode {access_mode}")
self.conn.getFile(share_name, remote_path, dest_file, shareAccessMode=access_mode)
return True
except SessionError as e:
if "STATUS_SHARING_VIOLATION" in str(e):
self.logger.debug(f"Sharing violation on {remote_path}: {e}")
return False
except Exception as e:
self.logger.debug(f"Other error when attempting to download file {remote_path}: {e}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo we should include the error message in the fail output not in the debug log

return False

def get_file_single(self, remote_path, download_path, silent=False):
share_name = self.args.share
self.logger.display(f'Copying "{remote_path}" to "{download_path}"')
if not silent:
self.logger.display(f"Copying '{remote_path}' to '{download_path}'")
if self.args.append_host:
download_path = f"{self.hostname}-{remote_path}"
with open(download_path, "wb+") as file:
try:
self.conn.getFile(share_name, remote_path, file.write)
self.logger.success(f'File "{remote_path}" was downloaded to "{download_path}"')
except Exception as e:
self.logger.fail(f'Error writing file "{remote_path}" from share "{share_name}": {e}')
if os.path.getsize(download_path) == 0:
os.remove(download_path)

if self.download_file(share_name, remote_path, file.write):
if not silent:
self.logger.success(f"File '{remote_path}' was downloaded to '{download_path}'")
else:
self.logger.debug("Opening with READ alone failed, trying to open file with READ/WRITE access")
if self.download_file(share_name, remote_path, file.write, FILE_READ_DATA | FILE_WRITE_DATA):
if not silent:
self.logger.success(f"File '{remote_path}' was downloaded to '{download_path}'")
else:
if not silent:
self.logger.fail(f"Error downloading file '{remote_path}' from share '{share_name}'")
Comment on lines +1417 to +1434
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am kinda not a huge fan of having 3 different "get file" functions now. Maybe we should merge this function with download_file and make the access_mode and log message conditional? That way we can also get the error message in the fail message.


def get_file(self):
for src, dest in self.args.get_file:
self.get_file_single(src, dest)

def download_folder(self, folder, dest, recursive=False, silent=False, base_dir=None):
normalized_folder = ntpath.normpath(folder)
base_folder = os.path.basename(normalized_folder)
self.logger.debug(f"Base folder: {base_folder}")

items = self.conn.listPath(self.args.share, ntpath.join(folder, "*"))
self.logger.debug(f"{len(items)} items in folder: {items}")

for item in items:
item_name = item.get_longname()
if item_name not in [".", ".."]:
dir_path = ntpath.normpath(ntpath.join(normalized_folder, item_name))
if item.is_directory() and recursive:
self.download_folder(dir_path, dest, silent, recursive, base_dir or folder)
elif not item.is_directory():
remote_file_path = ntpath.join(folder, item_name)
# change the Windows path to Linux and then join it with the base directory to get our actual save path
relative_path = os.path.join(*folder.replace(base_dir or folder, "").lstrip("\\").split("\\"))
local_folder_path = os.path.join(dest, relative_path)
local_file_path = os.path.join(local_folder_path, item_name)
self.logger.debug(f"{dest=} {remote_file_path=} {relative_path=} {local_folder_path=} {local_file_path=}")

os.makedirs(local_folder_path, exist_ok=True)
try:
self.get_file_single(remote_file_path, local_file_path, silent)
except FileNotFoundError:
self.logger.fail(f"Error downloading file '{remote_file_path}' due to file not found (probably a race condition between listing and downloading)")

def get_folder(self):
recursive = self.args.recursive
for folder, dest in self.args.get_folder:
self.download_folder(folder, dest, recursive)

def enable_remoteops(self):
try:
Expand Down
4 changes: 3 additions & 1 deletion nxc/protocols/smb/proto_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,11 @@ def proto_args(parser, parents):
segroup.add_argument("--pattern", nargs="+", help="pattern(s) to search for in folders, filenames and file content")
segroup.add_argument("--regex", nargs="+", help="regex(s) to search for in folders, filenames and file content")

files_group = smb_parser.add_argument_group("Files", "Options for remote file interaction")
files_group = smb_parser.add_argument_group("Files", "Options for put and get remote files")
files_group.add_argument("--put-file", action="append", nargs=2, metavar="FILE", help="Put a local file into remote target, ex: whoami.txt \\\\Windows\\\\Temp\\\\whoami.txt")
files_group.add_argument("--get-file", action="append", nargs=2, metavar="FILE", help="Get a remote file, ex: \\\\Windows\\\\Temp\\\\whoami.txt whoami.txt")
files_group.add_argument("--get-folder", action="append", nargs=2, metavar="DIR", help="Get a remote directory, ex: \\\\Windows\\\\Temp\\\\testing testing")
files_group.add_argument("--recursive", default=False, action="store_true", help="Recursively get a folder")
files_group.add_argument("--append-host", action="store_true", help="append the host to the get-file filename")

cmd_exec_group = smb_parser.add_argument_group("Command Execution", "Options for executing commands")
Expand Down
7 changes: 7 additions & 0 deletions tests/e2e_commands.txt
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,13 @@ netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M subnets
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M user-desc
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M whoami
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M pso
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get_gpos
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get_gpos -o NAME="Default Domain Policy"
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get_gpos -o NAME="Default Domain Policy" -o ALL_PROPS=True
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get_gpos -o NAME="Default"
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get_gpos -o NAME="Default" -o FUZZY=True
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get_gpos -o NAME="Default" -o FUZZY=True -o ALL_PROPS=True
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get_gpos -o ALL_PROPS=True
##### WINRM
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS # need an extra space after this command due to regex
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -X ipconfig
Expand Down