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 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
3afcbbe
Merge branch 'main' into marshall-get-gpos
Marshall-Hallenbeck Jan 9, 2025
c641137
Merge branch 'main' into marshall-get-gpos
Marshall-Hallenbeck Jan 17, 2025
28d8274
docs: add add_loot_data
Marshall-Hallenbeck Jan 17, 2025
9fef9ed
catch errors for get-folder
Marshall-Hallenbeck Jan 17, 2025
01185ff
Remove duplicate function call
NeffIsBack Feb 6, 2025
bd4264d
Merge branch 'main' into marshall-get-gpos
Marshall-Hallenbeck Feb 7, 2025
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
37 changes: 37 additions & 0 deletions nxc/helpers/modules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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):
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}"
11 changes: 8 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 @@ -42,12 +42,17 @@ def on_login(self, context, connection):
raise Exception("Exception that might have occurred")
except Exception as e:
context.log.exception(f"Exception occurred: {e}") # This will display an exception traceback screen after an exception was raised and should only be used for critical errors

# 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_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}")
Marshall-Hallenbeck marked this conversation as resolved.
Show resolved Hide resolved
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")
78 changes: 67 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 @@ -1549,24 +1550,79 @@ 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}")
NeffIsBack marked this conversation as resolved.
Show resolved Hide resolved
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}'")
NeffIsBack marked this conversation as resolved.
Show resolved Hide resolved

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}")

try:
items = self.conn.listPath(self.args.share, ntpath.join(folder, "*"))
except SessionError as e:
self.logger.error(f"Error listing folder '{folder}': {e}")
return
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 @@ -67,9 +67,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 @@ -200,6 +200,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